Repository: podlove/podlove-publisher Branch: master Commit: 5fb91bd0fbff Files: 736 Total size: 3.8 MB Directory structure: gitextract_4vqhqypb/ ├── .distignore ├── .dockerignore ├── .editorconfig ├── .github/ │ ├── CONTRIBUTING.md │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE.md │ └── workflows/ │ ├── docker-image.yml │ ├── release-beta.yml │ ├── release-wordpress.yml │ └── tests.yml ├── .gitignore ├── .gitmodules ├── .php-cs-fixer.dist.php ├── .prettierrc ├── .wp-env.json ├── .wp-env.test.json ├── .zed/ │ └── settings.json ├── AGENTS.md ├── Dockerfile ├── Makefile ├── README.md ├── bin/ │ ├── code-coverage.sh │ ├── docker-entry.sh │ ├── docker-setup.sh │ ├── release.sh │ ├── remove-tunnel.sh │ ├── reset-nux.sh │ ├── template_ref.erb │ ├── template_ref.rb │ ├── template_ref_json.php │ ├── uadetect.php │ ├── update-opawg.sh │ ├── update_pwp4.sh │ ├── workspace.js │ └── wp-env-test-after-start.js ├── bootstrap/ │ ├── autoload.php │ ├── bootstrap.php │ └── constants.php ├── changelog.txt ├── client/ │ ├── .tool-versions │ ├── config.local.template.js │ ├── index.html │ ├── package.json │ ├── postcss.config.js │ ├── src/ │ │ ├── assets/ │ │ │ └── index.d.ts │ │ ├── client.ts │ │ ├── components/ │ │ │ ├── button/ │ │ │ │ └── Button.vue │ │ │ ├── combobox/ │ │ │ │ └── Combobox.vue │ │ │ ├── icons/ │ │ │ │ └── Avatar.vue │ │ │ ├── modal/ │ │ │ │ └── Modal.vue │ │ │ ├── module/ │ │ │ │ └── Module.vue │ │ │ ├── popover/ │ │ │ │ └── Popover.vue │ │ │ ├── steps/ │ │ │ │ └── Steps.vue │ │ │ ├── tabs/ │ │ │ │ ├── Tab.vue │ │ │ │ ├── TabsContainer.vue │ │ │ │ └── index.ts │ │ │ ├── tag/ │ │ │ │ └── Tag.vue │ │ │ └── tooltip/ │ │ │ └── Tooltip.vue │ │ ├── lib/ │ │ │ ├── api.ts │ │ │ ├── array.ts │ │ │ ├── auphonic.api.ts │ │ │ ├── chapters.ts │ │ │ ├── errorHandling.ts │ │ │ ├── license.ts │ │ │ ├── normalplaytime.ts │ │ │ ├── popper.ts │ │ │ ├── statusHelpers.ts │ │ │ ├── timestamp.ts │ │ │ └── wordpress.ts │ │ ├── modules/ │ │ │ ├── auphonic/ │ │ │ │ ├── Auphonic.vue │ │ │ │ ├── components/ │ │ │ │ │ ├── FileChooser.vue │ │ │ │ │ ├── Logo.vue │ │ │ │ │ ├── ManageProductionForm.vue │ │ │ │ │ ├── SelectPreset.vue │ │ │ │ │ ├── SelectProduction.vue │ │ │ │ │ ├── StartScreen.vue │ │ │ │ │ ├── WebhookToggle.vue │ │ │ │ │ └── production_form/ │ │ │ │ │ ├── DonePage.vue │ │ │ │ │ ├── PlusTransferStatus.vue │ │ │ │ │ ├── TransferFileItem.vue │ │ │ │ │ ├── TransferFileList.vue │ │ │ │ │ ├── TransferHeader.vue │ │ │ │ │ └── TransferStatusPanel.vue │ │ │ │ └── index.ts │ │ │ ├── chapters/ │ │ │ │ ├── Chapters.vue │ │ │ │ ├── components/ │ │ │ │ │ ├── Export.vue │ │ │ │ │ ├── Form.vue │ │ │ │ │ └── Import.vue │ │ │ │ └── index.ts │ │ │ ├── contributors/ │ │ │ │ ├── Contributors.vue │ │ │ │ ├── components/ │ │ │ │ │ ├── AddContribution.vue │ │ │ │ │ └── Contribution.vue │ │ │ │ └── index.ts │ │ │ ├── description/ │ │ │ │ ├── Description.vue │ │ │ │ ├── components/ │ │ │ │ │ ├── EpisodeContent.vue │ │ │ │ │ ├── EpisodeNumber.vue │ │ │ │ │ ├── EpisodePoster.vue │ │ │ │ │ ├── EpisodeSubtitle.vue │ │ │ │ │ ├── EpisodeSummary.vue │ │ │ │ │ ├── EpisodeTitle.vue │ │ │ │ │ └── EpisodeType.vue │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── license/ │ │ │ │ ├── License.vue │ │ │ │ ├── components/ │ │ │ │ │ ├── LicenseName.vue │ │ │ │ │ ├── LicenseSelector.vue │ │ │ │ │ ├── LicenseSelectorButton.vue │ │ │ │ │ ├── LicenseUrl.vue │ │ │ │ │ └── LicenseView.vue │ │ │ │ └── index.ts │ │ │ ├── mediafiles/ │ │ │ │ ├── MediaFiles.vue │ │ │ │ ├── components/ │ │ │ │ │ ├── AssetsEmptyState.vue │ │ │ │ │ ├── AssetsTable.vue │ │ │ │ │ ├── MediaSlug.vue │ │ │ │ │ ├── MediaUpload.vue │ │ │ │ │ └── PlusMediaUpload.vue │ │ │ │ └── index.ts │ │ │ ├── plus_features/ │ │ │ │ ├── Feature.vue │ │ │ │ ├── PlusFeatures.vue │ │ │ │ └── index.ts │ │ │ ├── plus_file_migration/ │ │ │ │ ├── PlusFileMigration.vue │ │ │ │ └── index.ts │ │ │ ├── plus_token/ │ │ │ │ ├── PlusToken.vue │ │ │ │ ├── TokenInput.vue │ │ │ │ └── index.ts │ │ │ ├── related/ │ │ │ │ ├── RelatedEpisodes.vue │ │ │ │ └── index.ts │ │ │ ├── shows/ │ │ │ │ ├── ShowSelect.vue │ │ │ │ └── index.ts │ │ │ ├── soundbite/ │ │ │ │ ├── Soundbite.vue │ │ │ │ ├── components/ │ │ │ │ │ ├── Clear.vue │ │ │ │ │ └── Form.vue │ │ │ │ └── index.ts │ │ │ └── transcripts/ │ │ │ ├── Transcripts.vue │ │ │ ├── components/ │ │ │ │ ├── Delete.vue │ │ │ │ ├── Export.vue │ │ │ │ ├── Import.vue │ │ │ │ ├── List.vue │ │ │ │ └── Voices.vue │ │ │ └── index.ts │ │ ├── plugins/ │ │ │ └── translations.ts │ │ ├── sagas/ │ │ │ ├── admin.sagas.ts │ │ │ ├── api.ts │ │ │ ├── auphonic.api.ts │ │ │ ├── auphonic.sagas.ts │ │ │ ├── chapters.sagas.ts │ │ │ ├── contributors.sagas.ts │ │ │ ├── episode.sagas.ts │ │ │ ├── helper.ts │ │ │ ├── lifecycle.sagas.ts │ │ │ ├── mediafiles.duration.sagas.ts │ │ │ ├── mediafiles.enable.sagas.ts │ │ │ ├── mediafiles.fileselection.sagas.ts │ │ │ ├── mediafiles.sagas.ts │ │ │ ├── mediafiles.slug.sagas.ts │ │ │ ├── mediafiles.upload.sagas.ts │ │ │ ├── mediafiles.verification.sagas.ts │ │ │ ├── notification.saga.ts │ │ │ ├── plus.sagas.ts │ │ │ ├── plusFileMigration.sagas.ts │ │ │ ├── podcast.sagas.ts │ │ │ ├── relatedEpisodes.sagas.ts │ │ │ ├── shows.sagas.ts │ │ │ ├── transcripts.sagas.ts │ │ │ └── wordpress.sagas.ts │ │ ├── store/ │ │ │ ├── admin.store.ts │ │ │ ├── auphonic.store.ts │ │ │ ├── chapters.store.ts │ │ │ ├── contributors.store.ts │ │ │ ├── episode.store.ts │ │ │ ├── index.ts │ │ │ ├── lifecycle.store.ts │ │ │ ├── mediafiles.store.ts │ │ │ ├── notification.store.ts │ │ │ ├── plus.store.ts │ │ │ ├── plusFileMigration.store.ts │ │ │ ├── podcast.store.ts │ │ │ ├── post.store.ts │ │ │ ├── progress.store.ts │ │ │ ├── reducers.ts │ │ │ ├── relatedEpisodes.store.ts │ │ │ ├── runtime.store.ts │ │ │ ├── selectors.ts │ │ │ ├── settings.store.ts │ │ │ ├── shows.store.ts │ │ │ ├── transcripts.store.ts │ │ │ ├── vue.ts │ │ │ └── wordpress.store.ts │ │ ├── style.css │ │ ├── types/ │ │ │ ├── chapters.types.ts │ │ │ ├── contributors.types.ts │ │ │ ├── episode.types.ts │ │ │ ├── license.types.ts │ │ │ ├── relatedEpisodes.types.ts │ │ │ ├── shows.types.ts │ │ │ └── transcripts.types.ts │ │ └── vue-shims.d.ts │ ├── tailwind.config.js │ ├── tsconfig.json │ ├── typings/ │ │ ├── podlove.d.ts │ │ └── redux-actions.d.ts │ └── vite.config.js ├── composer.json ├── config/ │ └── php-scoper/ │ ├── matomo.inc.php │ ├── monolog.inc.php │ ├── piwik.inc.php │ ├── psr.inc.php │ └── twig.inc.php ├── css/ │ ├── about.css │ ├── admin-font.css │ ├── admin.css │ ├── dc.css │ └── frontend.css ├── data/ │ ├── .gitkeep │ ├── opawg.json │ └── podlove_v2_schema.json ├── devbox.d/ │ └── php/ │ ├── php-fpm.conf │ └── php.ini ├── devbox.json ├── docker-compose.yml ├── includes/ │ ├── about.php │ ├── api/ │ │ ├── admin/ │ │ │ ├── onboarding.php │ │ │ └── plus.php │ │ ├── analytics.php │ │ ├── api.php │ │ ├── chapters.php │ │ ├── episodes/ │ │ │ ├── contributions.php │ │ │ └── related_episodes.php │ │ ├── episodes.php │ │ ├── feeds.php │ │ ├── podcast.php │ │ ├── show.php │ │ └── tools.php │ ├── auto_post_titles.php │ ├── cache.php │ ├── capabilities.php │ ├── chapters.php │ ├── compatibility.php │ ├── db_migration.php │ ├── deprecations.php │ ├── detect_duplicate_slugs.php │ ├── donation_banner.html.php │ ├── donation_banner.img.src │ ├── donation_banner.php │ ├── downloads.php │ ├── episode_number_column.php │ ├── episode_number_quick_edit_form.php │ ├── explicit_content.php │ ├── extras.php │ ├── feed_discovery.php │ ├── frontend_styles.php │ ├── http.php │ ├── images.php │ ├── import.php │ ├── jetpack.php │ ├── license.php │ ├── merge_episodes.php │ ├── modules.php │ ├── no_enclosure_autodiscovery.php │ ├── permalinks.php │ ├── podlove-web-player-5.php │ ├── podlove_data_js_adapter.php │ ├── recording_date.php │ ├── redirects.php │ ├── request_id_rehash.php │ ├── require_curl.php │ ├── screen_options.php │ ├── scripts_and_styles.php │ ├── search.php │ ├── setup.php │ ├── setup_wizard.php │ ├── system_report.php │ ├── template_pages.php │ ├── templates.php │ ├── theme_helper.php │ ├── trash.php │ ├── verify_itunes_category.php │ ├── webhooks.php │ └── wp_rocket.php ├── js/ │ ├── .tool-versions │ ├── admin/ │ │ ├── ace/ │ │ │ ├── ace.js │ │ │ ├── mode-twig.js │ │ │ ├── theme-chrome.js │ │ │ └── theme-github.js │ │ ├── chosen/ │ │ │ ├── chosenImage.css │ │ │ └── chosenImage.jquery.js │ │ ├── cornify.js │ │ ├── dc.js │ │ ├── jquery-ui/ │ │ │ └── css/ │ │ │ └── smoothness/ │ │ │ └── jquery-ui.css │ │ ├── spectrum/ │ │ │ ├── spectrum.css │ │ │ └── spectrum.js │ │ ├── template.js │ │ └── tools/ │ │ └── useragent.js │ ├── package.json │ ├── src/ │ │ ├── admin/ │ │ │ ├── dashboard_asset_validation.js │ │ │ ├── dashboard_feed_validation.js │ │ │ ├── episode.js │ │ │ ├── episode_asset_settings.js │ │ │ ├── feed_settings.js │ │ │ ├── jobs.js │ │ │ ├── jquery.count_characters.js │ │ │ ├── license.js │ │ │ ├── md5.js │ │ │ ├── media.js │ │ │ ├── podlove_data_table.js │ │ │ ├── post_title_autogenerate.js │ │ │ ├── protected_feed.js │ │ │ └── timeago.jquery.js │ │ ├── admin.js │ │ ├── analytics/ │ │ │ ├── common.js │ │ │ ├── episode.js │ │ │ └── totals.js │ │ ├── app.js │ │ ├── components/ │ │ │ ├── AnalyticsDatePicker.vue │ │ │ ├── JobsDashboard.vue │ │ │ ├── Shownotes.vue │ │ │ ├── ShownotesEntry.vue │ │ │ ├── Slacknotes.vue │ │ │ ├── icons/ │ │ │ │ ├── CheveronDown.vue │ │ │ │ ├── CheveronUp.vue │ │ │ │ ├── Close.vue │ │ │ │ ├── DotsVertical.vue │ │ │ │ ├── Edit.vue │ │ │ │ ├── Eye.vue │ │ │ │ ├── EyeOff.vue │ │ │ │ ├── Image.vue │ │ │ │ ├── Link.vue │ │ │ │ ├── Menu.vue │ │ │ │ ├── Refresh.vue │ │ │ │ └── Type.vue │ │ │ ├── shownotes/ │ │ │ │ ├── link-compact.vue │ │ │ │ ├── link-unfurling.vue │ │ │ │ ├── link.vue │ │ │ │ ├── sn-button.vue │ │ │ │ ├── sn-card.vue │ │ │ │ ├── suggestion.vue │ │ │ │ └── topic.vue │ │ │ └── temp.xml │ │ └── lib/ │ │ ├── duration_errors.js │ │ ├── guid.js │ │ └── timestamp.js │ └── webpack.mix.js ├── lib/ │ ├── ajax/ │ │ ├── ajax.analytics_global_total_downloads_by_show.html.php │ │ ├── ajax.php │ │ ├── file_controller.php │ │ └── template_controller.php │ ├── analytics/ │ │ ├── download_intent_cleanup.php │ │ ├── download_sums_calculator.php │ │ ├── episode_download_average.php │ │ └── salt_shaker.php │ ├── api/ │ │ ├── error.php │ │ ├── permissions.php │ │ ├── response.php │ │ └── validation.php │ ├── authentication.php │ ├── cache/ │ │ ├── http_header_validator.php │ │ └── template_cache.php │ ├── chapters_manager.php │ ├── comment/ │ │ └── comment.php │ ├── cron.php │ ├── custom_guid.php │ ├── delete_head_requests.php │ ├── dom_document_fragment.php │ ├── downloads.php │ ├── downloads_list_data.php │ ├── downloads_list_table.php │ ├── duplicate_post.php │ ├── duration.php │ ├── episode_asset_list_table.php │ ├── feed_list_table.php │ ├── feeds/ │ │ ├── base.php │ │ ├── chapters.php │ │ └── rss.php │ ├── feeds.php │ ├── file_type_list_table.php │ ├── form/ │ │ └── input/ │ │ ├── builder.php │ │ ├── div_wrapper.php │ │ ├── table_wrapper.php │ │ └── wrapper.php │ ├── geo_ip.php │ ├── has_page_documentation_trait.php │ ├── helper.php │ ├── http/ │ │ └── curl.php │ ├── jobs/ │ │ ├── counting_job.php │ │ ├── cron_job_runner.php │ │ ├── download_intent_cleanup_job.php │ │ ├── download_timed_aggregator_job.php │ │ ├── job_cleaner.php │ │ ├── job_trait.php │ │ ├── request_id_rehash_job.php │ │ ├── tools_section.php │ │ ├── tools_section_cron_diagnostics.php │ │ └── user_agent_refresh_job.php │ ├── list_table.php │ ├── log.php │ ├── model/ │ │ ├── asset_assignment.php │ │ ├── base.php │ │ ├── download_intent.php │ │ ├── download_intent_clean.php │ │ ├── episode.php │ │ ├── episode_asset.php │ │ ├── feed.php │ │ ├── file_type.php │ │ ├── geo_area.php │ │ ├── geo_area_name.php │ │ ├── image.php │ │ ├── job.php │ │ ├── keeps_blog_reference_trait.php │ │ ├── licensable.php │ │ ├── license.php │ │ ├── media_file.php │ │ ├── network_trait.php │ │ ├── podcast.php │ │ ├── template.php │ │ ├── template_assignment.php │ │ └── user_agent.php │ ├── modules/ │ │ ├── affiliate/ │ │ │ ├── affiliate.php │ │ │ └── podcast_affiliate_settings_tab.php │ │ ├── analytics_heartbeat/ │ │ │ ├── analytics_heartbeat.php │ │ │ └── model/ │ │ │ └── heartbeat.php │ │ ├── asset_validation/ │ │ │ └── asset_validation.php │ │ ├── auphonic/ │ │ │ ├── api_wrapper.php │ │ │ ├── auphonic.php │ │ │ ├── episode_enhancer.php │ │ │ ├── plus_file_transfer.php │ │ │ └── rest_api.php │ │ ├── automatic_numbering/ │ │ │ └── automatic_numbering.php │ │ ├── base.php │ │ ├── categories/ │ │ │ └── categories.php │ │ ├── contributors/ │ │ │ ├── contributor_group_list_table.php │ │ │ ├── contributor_list_table.php │ │ │ ├── contributor_repair.php │ │ │ ├── contributor_role_list_table.php │ │ │ ├── contributors.php │ │ │ ├── gender_stats.php │ │ │ ├── jobs/ │ │ │ │ ├── podcast_import_contributor_episode_contributions_job.php │ │ │ │ ├── podcast_import_contributor_groups_job.php │ │ │ │ ├── podcast_import_contributor_roles_job.php │ │ │ │ ├── podcast_import_contributor_show_contributions_job.php │ │ │ │ └── podcast_import_contributors_job.php │ │ │ ├── js/ │ │ │ │ └── admin.js │ │ │ ├── model/ │ │ │ │ ├── contribution_gender_statistics.php │ │ │ │ ├── contributor.php │ │ │ │ ├── contributor_group.php │ │ │ │ ├── contributor_role.php │ │ │ │ ├── default_contribution.php │ │ │ │ ├── episode_contribution.php │ │ │ │ └── show_contribution.php │ │ │ ├── rest_api.php │ │ │ ├── settings/ │ │ │ │ ├── contributor_defaults.php │ │ │ │ ├── contributor_settings.php │ │ │ │ ├── generic_entity_settings.php │ │ │ │ ├── podcast_contributors_settings_tab.php │ │ │ │ └── tab/ │ │ │ │ ├── contributors.php │ │ │ │ ├── defaults.php │ │ │ │ ├── groups.php │ │ │ │ └── roles.php │ │ │ ├── shortcodes.php │ │ │ ├── template/ │ │ │ │ ├── avatar.php │ │ │ │ ├── contributor.php │ │ │ │ └── contributor_group.php │ │ │ ├── template_extensions.php │ │ │ ├── templates/ │ │ │ │ ├── _contributor-table-flattr.twig │ │ │ │ ├── _contributor-table-row.twig │ │ │ │ ├── avatar.twig │ │ │ │ ├── contributor-comma-separated.twig │ │ │ │ ├── contributor-list.twig │ │ │ │ ├── contributor-table.twig │ │ │ │ ├── podcast-contributor-list.twig │ │ │ │ └── podcast-contributor-table.twig │ │ │ └── views/ │ │ │ └── form_table.php │ │ ├── external_analytics/ │ │ │ └── external_analytics.php │ │ ├── fyyd/ │ │ │ └── fyyd.php │ │ ├── import_export/ │ │ │ ├── export/ │ │ │ │ ├── podcast_exporter.php │ │ │ │ └── tracking_exporter.php │ │ │ ├── import/ │ │ │ │ ├── podcast_import_assets_job.php │ │ │ │ ├── podcast_import_episodes_job.php │ │ │ │ ├── podcast_import_feeds_job.php │ │ │ │ ├── podcast_import_filetypes_job.php │ │ │ │ ├── podcast_import_job_table_trait.php │ │ │ │ ├── podcast_import_job_trait.php │ │ │ │ ├── podcast_import_mediafiles_job.php │ │ │ │ ├── podcast_import_options_job.php │ │ │ │ ├── podcast_import_templates_job.php │ │ │ │ ├── podcast_import_tracking_area_job.php │ │ │ │ ├── podcast_import_tracking_area_name_job.php │ │ │ │ ├── podcast_import_user_agents_job.php │ │ │ │ ├── podcast_importer.php │ │ │ │ ├── podcast_importer_job.php │ │ │ │ ├── tracking_importer.php │ │ │ │ └── tracking_importer_job.php │ │ │ ├── import_export.php │ │ │ └── js/ │ │ │ └── import.js │ │ ├── logging/ │ │ │ ├── log_table.php │ │ │ ├── logging.php │ │ │ ├── wpdbhandler.php │ │ │ └── wpmail_handler.php │ │ ├── networks/ │ │ │ ├── admin_bar_menu.php │ │ │ ├── css/ │ │ │ │ └── admin.css │ │ │ ├── model/ │ │ │ │ ├── network.php │ │ │ │ └── podcast_list.php │ │ │ ├── networks.php │ │ │ ├── podcast_list_list_table.php │ │ │ ├── podcast_list_table.php │ │ │ ├── settings/ │ │ │ │ ├── dashboard.php │ │ │ │ ├── podcast_lists.php │ │ │ │ └── templates.php │ │ │ └── template/ │ │ │ ├── network.php │ │ │ └── podcast_list.php │ │ ├── notifications/ │ │ │ ├── mailer_job.php │ │ │ ├── notifications.php │ │ │ └── settings_tab.php │ │ ├── oembed/ │ │ │ └── oembed.php │ │ ├── onboarding/ │ │ │ ├── css/ │ │ │ │ └── podlove-onboarding-banner.css │ │ │ ├── onboarding.php │ │ │ ├── rest_api.php │ │ │ └── settings/ │ │ │ └── onboarding_page.php │ │ ├── open_graph/ │ │ │ └── open_graph.php │ │ ├── plus/ │ │ │ ├── api.php │ │ │ ├── banner.html.php │ │ │ ├── banner.php │ │ │ ├── early_file_hosting_banner.php │ │ │ ├── feed_proxy.php │ │ │ ├── feed_pusher.php │ │ │ ├── file_storage.php │ │ │ ├── global_feed_settings.php │ │ │ ├── growth_banner.php │ │ │ ├── plus.php │ │ │ ├── promotion_coordinator.php │ │ │ ├── rest_api.php │ │ │ └── settings_page.php │ │ ├── podlove_web_player/ │ │ │ ├── media_tag_renderer.php │ │ │ ├── player_printer_interface.php │ │ │ ├── player_v3/ │ │ │ │ └── player_media_files.php │ │ │ ├── player_v4/ │ │ │ │ ├── html5printer.php │ │ │ │ ├── module.php │ │ │ │ └── pwp4.js │ │ │ ├── player_v5/ │ │ │ │ └── module.php │ │ │ ├── podigee/ │ │ │ │ ├── html5printer.php │ │ │ │ └── module.php │ │ │ └── podlove_web_player.php │ │ ├── protected_feed/ │ │ │ └── protected_feed.php │ │ ├── pubsubhubbub/ │ │ │ └── pubsubhubbub.php │ │ ├── readme.md │ │ ├── related_episodes/ │ │ │ ├── js/ │ │ │ │ └── admin.js │ │ │ ├── model/ │ │ │ │ └── episode_relation.php │ │ │ ├── related_episodes.php │ │ │ ├── shortcodes.php │ │ │ ├── template_extensions.php │ │ │ └── templates/ │ │ │ └── related-episodes-list.twig │ │ ├── seasons/ │ │ │ ├── css/ │ │ │ │ └── admin.css │ │ │ ├── js/ │ │ │ │ └── admin.js │ │ │ ├── model/ │ │ │ │ ├── season.php │ │ │ │ ├── season_map.php │ │ │ │ ├── seasons_issue.php │ │ │ │ └── seasons_validator.php │ │ │ ├── podcast_import_seasons_job.php │ │ │ ├── seasons.php │ │ │ ├── settings/ │ │ │ │ ├── help/ │ │ │ │ │ └── settings.php │ │ │ │ ├── season_list_table.php │ │ │ │ └── settings.php │ │ │ ├── template/ │ │ │ │ └── season.php │ │ │ └── template_extensions.php │ │ ├── shownotes/ │ │ │ ├── model/ │ │ │ │ └── entry.php │ │ │ ├── rest_api.php │ │ │ ├── shownotes.php │ │ │ ├── template/ │ │ │ │ └── entry.php │ │ │ ├── template_extensions.php │ │ │ └── twig/ │ │ │ ├── plain-html-list-grouped.twig │ │ │ ├── plain-html-list.twig │ │ │ └── shownotes.twig │ │ ├── shows/ │ │ │ ├── js/ │ │ │ │ └── admin.js │ │ │ ├── model/ │ │ │ │ └── show.php │ │ │ ├── rest_api.php │ │ │ ├── settings/ │ │ │ │ ├── help/ │ │ │ │ │ └── settings.php │ │ │ │ ├── settings.php │ │ │ │ └── show_list_table.php │ │ │ ├── shows.php │ │ │ ├── template/ │ │ │ │ └── show.php │ │ │ └── template_extensions.php │ │ ├── slack_shownotes/ │ │ │ ├── message.php │ │ │ ├── settings/ │ │ │ │ └── settings.php │ │ │ └── slack_shownotes.php │ │ ├── social/ │ │ │ ├── admin.css │ │ │ ├── data/ │ │ │ │ └── services.yml │ │ │ ├── jobs/ │ │ │ │ ├── podcast_import_contributor_services_job.php │ │ │ │ ├── podcast_import_services_job.php │ │ │ │ └── podcast_import_show_services_job.php │ │ │ ├── js/ │ │ │ │ └── admin.js │ │ │ ├── model/ │ │ │ │ ├── contributor_service.php │ │ │ │ ├── service.php │ │ │ │ └── show_service.php │ │ │ ├── repair_social.php │ │ │ ├── rest_api.php │ │ │ ├── settings/ │ │ │ │ ├── podcast_settings_donation_tab.php │ │ │ │ └── podcast_settings_social_tab.php │ │ │ ├── shortcodes.php │ │ │ ├── social.php │ │ │ ├── template/ │ │ │ │ └── service.php │ │ │ ├── template_extensions.php │ │ │ └── templates/ │ │ │ ├── podcast-donations-list.twig │ │ │ └── podcast-social-media-list.twig │ │ ├── soundbite/ │ │ │ └── soundbite.php │ │ ├── subscribe_button/ │ │ │ ├── button.php │ │ │ ├── js/ │ │ │ │ └── admin.js │ │ │ ├── subscribe_button.php │ │ │ ├── template_extensions.php │ │ │ └── widget.php │ │ ├── title_migration/ │ │ │ ├── notices.php │ │ │ ├── state.php │ │ │ └── title_migration.php │ │ ├── transcripts/ │ │ │ ├── jobs/ │ │ │ │ ├── import_transcripts_job.php │ │ │ │ └── import_voice_assignments_job.php │ │ │ ├── model/ │ │ │ │ ├── transcript.php │ │ │ │ └── voice_assignment.php │ │ │ ├── renderer.php │ │ │ ├── rest_api.php │ │ │ ├── template/ │ │ │ │ ├── group.php │ │ │ │ └── line.php │ │ │ ├── template_extensions.php │ │ │ ├── transcripts.php │ │ │ └── twig/ │ │ │ └── transcript.twig │ │ ├── widgets/ │ │ │ ├── widgets/ │ │ │ │ ├── podcast_information.php │ │ │ │ ├── podcast_license.php │ │ │ │ ├── recent_episodes.php │ │ │ │ └── render_template.php │ │ │ └── widgets.php │ │ └── wordpress_file_upload/ │ │ └── wordpress_file_upload.php │ ├── network.php │ ├── php/ │ │ ├── array.php │ │ └── string.php │ ├── php_deprecation_warning.php │ ├── podcast_post_meta_box.php │ ├── podcast_post_type.php │ ├── repair.php │ ├── settings/ │ │ ├── analytics.php │ │ ├── dashboard/ │ │ │ ├── about.php │ │ │ ├── file_validation.php │ │ │ ├── news.php │ │ │ └── statistics.php │ │ ├── dashboard.php │ │ ├── episode_asset.php │ │ ├── expert/ │ │ │ ├── tab/ │ │ │ │ ├── file_types.php │ │ │ │ ├── metadata.php │ │ │ │ ├── redirects.php │ │ │ │ ├── tracking.php │ │ │ │ ├── web_player.php │ │ │ │ └── website.php │ │ │ ├── tab.php │ │ │ └── tabs.php │ │ ├── feed.php │ │ ├── file_type.php │ │ ├── help/ │ │ │ ├── analytics.php │ │ │ ├── dashboard.php │ │ │ ├── episode_asset.php │ │ │ ├── feed.php │ │ │ ├── modules.php │ │ │ ├── podcast.php │ │ │ ├── settings.php │ │ │ ├── support.php │ │ │ └── templates.php │ │ ├── modules.php │ │ ├── podcast/ │ │ │ ├── tab/ │ │ │ │ ├── description.php │ │ │ │ ├── directory.php │ │ │ │ ├── license.php │ │ │ │ ├── media.php │ │ │ │ └── player.php │ │ │ └── tab.php │ │ ├── podcast.php │ │ ├── settings.php │ │ ├── support.php │ │ ├── templates.php │ │ ├── tools/ │ │ │ └── user_agent_refresh.php │ │ └── tools.php │ ├── shortcodes.php │ ├── slug_freeze.php │ ├── system_report.php │ ├── template/ │ │ ├── asset.php │ │ ├── category.php │ │ ├── chapter.php │ │ ├── date_time.php │ │ ├── duration.php │ │ ├── episode.php │ │ ├── episode_title.php │ │ ├── feed.php │ │ ├── file.php │ │ ├── file_type.php │ │ ├── image.php │ │ ├── license.php │ │ ├── podcast.php │ │ ├── tag.php │ │ ├── twig_date_extension.php │ │ ├── twig_filter.php │ │ ├── twig_loader_podlove_database.php │ │ ├── twig_sandbox.php │ │ └── wrapper.php │ ├── tools.php │ ├── tracking/ │ │ └── debug.php │ ├── version.php │ └── webhook/ │ └── webhook.php ├── license.txt ├── mise.toml ├── package.json ├── phpunit.xml.dist ├── plugin.php ├── podlove.php ├── readme.txt ├── templates/ │ ├── feed-rss2.php │ ├── license.twig │ ├── network/ │ │ └── network-bar.twig │ └── shortcode/ │ ├── downloads-buttons.twig │ ├── downloads-select.twig │ ├── episode-list.twig │ └── feed-list.twig ├── tests/ │ └── phpunit/ │ ├── bootstrap.php │ ├── helpers/ │ │ ├── EpisodeFactory.php │ │ ├── db.php │ │ └── module.php │ ├── integration/ │ │ ├── FeedHtmlOptimizationTest.php │ │ ├── ModuleUninstallTest.php │ │ ├── PluginActivationTest.php │ │ ├── PlusFeedProxyTest.php │ │ ├── SeasonsTest.php │ │ ├── SeasonsValidatorTest.php │ │ └── SlackMessageTest.php │ └── rest/ │ └── EpisodesApiTest.php ├── vetur.config.js └── views/ ├── expert_settings/ │ └── website/ │ ├── blog_post_title.php │ ├── custom_episode_slug.php │ ├── episode_archive.php │ └── landing_page.php └── settings/ └── dashboard/ ├── about.php ├── dashboard.php ├── file_validation.php ├── news.php └── statistics.php ================================================ FILE CONTENTS ================================================ ================================================ FILE: .distignore ================================================ /.wordpress-org /.git /.github /node_modules .distignore .gitignore /.build .php_cs.dist deploy_key.enc mix-manifest.json package-lock.json package.json *.code-workspace webpack.mix.js /vendor/twig/twig *.swp ================================================ FILE: .dockerignore ================================================ !/dist vendor vendor-prefixed ================================================ FILE: .editorconfig ================================================ # EditorConfig helps developers define and maintain consistent # coding styles between different editors and IDEs # editorconfig.org root = true [*] # Change these settings to your own preference indent_style = space indent_size = 2 max_line_length = 120 # We recommend you to keep these unchanged end_of_line = lf charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true [*.md] trim_trailing_whitespace = false [*.html] indent_size = 2 [Makefile] indent_style = tab ================================================ FILE: .github/CONTRIBUTING.md ================================================ Thanks for reading our contribution guidelines! * [Report a Bug](#report-bug) * [Ask for Support](#request-support) * [Theme Incompatibility](#theme-compat) * [Help / Donate](#donate) # Report a Bug Something is not working? Please follow the steps below to help us isolate the cause of error. ### Disable Podlove Cache While testing, disable our internal cache. Put the following at the end of `wp-config.php` ```php # wp-config.php define('PODLOVE_TEMPLATE_CACHE', false); ``` ### Disable other Caches If you are using a caching plugin, please deactivate it. Examples for such plugins are: - W3 Total Cache - WP Super Cache - Quick Cache ### Does it work when you use a default theme (like “twentyfifteen”)? Sometimes 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. ### Does it work when you disable all plugins except the Publisher? Just like the theme, other plugins might interfere with how the Publisher works. ### Now What? You followed the steps above and the error still persists? Create 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. Thank you! # Ask for Support We have a community forum for questions, answers and feature discussions at [community.podlove.org](https://community.podlove.org). Please 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. # Theme Incompatibility Unfortunately, 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**. Only 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). # Donate We are happy about every donation. Please visit [podlove.org/donations](http://podlove.org/donations/) for details. ================================================ FILE: .github/FUNDING.yml ================================================ open_collective: podlove ================================================ FILE: .github/ISSUE_TEMPLATE.md ================================================ ### Expected behavior ### Actual behavior ### System information (see `Podlove > Support` menu) ================================================ FILE: .github/workflows/docker-image.yml ================================================ # https://docs.github.com/en/actions/publishing-packages/publishing-docker-images#publishing-images-to-github-packages name: Create and publish a Docker image # Configures this workflow to run every time a change is pushed to the branch called `release`. on: push: branches: ['beta', 'master'] # 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. env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} # There is a single job in this workflow. It's configured to run on the latest available version of Ubuntu. jobs: build-and-push-image: runs-on: ubuntu-latest # Sets the permissions granted to the `GITHUB_TOKEN` for the actions in this job. permissions: contents: read packages: write attestations: write id-token: write steps: - name: Checkout repository uses: actions/checkout@v4 - name: Install devbox uses: jetify-com/devbox-install-action@v0.11.0 - name: Bootstrap Podlove Publisher run: devbox run bootstrap - name: Build Podlove Publisher run: devbox run build - name: Log in to the Container registry uses: docker/login-action@v3.2.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Extract metadata (tags, labels) for Docker id: meta uses: docker/metadata-action@v5.5.1 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - name: Build and push Docker image id: push uses: docker/build-push-action@v6.1.0 with: context: . push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} ================================================ FILE: .github/workflows/release-beta.yml ================================================ on: push: # Sequence of patterns matched against refs/tags tags: - '*-beta*' name: Beta Release jobs: build: name: Build and Release Beta Version runs-on: ubuntu-24.04 steps: - name: Setup PHP with PECL extension uses: shivammathur/setup-php@v2 with: php-version: '8.0' - name: Checkout code uses: actions/checkout@v3 - name: Build project env: TAG_NAME: ${{ github.ref }} run: | make install_php_scoper make build mv dist podlove-podcasting-plugin-for-wordpress zip -r podlove-podcasting-plugin-for-wordpress.zip podlove-podcasting-plugin-for-wordpress - name: Create Release id: create_release uses: softprops/action-gh-release@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: tag_name: ${{ github.ref }} name: ${{ github.ref }} draft: false prerelease: false files: | podlove-podcasting-plugin-for-wordpress.zip ================================================ FILE: .github/workflows/release-wordpress.yml ================================================ name: Release to WordPress.org on: push: tags: - '*' - '!*-beta*' jobs: tag: name: Build and Release to WordPress.org runs-on: ubuntu-24.04 steps: - name: Setup PHP with PECL extension uses: shivammathur/setup-php@v2 with: php-version: '8.0' - uses: actions/checkout@v3 - name: Build run: | make install_php_scoper make build npm install fs-extra node bin/workspace.js - name: WordPress Plugin Deploy uses: 10up/action-wordpress-plugin-deploy@master env: SVN_PASSWORD: ${{ secrets.SVN_PASSWORD }} SVN_USERNAME: ${{ secrets.SVN_USERNAME }} SLUG: podlove-podcasting-plugin-for-wordpress ================================================ FILE: .github/workflows/tests.yml ================================================ name: Tests on: pull_request: jobs: phpunit: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Set up PHP uses: shivammathur/setup-php@v2 with: php-version: '8.0' extensions: mbstring, xml - name: Set up Node uses: actions/setup-node@v4 with: node-version: '20' - name: Cache wp-env uses: actions/cache@v4 with: path: ~/.wp-env key: ${{ runner.os }}-wp-env-${{ hashFiles('.wp-env.test.json') }} - name: Cache Composer uses: actions/cache@v4 with: path: ~/.composer/cache key: ${{ runner.os }}-composer-${{ hashFiles('composer.lock') }} - name: Cache npm uses: actions/cache@v4 with: path: ~/.npm key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }} - name: Install PHP dependencies (with prefixing) run: make install - name: Install PHP dev dependencies (phpunit) run: composer install --no-interaction --prefer-dist - name: Install Node dependencies run: npm install - name: Start wp-env test environment run: npm run wp-env:test:start - name: Run integration tests run: npm run test ================================================ FILE: .gitignore ================================================ .DS_Store .tags* .wordpress_release *.sublime-* *.code-workspace wprelease.yml composer.phar node_modules js/node_modules client/node_modules vendor/* vendor-*/* test/config.yml doc .htaccess Mix.json mix-manifest.json *.log .vscode dist js/dist lib/modules/podlove_web_player/player_v2/ .php_cs.cache .php-cs-fixer.cache .build/wp* /.vs config.local.js *.cache ================================================ FILE: .gitmodules ================================================ ================================================ FILE: .php-cs-fixer.dist.php ================================================ exclude('vendor') ->in(__DIR__) ; $config = new PhpCsFixer\Config(); $c = $config->setRules([ '@PSR2' => true, '@PhpCsFixer' => true, 'yoda_style' => false, 'fully_qualified_strict_types' => false, 'array_syntax' => ['syntax' => 'short'], 'trailing_comma_in_multiline' => false, 'no_trailing_comma_in_singleline_array' => true, 'blank_line_before_statement' => ['statements' => ['break', 'continue', 'declare', 'default', 'return', 'throw', 'try']], 'visibility_required' => ['elements' => ['method', 'property']] ]) ->setFinder($finder) ; return $c; ================================================ FILE: .prettierrc ================================================ { "printWidth": 100, "jsxBracketSameLine": true, "semi": false, "singleQuote": true, "bracketSpacing": true, "tabWidth": 2, "useTabs": false } ================================================ FILE: .wp-env.json ================================================ { "plugins": ["."], "testsEnvironment": false, "config": { "WP_DEBUG": true, "WP_DEBUG_LOG": true, "WP_DEBUG_DISPLAY": false } } ================================================ FILE: .wp-env.test.json ================================================ { "port": 8889, "testsEnvironment": false, "mappings": { "wp-content/plugins/podlove-podcasting-plugin-for-wordpress": "." }, "lifecycleScripts": { "afterStart": "node bin/wp-env-test-after-start.js", "afterReset": "node bin/wp-env-test-after-start.js" }, "config": { "WP_DEBUG": true, "WP_DEBUG_LOG": true, "WP_DEBUG_DISPLAY": false } } ================================================ FILE: .zed/settings.json ================================================ { "languages": { "PHP": { "language_servers": [ "phpactor", "!intelephense", "!phptools", "..." ], "formatter": [ { "language_server": { "name": "phpactor" } } ], "format_on_save": "on" } }, "lsp": { "phpactor": { "initialization_options": { "language_server_php_cs_fixer.enabled": true, "language_server_php_cs_fixer.bin": "%project_root%/vendor-bin/php-cs-fixer/vendor/friendsofphp/php-cs-fixer/php-cs-fixer", "language_server_php_cs_fixer.config": "%project_root%/.php-cs-fixer.dist.php" } } } } ================================================ FILE: AGENTS.md ================================================ # Repository Guidelines ## Project Structure & Module Organization - `podlove.php` and `plugin.php` are the plugin entry points. - PHP source lives in `includes/` and `lib/`; templates in `templates/`; view helpers in `views/`. - Client assets are split between legacy JS in `js/` and the newer app in `client/`. - Static assets are in `css/`, `images/`, and `fonts/`. - Tests live in `tests/phpunit/` with suites under `integration/` and `rest/`. - Build artifacts are staged in `dist/` (generated by `make build`). ## Build, Test, and Development Commands - `make install`: installs PHP tooling and prefixed dependencies (uses PHP-Scoper). - `make format`: runs PHP-CS-Fixer to format PHP code. - `npm run wp-env:start`: starts the local WordPress development environment on port 8888. - `npm run wp-env:stop`: stops the development `wp-env` environment. - `npm run wp-env:test:start`: starts the dedicated test `wp-env` environment on port 8889. - `npm run wp-env:test:stop`: stops the dedicated test environment. - `npm run test`: runs PHPUnit inside the dedicated test config's `cli` container (start it first). - DB quick query (read-only): `npx wp-env run cli -- wp db query "SELECT * FROM wp_options LIMIT 5;"` - Legacy JS dev: `cd js && npm install && npm run serve`. - Client dev: `cd client && npm install && npm run dev` (set `WORDPRESS_URL=...` for isolated dev). - 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`. ## Coding Style & Naming Conventions - Indentation: 2 spaces (see `.editorconfig`), LF line endings, max line length 120. - PHP formatting is enforced with PHP-CS-Fixer (`.php-cs-fixer.dist.php`). - 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`. - Use descriptive, WordPress-appropriate names for hooks and filters; keep filenames lowercase with underscores where applicable. ## Testing Guidelines - PHPUnit is configured in `phpunit.xml.dist` and bootstraps via `tests/phpunit/bootstrap.php`. - Integration and REST tests live under `tests/phpunit/integration/` and `tests/phpunit/rest/`. - Run tests with `npm run wp-env:test:start` followed by `npm run test`. ## Commit & Pull Request Guidelines - Commit messages follow a lightweight conventional style (examples: `feat: ...`, `fix: ...`, `chore: ...`, `change: ...`). - Changelog entries belong in `readme.txt` under the `== Changelog ==` header. - PRs should include a clear description, linked issues if applicable, and notes on how changes were tested (commands and environment). ## Configuration Tips - Local WordPress uses `wp-env`; see `README.md` for ports and default credentials. - For release builds, use `make build` to generate a clean `dist/` directory. - Read-only database queries are allowed without asking for permission. - 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`. ================================================ FILE: Dockerfile ================================================ FROM wordpress:6-php8.1-apache RUN apt-get update RUN apt-get install zip default-mysql-client -y RUN 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 WORKDIR /var/www/html COPY ./bin/docker-entry.sh /usr/local/bin/entry.sh COPY ./bin/docker-setup.sh /usr/local/bin/setup.sh COPY ./dist wp-content/plugins/podlove-podcasting-plugin-for-wordpress ENTRYPOINT ["entry.sh"] ================================================ FILE: Makefile ================================================ PHP_CS_FIXER = vendor-bin/php-cs-fixer/vendor/friendsofphp/php-cs-fixer/php-cs-fixer release: bin/release.sh format: $(PHP_CS_FIXER) fix . --config .php-cs-fixer.dist.php validateFormat: $(PHP_CS_FIXER) fix . --config .php-cs-fixer.dist.php -v --dry-run --stop-on-violation --using-cache=no update_subscribe_button: rm -rf .tmppsb git clone https://github.com/podlove/podlove-subscribe-button.git .tmppsb rm -rf lib/modules/subscribe_button/dist mv .tmppsb/dist lib/modules/subscribe_button/dist rm -rf .tmppsb player: mkdir -p $(player_dst)/bin mkdir -p $(player_dst)/css mkdir -p $(player_dst)/img mkdir -p $(player_dst)/js/vendor cp -r $(player_src)/css/vendor $(player_dst)/css/vendor cp -r $(player_src)/img/* $(player_dst)/img cp -r $(player_src)/js/*.min.js $(player_dst)/js cp -r $(player_src)/js/vendor/*.min.js $(player_dst)/js/vendor composer_with_prefixing: mkdir -p vendor-prefixed composer install --no-progress --prefer-dist --optimize-autoloader --no-dev composer prefix-dependencies rm -rf vendor/matomo rm -rf vendor/twig rm -rf vendor/monolog rm -rf vendor/psr composer dump-autoload --classmap-authoritative # composer install --no-progress --prefer-dist --optimize-autoloader --no-dev install_php_scoper: mkdir -p vendor-prefixed composer require --dev bamarni/composer-bin-plugin:1.4.1 composer bin php-scoper config minimum-stability dev composer bin php-scoper config prefer-stable true composer bin php-scoper require --dev --update-with-all-dependencies humbug/php-scoper:0.17.5 install_php_cs_fixer: composer bin php-cs-fixer install client_legacy: cd js && npm install cd js && NODE_ENV=production npm run build client_next: cd client && npm install cd client && NODE_ENV=production npm run build client: client_legacy client_next build: make composer_with_prefixing make client rm -rf dist/* mkdir -p dist # move everything into dist rsync -r --exclude=.git --exclude=node_modules --exclude=./dist . dist # cleanup find dist -name "*.git*" | xargs rm -rf rm -rf dist/lib/modules/podlove_web_player/player_v2/player/podlove-web-player/libs rm -rf dist/lib/modules/podlove_web_player/player_v2/player/podlove-web-player/img/banner-772x250.png rm -rf dist/lib/modules/podlove_web_player/player_v2/player/podlove-web-player/img/banner-1544x500.png rm -rf dist/client/src rm -rf dist/client/package-lock.json rm -rf dist/tests rm -rf dist/vendor-bin rm -rf dist/vendor/bin rm -rf dist/vendor/phpunit/php-code-coverage rm -rf dist/vendor/phpunit/phpunit rm -rf dist/vendor/phpunit/phpunit-mock-objects rm -rf dist/vendor/twig/twig/test rm -rf dist/vendor/guzzle/guzzle/tests rm -f dist/.travis.yml rm -rf dist/bin rm -f dist/wprelease.yml rm -f dist/CONTRIBUTING.md rm -f dist/Makefile rm -f dist/phpunit.xml rm -f dist/Rakefile rm -f dist/README.md rm -f dist/*.code-workspace rm -f dist/.prettierrc rm -f dist/.editorconfig rm -rf dist/devbox.d rm -f dist/devbox.json rm -f dist/devbox.lock rm -f dist/Dockerfile rm -f dist/docker-compose.yml find dist -name "*composer.json" | xargs rm -rf find dist -name "*composer.lock" | xargs rm -rf find dist -name "*.swp" | xargs rm -rf # find dist/vendor -type d -iname "test" | xargs rm -rf # find dist/vendor -type d -iname "tests" | xargs rm -rf # player v2 / mediaelement find dist -iname "echo-hereweare.*" | xargs rm -rf find dist -iname "*.jar" | xargs rm -rf install: install_php_scoper install_php_cs_fixer composer_with_prefixing ================================================ FILE: README.md ================================================ # Podlove Podcast Publisher This is the podcast publishing plugin for WordPress. - [Getting Started & Documentation][6] - [Podlove Community][9] - Latest stable version: in [WordPress plugin directory][3] - [Podlove Project & Blog][7] - Report a bug: [Use GitHub Issues][5] [![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) ## Development Setup PHP 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/). Clone the publisher in the `wp-content/plugins` directory. ``` git clone --recursive https://github.com/podlove/podlove-publisher.git cd podlove-publisher curl -sS https://getcomposer.org/installer | php make install ``` If you have a docker environment handy you can simply run: ``` make docker-install ``` ## Development ### Legacy JS Development 1. Change your working direcetory to `js/` 2. Run `npm install` 3. Run `npm run serve` to start the development build 4. Go to your local Wordpress environment and see your changes ### Client Development 1. Create an [Wordpress application password](https://www.paidmembershipspro.com/create-application-password-wordpress/) 2. Update the authorization tokens in= `client/index.html` 3. Change your working directory to `client/` 4. Run `npm install` 5. For isolated development run `WORDPRESS_URL=http://podlove.local npm run dev` with your Wordpress environment 6. For integrated development run `npm run serve` and go to your local Wordpress environment and see your changes ## Testing Integration tests use the official WordPress PHPUnit setup via `wp-env` with a dedicated isolated config. Prerequisites: - Docker is running - Node.js + npm - PHP + Composer First-time setup: ``` composer install composer bin php-scoper install composer bin php-cs-fixer install composer prefix-dependencies npm install npm run wp-env:test:start ``` ## Local Development with wp-env You can use `wp-env` to run a local WordPress instance with the plugin mounted from this repo. Start the environment: ``` npm install npm run wp-env:start ``` Open: - Site: http://localhost:8888 - Admin: http://localhost:8888/wp-admin - Default credentials: admin / password Useful commands: ``` npm run wp-env:stop npx wp-env destroy npx wp-env run cli -- wp option get siteurl ``` Run integration tests: ``` npm run wp-env:test:start npm run test ``` Start the dedicated test environment: ``` npm run wp-env:test:start ``` Open: - Test site: http://localhost:8889 Useful test commands: ``` npm run wp-env:test:stop npm run wp-env:test:destroy npm run wp-env:test:logs ``` If `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: ``` npx wp-env destroy npm run wp-env:start ``` For the test environment use: ``` npm run wp-env:test:destroy npm run wp-env:test:start ``` ## Formatting Code Use [PHP-CS-Fixer](https://github.com/FriendsOfPhp/PHP-CS-Fixer) to format code before committing. You 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. ## Releases Both beta and stable releases are creates with GitHub Actions. To release a new stable version: 1. _manually_ update the following fields in `readme.txt`: - Tested up to - Stable tag - check that changelog has an entry 2. `bash bin/release.sh`, which does: - updates version in `podlove.php` - creates release commit - tags commit 3. git push The GitHub action detects the release via the tag, builds it and submits it to the wordpress.org plugin directory. [3]: https://wordpress.org/plugins/podlove-podcasting-plugin-for-wordpress/ [4]: https://trello.com/b/zB4mKQlD/podlove-publisher [5]: https://github.com/podlove/podlove-publisher/issues [6]: http://docs.podlove.org/ [7]: http://podlove.org/ [8]: https://github.com/podlove/podlove-publisher/releases [9]: https://community.podlove.org/ ## License [![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) ================================================ FILE: bin/code-coverage.sh ================================================ phpunit --coverage-html=../../coverage/report echo "Code Coverage Report: http://podlove-publisher.dev/wp-content/coverage/report/" ================================================ FILE: bin/docker-entry.sh ================================================ #!/usr/bin/env bash set -Eeuo pipefail # Waiting for the MySQL server to start HOST=$(echo $WORDPRESS_DB_HOST | cut -d: -f1) PORT=$(echo $WORDPRESS_DB_HOST | cut -d: -f2) until mysql -h $HOST -P $PORT -D $WORDPRESS_DB_NAME -u $WORDPRESS_DB_USER -p$WORDPRESS_DB_PASSWORD -e '\q'; do >&2 echo "Mysql is unavailable - sleeping..." sleep 2 done uid="$(id -u)" gid="$(id -g)" if [ "$uid" = '0' ]; then user="${APACHE_RUN_USER:-www-data}" group="${APACHE_RUN_GROUP:-www-data}" # strip off any '#' symbol ('#1000' is valid syntax for Apache) pound='#' user="${user#$pound}" group="${group#$pound}" if [ ! -e index.php ] && [ ! -e wp-includes/version.php ]; then # 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) if [ "$uid" = '0' ] && [ "$(stat -c '%u:%g' .)" = '0:0' ]; then chown "$user:$group" . fi echo >&2 "WordPress not found in $PWD - copying now..." if [ -n "$(find -mindepth 1 -maxdepth 1 -not -name wp-content)" ]; then echo >&2 "WARNING: $PWD is not empty! (copying anyhow)" fi sourceTarArgs=( --create --file - --directory /usr/src/wordpress --owner "$user" --group "$group" ) targetTarArgs=( --extract --file - ) if [ "$uid" != '0' ]; then # avoid "tar: .: Cannot utime: Operation not permitted" and "tar: .: Cannot change mode to rwxr-xr-x: Operation not permitted" targetTarArgs+=( --no-overwrite-dir ) fi # loop over "pluggable" content in the source, and if it already exists in the destination, skip it # https://github.com/docker-library/wordpress/issues/506 ("wp-content" persisted, "akismet" updated, WordPress container restarted/recreated, "akismet" downgraded) for contentPath in \ /usr/src/wordpress/.htaccess \ /usr/src/wordpress/wp-content/*/*/ \ ; do contentPath="${contentPath%/}" [ -e "$contentPath" ] || continue contentPath="${contentPath#/usr/src/wordpress/}" # "wp-content/plugins/akismet", etc. if [ -e "$PWD/$contentPath" ]; then echo >&2 "WARNING: '$PWD/$contentPath' exists! (not copying the WordPress version)" sourceTarArgs+=( --exclude "./$contentPath" ) fi done tar "${sourceTarArgs[@]}" . | tar "${targetTarArgs[@]}" echo >&2 "Complete! WordPress has been successfully copied to $PWD" fi wpEnvs=( "${!WORDPRESS_@}" ) if [ ! -s wp-config.php ] && [ "${#wpEnvs[@]}" -gt 0 ]; then for wpConfigDocker in \ wp-config-docker.php \ /usr/src/wordpress/wp-config-docker.php \ ; do if [ -s "$wpConfigDocker" ]; then echo >&2 "No 'wp-config.php' found in $PWD, but 'WORDPRESS_...' variables supplied; copying '$wpConfigDocker' (${wpEnvs[*]})" # 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) awk ' /put your unique phrase here/ { cmd = "head -c1m /dev/urandom | sha1sum | cut -d\\ -f1" cmd | getline str close(cmd) gsub("put your unique phrase here", str) } { print } ' "$wpConfigDocker" > wp-config.php if [ "$uid" = '0' ]; then # attempt to ensure that wp-config.php is owned by the run user # could be on a filesystem that doesn't allow chown (like some NFS setups) chown "$user:$group" wp-config.php || true fi break fi done fi fi eval setup.sh apache2-foreground ================================================ FILE: bin/docker-setup.sh ================================================ #!/usr/bin/env bash ================================================ FILE: bin/release.sh ================================================ #!/usr/bin/env bash PLUGIN_FILE=./podlove.php CURRENT_VERSION=`head -n 20 $PLUGIN_FILE | grep "Version:" | cut -d: -f2 | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//'` echo "Creating a release will:" echo " - update the version in $PLUGIN_FILE" echo " - commit that change" echo " - create a git tag on that commit with the given version" echo "" echo Current Version: $CURRENT_VERSION echo "Input new version:" read version echo "----------" echo "Current Version: $CURRENT_VERSION" echo "New Version: $version" echo "----------" while true; do read -p "Correct & Continue?" yn case $yn in [Yy]* ) sed -i.bak "s/\(Version:\).*/\1 `echo $version | rev | cut -d/ -f1 | rev`/" $PLUGIN_FILE rm $PLUGIN_FILE.bak git add $PLUGIN_FILE git commit -m "release $version" git tag -f $version -m $version break;; [Nn]* ) exit;; * ) echo "Please answer yes or no.";; esac done ================================================ FILE: bin/remove-tunnel.sh ================================================ #!/usr/bin/env bash set -euo pipefail # Reset WordPress URLs back to local defaults after using a tunnel. # Run this from within a Local site shell (where wp-config.php is present) # or from the plugin root when using wp-env. # # Usage: # bin/remove-tunnel.sh # # Override defaults with: # LOCAL_HOST=publisher.local LOCAL_PORT=80 LOCAL_SCHEME=https bin/remove-tunnel.sh if ! command -v wp >/dev/null 2>&1; then echo "Error: wp (WP-CLI) is not available in PATH." >&2 exit 1 fi SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" LOCAL_WP_ENV_BIN="${REPO_ROOT}/node_modules/.bin/wp-env" # Local defaults (Local app). wp-env defaults to localhost:8888. LOCAL_HOST="${LOCAL_HOST:-localhost}" LOCAL_SCHEME="${LOCAL_SCHEME:-http}" LOCAL_PORT="${LOCAL_PORT:-8888}" LOCAL_URL="${LOCAL_SCHEME}://${LOCAL_HOST}:${LOCAL_PORT}/" WP_URL="${WP_URL:-$LOCAL_URL}" set_wp_cli() { if [ -f "wp-config.php" ]; then WP="wp" return fi if command -v wp-env >/dev/null 2>&1; then WP="wp-env run cli wp" return fi if [ -x "${LOCAL_WP_ENV_BIN}" ]; then WP="${LOCAL_WP_ENV_BIN} run cli wp" return fi if command -v npx >/dev/null 2>&1; then if npx --no-install wp-env --version >/dev/null 2>&1; then WP="npx --no-install wp-env run cli wp" return fi fi echo "Error: wp-config.php not found and wp-env is not available." >&2 echo "Run this from a WordPress root or from the plugin root with wp-env installed." >&2 echo "Tip: run npm install (or npm run wp-env:start) to install wp-env locally." >&2 exit 1 } set_wp_cli cat </dev/null 2>&1 || true ${WP} option update siteurl "${WP_URL}" >/dev/null 2>&1 || true ${WP} rewrite flush --hard >/dev/null 2>&1 || true # Local/wp-env may inject redirects via wp-config constants. Clear then set. ${WP} config delete WP_HOME --type=constant >/dev/null 2>&1 || true ${WP} config delete WP_SITEURL --type=constant >/dev/null 2>&1 || true ${WP} config set WP_HOME "${WP_URL}" --type=constant >/dev/null 2>&1 || true ${WP} config set WP_SITEURL "${WP_URL}" --type=constant >/dev/null 2>&1 || true echo "Done. WordPress URL reset to local defaults." ================================================ FILE: bin/reset-nux.sh ================================================ #!/usr/bin/env bash set -euo pipefail # Full WordPress reset + fresh install for new-user experience testing. # Run this from within a Local site shell (where wp-config.php is present) # or from the plugin root when using wp-env. # # Usage: # bin/reset-nux.sh # reset using LOCAL_URL # bin/reset-nux.sh --tunnel # reset and set site URLs to ngrok public URL # # wp-env defaults: # - Run from plugin root; wp-env exposes WP at http://localhost:8888 by default. # - Override host/port (e.g. pretty URLs) using wp-env + Caddy: # 1) Add a .wp-env.override.json like: # { # "port": 80, # "host": "publisher.local" # } # 2) Add to /etc/hosts: # 127.0.0.1 publisher.local # 3) Run Caddy to serve HTTPS locally: # caddy reverse-proxy --from https://publisher.local --to http://localhost:80 # 4) Then run: # LOCAL_HOST=publisher.local LOCAL_PORT=80 LOCAL_SCHEME=https bin/reset-nux.sh if ! command -v wp >/dev/null 2>&1; then echo "Error: wp (WP-CLI) is not available in PATH." >&2 exit 1 fi SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" LOCAL_WP_ENV_BIN="${REPO_ROOT}/node_modules/.bin/wp-env" TUNNEL=false while [ $# -gt 0 ]; do case "$1" in --tunnel) TUNNEL=true ;; *) echo "Error: unknown argument: $1" >&2 echo "Usage: $0 [--tunnel]" >&2 exit 1 ;; esac shift done # Local defaults (Local app). wp-env defaults to localhost:8888. LOCAL_HOST="${LOCAL_HOST:-localhost}" LOCAL_SCHEME="${LOCAL_SCHEME:-http}" LOCAL_PORT="${LOCAL_PORT:-8888}" LOCAL_URL="${LOCAL_SCHEME}://${LOCAL_HOST}:${LOCAL_PORT}/" WP_URL="${WP_URL:-$LOCAL_URL}" WP_TITLE="${WP_TITLE:-NUX Test}" WP_ADMIN_USER="${WP_ADMIN_USER:-admin}" WP_ADMIN_PASS="${WP_ADMIN_PASS:-admin}" WP_ADMIN_EMAIL="${WP_ADMIN_EMAIL:-admin@example.com}" PLUGIN_SLUG="${PLUGIN_SLUG:-podlove-podcasting-plugin-for-wordpress}" NGROK_PID="" NGROK_LOG="" cleanup() { if [ "${TUNNEL}" = true ]; then return fi if [ -n "${NGROK_PID}" ] && kill -0 "${NGROK_PID}" >/dev/null 2>&1; then kill "${NGROK_PID}" >/dev/null 2>&1 || true fi } trap cleanup EXIT print_ngrok_session_limit_hint() { if ! rg -q "ERR_NGROK_108|simultaneous ngrok agent sessions|limited to 1 simultaneous" "${NGROK_LOG}" 2>/dev/null; then return fi echo >&2 echo "ngrok reports another agent session is already running." >&2 echo "Find running ngrok processes:" >&2 echo " pgrep -fl ngrok" >&2 echo "Stop them:" >&2 echo " pkill -f ngrok" >&2 } print_ngrok_authtoken_hint() { if ! rg -q "ERR_NGROK_4018|authtoken|authentication failed" "${NGROK_LOG}" 2>/dev/null; then return fi echo >&2 echo "ngrok needs an authtoken. Fix with:" >&2 echo " ngrok config add-authtoken " >&2 } start_ngrok_tunnel() { if ! command -v ngrok >/dev/null 2>&1; then echo "Error: ngrok is required for --tunnel but was not found in PATH." >&2 exit 1 fi if ! curl -fsS --max-time 2 "http://127.0.0.1:${LOCAL_PORT}/" >/dev/null 2>&1 \ && ! curl -fsS --max-time 2 "http://localhost:${LOCAL_PORT}/" >/dev/null 2>&1; then echo "Error: local WordPress is not reachable on http://localhost:${LOCAL_PORT}/." >&2 echo "Check Docker port mappings (wp-env should map ${LOCAL_PORT}->80):" >&2 echo " docker ps --format '{{.Names}}\\t{{.Ports}}' | rg 'wordpress'" >&2 echo "If the port differs, set LOCAL_PORT or LOCAL_HOST before running this script." >&2 exit 1 fi # Tunnel over plain HTTP to Local's router port; Local handles HTTPS itself. local tunnel_target="http://${LOCAL_HOST}:${LOCAL_PORT}/" echo "Starting ngrok tunnel for: ${tunnel_target}" # Start ngrok in the background and resolve the public URL via the local API. # macOS mktemp requires a template with at least 3-6 X's. NGROK_LOG="$(mktemp -t ngrok-reset-nux.XXXXXX)" local host_header="--host-header=${LOCAL_HOST}:${LOCAL_PORT}" case "${WP}" in *wp-env*) host_header="--host-header=preserve" ;; esac ngrok http "${tunnel_target}" ${host_header} --log=stdout >"${NGROK_LOG}" 2>&1 & NGROK_PID=$! echo "ngrok PID: ${NGROK_PID}" echo "ngrok log: ${NGROK_LOG}" sleep 1 if ! kill -0 "${NGROK_PID}" >/dev/null 2>&1; then echo "Error: ngrok exited immediately." >&2 echo "ngrok output:" >&2 sed -n '1,80p' "${NGROK_LOG}" >&2 || true print_ngrok_session_limit_hint print_ngrok_authtoken_hint exit 1 fi local tunnel_url="" local attempts=0 local max_attempts=20 while [ ${attempts} -lt ${max_attempts} ]; do attempts=$((attempts + 1)) if ! kill -0 "${NGROK_PID}" >/dev/null 2>&1; then echo "Error: ngrok stopped while waiting for the tunnel URL." >&2 echo "ngrok output:" >&2 sed -n '1,120p' "${NGROK_LOG}" >&2 || true exit 1 fi tunnel_url="$( curl -fsS http://127.0.0.1:4040/api/tunnels 2>/dev/null \ | 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);' \ || true )" if [ -n "${tunnel_url}" ]; then echo "ngrok public URL: ${tunnel_url}" WP_URL="${tunnel_url}" return fi sleep 1 done echo "Error: ngrok tunnel did not become ready via http://127.0.0.1:4040/api/tunnels." >&2 echo "ngrok output (first lines):" >&2 sed -n '1,120p' "${NGROK_LOG}" >&2 || true print_ngrok_session_limit_hint print_ngrok_authtoken_hint echo "Tip: make sure no other ngrok process is running and try again." >&2 exit 1 } set_wp_cli() { if [ -f "wp-config.php" ]; then WP="wp" return fi if command -v wp-env >/dev/null 2>&1; then WP="wp-env run cli wp" return fi if [ -x "${LOCAL_WP_ENV_BIN}" ]; then WP="${LOCAL_WP_ENV_BIN} run cli wp" return fi if command -v npx >/dev/null 2>&1; then if npx --no-install wp-env --version >/dev/null 2>&1; then WP="npx --no-install wp-env run cli wp" return fi fi echo "Error: wp-config.php not found and wp-env is not available." >&2 echo "Run this from a WordPress root or from the plugin root with wp-env installed." >&2 echo "Tip: run npm install (or npm run wp-env:start) to install wp-env locally." >&2 exit 1 } set_wp_cli if [ "${TUNNEL}" = true ]; then start_ngrok_tunnel fi cat </dev/null 2>&1 || true ${WP} option update siteurl "${WP_URL}" >/dev/null 2>&1 || true ${WP} rewrite flush --hard >/dev/null 2>&1 || true # Local/wp-env may inject redirects via wp-config constants. Clear then set. ${WP} config delete WP_HOME --type=constant >/dev/null 2>&1 || true ${WP} config delete WP_SITEURL --type=constant >/dev/null 2>&1 || true ${WP} config set WP_HOME "${WP_URL}" --type=constant >/dev/null 2>&1 || true ${WP} config set WP_SITEURL "${WP_URL}" --type=constant >/dev/null 2>&1 || true activate_podlove_plugin() { local candidates=( "${PLUGIN_SLUG}" "podlove-podcast-publisher" "podlove-publisher" "$(basename "${REPO_ROOT}")" ) local seen="" local slug="" for slug in "${candidates[@]}"; do case " ${seen} " in *" ${slug} "*) continue ;; esac seen="${seen} ${slug}" if ${WP} plugin is-installed "${slug}" >/dev/null 2>&1; then ${WP} plugin activate "${slug}" || true return fi done echo "Warning: Podlove plugin not found. Tried: ${seen# }" >&2 } # Ensure the plugin repo is active after reinstall. activate_podlove_plugin # For convenience, also activate web player ${WP} plugin activate podlove-web-player || true if [ "${TUNNEL}" = true ]; then echo "Done. Fresh install configured for tunnel URL: ${WP_URL}" echo "ngrok is still running in the background (PID: ${NGROK_PID})." echo "Stop it with: kill ${NGROK_PID}" echo "Switch back to local URLs with: bin/remove-tunnel.sh" else echo "Done. Fresh WordPress install and plugin activation attempted." fi ================================================ FILE: bin/template_ref.erb ================================================ <% templateRefClasses.each do |item| %> #### <%= item['class']['classname'] %> <%= renderDescription(item['class']['description']) %> <% item['methods'].each do |method| %> <% end %>
<%= item['class']['templatetag'] %>.<%= method['methodname'] %> <%= method['title'] %> <%= renderDescription(method['description']) %> <% seeTags = method['tags'].keep_if { |tag| tag['name'] == 'see' } if seeTags && seeTags.length > 0 %>

<% seeTags.each do |tag| %> see <%= renderDescription(tag['description']) %> <% end %>

<% end %>
<% end %> ================================================ FILE: bin/template_ref.rb ================================================ require "erb" require "json" def templateRefClasses classes = %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} classes.map { |klass| JSON.parse(IO.read("doc/data/template/#{klass}.json")) } end def renderDescription(s) if s s.gsub!(/^(\s+)```/, "\n```") s.gsub!(/```(\w+)?([^`]+)```/) { |m| "```" + $1.to_s + "\n{% raw %}" + $2.to_s + "{% endraw %}\n```" } s.gsub!(/[^`]`([^`]+)`/) { |m| "`{% raw %}" + $1.to_s + "{% endraw %}`" } s = "{% capture tmp %}" + s + "{% endcapture %}\n{{ tmp | markdownify }}" end s end renderer = ERB.new(File.read("bin/template_ref.erb")) puts output = renderer.result() ================================================ FILE: bin/template_ref_json.php ================================================ WPBASE=/path/to/wordpress php -d "opcache.enable=off" bin/template_ref_json.php * 2. $> ruby bin/template_ref.rb > doc/template_ref.md */ require_once 'vendor/autoload.php'; use Podlove\Comment\Comment; define('MULTISITE', false); if (!getenv('WPBASE')) { exit("You need to set the environment variable WPBASE to your WordPress root\n"); } require_once dirname(__FILE__).'/../lib/helper.php'; require_once getenv('WPBASE').'/wp-load.php'; require_once dirname(__FILE__).'/../bootstrap/bootstrap.php'; // $output_dir = '/tmp/podlove/doc'; $output_dir = dirname(__FILE__).'/../doc/data/template'; @mkdir($output_dir, 0777, true); // classes containing dynamic accessors $dynamicAccessorClasses = [ '\Podlove\Modules\Contributors\TemplateExtensions', '\Podlove\Modules\Seasons\TemplateExtensions', '\Podlove\Modules\RelatedEpisodes\TemplateExtensions', '\Podlove\Modules\Shows\TemplateExtensions', '\Podlove\Modules\Social\TemplateExtensions', '\Podlove\Modules\SubscribeButton\TemplateExtensions', '\Podlove\Modules\Transcripts\TemplateExtensions', ]; $classes = [ '\Podlove\Template\Podcast', '\Podlove\Template\Feed', '\Podlove\Template\Episode', '\Podlove\Template\EpisodeTitle', '\Podlove\Template\Asset', '\Podlove\Template\File', '\Podlove\Template\Duration', '\Podlove\Template\Chapter', '\Podlove\Template\License', '\Podlove\Template\DateTime', '\Podlove\Template\FileType', '\Podlove\Template\Tag', '\Podlove\Template\Category', '\Podlove\Template\Image', '\Podlove\Modules\Contributors\Template\Contributor', '\Podlove\Modules\Contributors\Template\ContributorGroup', '\Podlove\Modules\Seasons\Template\Season', '\Podlove\Modules\Shows\Template\Show', '\Podlove\Modules\Social\Template\Service', '\Podlove\Modules\Networks\Template\Network', '\Podlove\Modules\Networks\Template\PodcastList', '\Podlove\Modules\Transcripts\Template\Line', '\Podlove\Modules\Transcripts\Template\Group', ]; // first, parse dynamic accessors $dynamicAccessors = []; foreach ($dynamicAccessorClasses as $class) { $reflectionClass = new ReflectionClass($class); $methods = $reflectionClass->getMethods(); $accessors = array_filter($methods, function ($method) { $comment = $method->getDocComment(); return stripos($comment, '@accessor') !== false && stripos($comment, '@dynamicAccessor') !== false; }); $parsedMethods = array_map(function ($method) { assert_options(ASSERT_CALLBACK, function () use ($method) { print_r("!!! Assertion failed in {$method->class}::{$method->name}\n"); }); $c = new Comment($method->getDocComment()); $c->parse(); $dynamicAccessor = $c->getTag('dynamicAccessor'); $callData = explode('.', $dynamicAccessor['description']); return [ 'methodname' => $callData[1], 'title' => $c->getTitle(), 'description' => $c->getDescription(), 'tags' => $c->getTags(), 'class' => $callData[0], ]; }, $accessors); foreach ($parsedMethods as $method) { if (!isset($dynamicAccessors[$method['class']])) { $dynamicAccessors[$method['class']] = []; } $dynamicAccessors[$method['class']][$method['methodname']] = $method; } } foreach ($classes as $class) { $reflectionClass = new ReflectionClass($class); $className = $reflectionClass->getShortName(); $methods = $reflectionClass->getMethods(); $accessors = array_filter($methods, function ($method) { $comment = $method->getDocComment(); return stripos($comment, '@accessor') !== false; }); $parsedMethods = array_map(function ($method) { $c = new Comment($method->getDocComment()); $c->parse(); return [ 'methodname' => $method->name, 'title' => $c->getTitle(), 'description' => $c->getDescription(), 'tags' => $c->getTags(), ]; }, $accessors); if (isset($dynamicAccessors[strtolower($className)])) { foreach ($dynamicAccessors[strtolower($className)] as $dynamicMethodName => $dynamicMethod) { $parsedMethods[] = $dynamicMethod; } } $classComment = new Comment($reflectionClass->getDocComment()); $classComment->parse(); $templatetag = $classComment->getTags()[0]['description']; assert(strlen($templatetag) > 0, 'templatetag must not be empty'); $classdoc = [ 'class' => [ 'classname' => $className, 'templatetag' => $templatetag, 'description' => $classComment->getDescription(), ], 'methods' => array_values($parsedMethods), ]; file_put_contents($output_dir.'/'.$templatetag.'.json', wp_json_encode($classdoc), LOCK_EX); } ================================================ FILE: bin/uadetect.php ================================================ parse(); if ($dd->isBot()) { var_dump($botInfo = $dd->getBot()); } else { $clientInfo = $dd->getClient(); // holds information about browser, feed reader, media player, ... $osInfo = $dd->getOs(); $device = $dd->getDevice(); $brand = $dd->getBrand(); $model = $dd->getModel(); var_dump($clientInfo, $osInfo, $device, $brand, $model); } ================================================ FILE: bin/update-opawg.sh ================================================ #!/usr/bin/env bash wget -O data/opawg.json https://raw.githubusercontent.com/opawg/user-agents/master/src/user-agents.json ================================================ FILE: bin/update_pwp4.sh ================================================ #!/usr/bin/env bash npm update @podlove/web-player rm -r lib/modules/podlove_web_player/player_v4/dist cp -r node_modules/@podlove/web-player/ lib/modules/podlove_web_player/player_v4/dist ================================================ FILE: bin/workspace.js ================================================ const path = require('path') const fs = require('fs-extra') const toDelete = (fs.readdirSync(path.resolve('.')) || []).filter(item => item !== 'dist') toDelete.forEach(file => fs.removeSync(path.resolve(file))) const toCopy = fs.readdirSync(path.resolve('dist')) || [] toCopy.forEach(file => fs.copySync(path.resolve('dist', file), path.resolve('.', file))) fs.removeSync(path.resolve('dist')) ================================================ FILE: bin/wp-env-test-after-start.js ================================================ const { spawnSync } = require('node:child_process'); const path = require('node:path'); const repoRoot = path.resolve(__dirname, '..'); const wpEnvBin = path.join(repoRoot, 'node_modules', '.bin', 'wp-env'); const configArg = '--config=.wp-env.test.json'; const pluginSlug = 'podlove-podcasting-plugin-for-wordpress'; function runWpEnv(args) { return spawnSync(wpEnvBin, [configArg, 'run', 'cli', 'wp', ...args], { cwd: repoRoot, stdio: 'inherit', }); } const isActive = runWpEnv(['plugin', 'is-active', pluginSlug]); if (isActive.status === 0) { process.exit(0); } const activated = runWpEnv(['plugin', 'activate', pluginSlug]); process.exit(activated.status ?? 1); ================================================ FILE: bootstrap/autoload.php ================================================ namespace $plugin = array_shift($split); if (!strlen($plugin)) { $plugin = array_shift($split); } // only load classes prefixed with namespace if ($plugin != 'Podlove') { return false; } // class name without namespace $class_name = array_pop($split); // CamelCase to snake_case $class_name = podlove_camelcase_to_snakecase($class_name); // the rest of the namespace, if any $namespaces = $split; // library directory $lib = dirname(dirname(__FILE__)).'/lib/'; // register all possible paths for the class $possibilities = []; if (count($namespaces) >= 1) { $possibilities[] = $lib.strtolower(implode('/', array_map('podlove_camelcase_to_snakecase', $namespaces)).'/'.$class_name.'.php'); } else { $possibilities[] = $lib.strtolower($class_name.'.php'); } // search for the class foreach ($possibilities as $file) { if (file_exists($file)) { require_once $file; return true; } } if (defined('WP_DEBUG') && WP_DEBUG) { $trace = debug_backtrace(); $functions = array_map(function ($t) { return $t['function']; }, $trace); if (in_array('class_exists', $functions)) { // don't log anything, we were just checking if that class exists } else { error_log(print_r([ 'message' => 'Class Autoload failed for "'.$class_name.'"', 'attempts' => $possibilities, 'trace' => $functions, ], true)); } } return false; } spl_autoload_register('podlove_autoloader'); ================================================ FILE: bootstrap/bootstrap.php ================================================ Modules`, search for "Automatic Numbering" and activate it. **Other Changes:** - Shows Module: each show can define its own Auphonic production preset - episode: after file revalidation, auto-detect duration - contributors: assign a `Sponsor` role and it will appear as `...` in the RSS feed - fix: correct PHP version number in message (https://github.com/podlove/podlove-publisher/pull/1272) - fix: feed cache issue when using the "Shows" module = 3.7.0 = **Shownotes** The Shownotes module helps you manage link based show notes to display on your website and podcatchers. The module UI has been rewritten and streamlined for efficient workflows. A new UI element was added to allow for quickly sorting long lists of links into topics: Whenever a link is dragged, a floating list of all topics appears next to the cursor. The link can then be dropped under the desired topic there instead of scrolling through the whole list of shownotes. Disclaimer: 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. Documentation: https://docs.podlove.org/podlove-publisher/modules/shownotes **Contributors** * Notifications: add "always send to..." section. Contributors selected there will always receive update notifications. * Avatars: default avatar is now a static svg instead of Gravatar (can be customized using the WordPress Filter `podlove_default_contributor_avatar_url`) **Enhancements for creating Auphonic productions** (thanks [lumaxis](https://github.com/lumaxis)!): * when the episode title is set, send this instead of the post title ([#1240](https://github.com/podlove/podlove-publisher/pull/1240)) * send the episode number as track number ([#1240](https://github.com/podlove/podlove-publisher/pull/1240)) * when the post thumbnail is configured as cover image, use it as direct fallback ([#1241](https://github.com/podlove/podlove-publisher/pull/1241)) **Webhooks** Define a webhook that gets triggered every time an episode updates. The webhook is a `POST` request with an `event` parameter and a `payload`. `event` is the webhook name ("episode_updated"), `payload` is a serialized JSON object of the current episode. Configuration: # wp-config.php define('PODLOVE_WEBHOOKS', [ 'episode_updated' => 'https://example.com/webhook-endpoint' ]); **Other Changes** * soundbites: add title field ([#1257](https://github.com/podlove/podlove-publisher/pull/1257), [#1237](https://github.com/podlove/podlove-publisher/issues/1237)) * allow detection of episode duration on mp4 ([#1249](https://github.com/podlove/podlove-publisher/pull/1249)) * update OPAWG data (for download analytics / user agent detection) **Fixes** * fix: parameters for shortcode `[podlove-episode-contributor-list]` ([#1233](https://github.com/podlove/podlove-publisher/issues/1233)) * fix: PHP 8 warnings ([#1258](https://github.com/podlove/podlove-publisher/issues/1258)) * fix: deleting an episode deletes its transcript from the database ([#1252](https://github.com/podlove/podlove-publisher/issues/1252)) * fix(contributors): notification test email ([#1247](https://github.com/podlove/podlove-publisher/issues/1247)) * fix(analytics): filtering of httprange requests with one or two bytes ([#1243](https://github.com/podlove/podlove-publisher/issues/1243)) * fix(image cache): redirect to source URL if image can't be downloaded into the cache = 3.6.1 = * fix: sql issue when creating the episode database tables = 3.6.0 = **New Module: Soundbite** Adds support for the `` RSS feed tag. The intended use includes episodes previews, discoverability, audiogram generation, episode highlights, etc. Using this module you can specify an audio segment for each episode that can be read by for example audiogram generation services. **New Module: WordPress File Upload** If you are using WordPress Media as storage for your Podlove assets, this new module adds conveniences. First, you define a `Upload subdirectory` for your Podlove assets. This overrides any WordPress settings, so for example you can safely enable the typical date/month structure for WordPress attachments and it will not affect your Podlove Uploads. Then you can update your "Podlove - Media - Upload Location" setting. You can keep it empty to let Podlove Publisher take care of it, or set it yourself if you have a custom file hostname. Now there is an "Upload Media File" button in your episode form above the "Episode Media File Slug" where you can directly upload your files. If you are using multiple assets, you can upload them all there. Just make sure they all have the same filename (except the file extension) before you upload. = 3.5.5, 3.5.6 = **Fixes** * SECURITY: sql injection in "Social & Donations" module * transcript API returns list again * PLUS open graph images (use new API) * handle webvtt voice, missing Contributors * related episodes: remove whitespace in shortcode HTML to fix rendering in Spotify **Changes** * webvtt transcripts use public contributor name * transcript voices / contributors: * you can now select "none" in the voice assignment * only voices with an assigned contributor (and not "none") appear in public transcripts * generate default copyright claim if it is not explcitly set = 3.5.4 = * adds copyright field in "Podcast Settings - Directory", which is apparently required by the Apple Podcast Directory since yesterday. * perf: remove frontend.js (inline logic to download button HTML) = 3.5.2 / 3.5.3 = This releases reverses all changes to Permalinks in releases 3.5.0 and 3.5.1. I 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. Please verify your episode URLs and the two expert settings “Permalink structure for episodes” and “Episode Pages”. What 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. What happened to the “Simple Episode Permalink” setting from release 3.5.1? It has been removed, too. Sorry for the trouble. Happy podcasting :) **Other** * fix: remove usage of PHP 7.1 syntax in one file = 3.5.1 = = 2021-04-14 = * add: expert setting to make episode permalinks `/%postname%/` * add: include Publisher Database Version in system report * drop WordPress version requirement to 4.9.6 = 3.5.0 = **Breaking Change** Removes two expert settings: * "Permalink structure for episodes" and * "Episode pages" These settings allowed to define custom URL structures for episodes and the episode archive. However they have caused trouble for a long time (see [#1038](https://github.com/podlove/podlove-publisher/issues/1038)) and the only viable way out seems to remove them. How does that affect you? If you have never touched these settings, feel free to shrug, smile and move on. If you _are_ using these settings, I encourage you to consider not using them as they are mostly of cosmetic nature. Should you however prefer to keep everything as is (including the known bugs of erratically broken permalinks / URLs), you can enable the settings back with a single line of code in your wp-config.php: `define('PODLOVE_ENABLE_PERMALINK_MAGIC', true);` **Experimental: Full-Page Podlove Templates** If you want to create a 100% custom page based on an episode but without all the WordPress theme around, this is for you. Possible use case: A dedicated page to print the episode transcript. 1. create a new Podlove Template, for example `page-episode-transcipt` 2. Write that transcript as a _full HTML page_. That means it starts with `` and ends with ``! 3. 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` Very simple example template: Transcript | {{ episode.title }} | {{ podcast.title }}

Here's the transcript for podcast {{ podcast.title }} episode {{ episode.title }}:

[podlove-transcript] Enjoy! **Shownotes Module** * provide website screenshot as fallback when no sharing image is available (requires PLUS token) * show images * show image in edit view * when importing, show all entries * show import progress when unfurling * fix osf importer * fix encoding issue when importing from HTML **Miscellaneous** * update database for podcast user agents -- notably includes classification of Apple Watch downloads as bot [#1203](https://github.com/podlove/podlove-publisher/issues/1203) * transcript: add some basic info about podcast and episode into webvtt as a note * analytics: add hook `podlove_useragent_opawg_data` to add custom user agent detection * 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`. * fix: transcripts with trailing newlines don't confuse the importer * fix: don't count contributors multiple times if they have multiple contributions in an episode ([#1200](https://github.com/podlove/podlove-publisher/issues/1200)) * fix: calling wptexturize too early ([#1194](https://github.com/podlove/podlove-publisher/issues/1194)) = 3.4.1 = * fix: analytics shows section now does not include other taxonomies * use image caching for shownotes images * analytics shows section is now ordered by downloads = 3.4.0 = **podcastindex namespace** Both additions add metadata to the feed automatically if the data is present. No new user interfaces or data entry is necessary. * 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) * add support for feed tag [`podcast:person`](https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/1.0.md#person) on episode level **analytics** * for selected date range, total downloads are shown * for selected date range, display downloads per show (only visible when shows module is enabled) = 3.3.2 = * fix: in analytics, the "Export as CSV" section is now clickable when global statistics are loading or have no data * fix: "Export as CSV" works again * fix: "global statistics" charts idling indefinitely until a custom date range is chosen = 3.3.0 / 3.3.1 = * 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) * unfurl uses https://plus.podlove.org/api/unfurl as API endpoint * add banner linking to donations page (can be dismissed) * shownotes: * add shortcode `[podlove-episode-shownotes]` * display links even if unfurling failed * template improvements * add "delete all" button * polished failure section UI and allow editing original URL * API: add missing permission callbacks * fix: keep order when importing via slacknotes * slacknotes: update to new API * change donation URL to https://opencollective.com/podlove * fix: handle missing templates in TwigLoaderPodloveDatabase = 3.2.2 = * fix: crash when creating new episodes = 3.2.1 = * fix: coverart url encoding [#1181](https://github.com/podlove/podlove-publisher/pull/1181) * fix: some settings not applying to episode title tag (thanks Dirk) * fix: crash when accessing season data for an episode without season = 3.2.0 = * when automatically generated episode titles are used, use the blogpost title as fallback for the episode title * fix: disable slug auto-updating after importing from Auphonic * fix: webvtt-parser autoloading issues [#1175](https://github.com/podlove/podlove-publisher/issues/1175) * 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) = 3.1.* = * fix twig namespace prefixing related issues * remove unused vendor-bin directory from releases = 3.1.1 = * tracking: fix operating systems appearing twice in different spellings * chore: prefix all composer packages (solves Twig related incompatibilities & crashes) * chore: add content and files to episodes api (#1165) = 3.1.0 = * 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)! * Auphonic: show production warnings in module (https://twitter.com/auphonic/status/1305849345762185217) * download tracking: use OPAWG podcast user agent database in addition to Matomo database * 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. * enhance: podcast file validation in dashboard includes all post stati and checks for missing slug [#1161](https://github.com/podlove/podlove-publisher/pull/1161) * enhance: only allow episode numbers of 0 and higher in form input [#1158](https://github.com/podlove/podlove-publisher/pull/1158) * api: add public endpoint for transcripts * api: add public endpoint for shownotes * fix: Podlove Web Player 5 includes all downloadable assets in download section * fix: transcript API URL [#1145](https://github.com/podlove/podlove-publisher/pull/1145) thanks [gibso](https://github.com/gibso)! * fix: editing/deleting shows ([#1077](https://github.com/podlove/podlove-publisher/issues/1077)) * fix: episodes and shows API * fix: migration for Shownotes only when the database table exists = 3.0.4 = * fix: contributor notifications settings can be saved again ([#1144](https://github.com/podlove/podlove-publisher/issues/1144)) * fix: do not include invisible contributors in Web Player 5 API ([#1142](https://github.com/podlove/podlove-publisher/issues/1142)) * fix: detect Yoast SEO, wpSEO: disables Open Graph module ([#1132](https://github.com/podlove/podlove-publisher/issues/1132)) * fix: use podcast summary as RSS Feed `` if subtitle is not set ([#1092](https://github.com/podlove/podlove-publisher/issues/1092)) = 3.0.3 = * fix: title escaping in RSS feed when using native (not auto-generated) titles = 3.0.2 = * add: Untappd social service * fix: Auphonic module (wrong HTTP API headers) * chore: update npm dependencies = 3.0.1 = * fix: escaping issue in RSS feed (itunes:author and itunes:owner) * fix: remove (rare) accidental double enclosure tag in RSS feed when "enclosure" post meta is present = 3.0.0 = **Breaking Changes** * requires PHP 7.0 (or newer) * requires WordPress 5.2 (or newer) * Web Player: * removes Podlove Web Player 2 * removes Podlove Web Player 3 * removes "insert player automatically" option (probably does not affect anyone as the web player is by default inserted via template) * removes "Chapters Visibility" option (use dedicated Web Player settings instead) **New Publisher PLUS** => [plus.podlove.org](https://plus.podlove.org/) Publisher PLUS is a new service providing Feed Proxy and Podcast Subscriber statistics for Podlove Publisher. To use it, enable the *Publisher PLUS* module, then visit [plus.podlove.org](https://plus.podlove.org/) to create an account. Subscriber Statistics are only the beginning. Expect more features soon! **Experimental: Shownotes** Generate and manage episode show notes. Helps you provide rich metadata for URLs. Full support for Publisher Templates. This 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. The 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);`. Use this template as a starting point: https://gist.github.com/eteubert/d6c51c52372dc2da2f1734a5f54c7918 **Shortcodes** * `podlove-episode-contributor-list` * new design * renders text-only in RSS feed * `podlove-podcast-contributor-list` * new design * `podlove-episode-downloads` * the text link variant is now the default style **Miscellaneous** * remove Bitlove module (service does not exist any more) * remove Flattr module * remove "Website Protocol" setting (not necessary any more as Let's Encrypt is widely supported) * enable episode chapters by default * convenience: "Copy to Clipboard" function for Podlove Template shortcodes * expose iTunes id/URL in podcast feed ([#1078](https://github.com/podlove/podlove-publisher/pull/1078)) * improve feed rendering: use XML generator for all tags with user input to guarantee valid feeds for all inputs * add function to remove a transcript from an episode ([#1131](https://github.com/podlove/podlove-publisher/issues/1131)) * add Steady as donation service * add template tag: `episode.post_title` ([#1136](https://github.com/podlove/podlove-publisher/issues/1136)) * add template tag: `service.type` (https://community.podlove.org/t/replacing-social-icons/2321) * add default avatar to transcript preview * fix: search logic ([#1072](https://github.com/podlove/podlove-publisher/issues/1072)) * fix: fetch Podlove News via https ([#1037](https://github.com/podlove/podlove-publisher/issues/1037)) * fix: don't send Publisher logs to system log when WP_DEBUG is on ([#1065](https://github.com/podlove/podlove-publisher/issues/1065)) * fix: ensure uploads for webvtt (transcripts) and gz (exports) are allowed * fix: ensure contributors module is active when transcripts are used * fix: ensure permissions in shownotes and transcripts APIs * fix: don't count download requests with http range header of `bytes=0-0` ([#1135](https://github.com/podlove/podlove-publisher/issues/1135)) * update dependencies * build releases with GitHub Actions (in favour of TravisCI) = 2.11.4 = * fix: missing monolog dependency = 2.11.2 = * fix: ensure that logging library Monolog is available at version 1.x, otherwise disable the database logger = 2.11.1 = * Podlove Web Player 5: support "show" parameter `episode.player({show: 'my-show-slug'})` = 2.11.0 = * add global network bar [#1101](https://github.com/podlove/podlove-publisher/pull/1101) by [@gglnx](https://github.com/gglnx) * improve template editing UI [#1109](https://github.com/podlove/podlove-publisher/pull/1109) * fix: template tag `episode.player` uses correct shortcode internally * fix: template tag `episode.player` uses correct episode on pages that are not its own episode-page = 2.10.0 = Add support for Podlove Web Player 5 Podlove Web Player 5 is the latest overhaul of our podcast web player. It comes with its own configuration interface giving you full control over its appearance. Activate it in `Podlove > Podcast Settings > Player`. You are then prompted to install the "Podlove Web Player" plugin if you don't have it installed already. Configure the web player appearance in `Settings > Podlove Web Player`. Existing web player shortcodes and template accessors continue to work as expected. For detailed shortcode options, please refer to https://wordpress.org/plugins/podlove-web-player/ = 2.9.10 = * when using Google Analytics tracking, the show title is sent as content group = 2.9.9 = Re-Release of 2.9.8 = 2.9.8 = * add Twig function `get_the_post_thumbnail_url` identical to the native WordPress function * fix Podlove Web Player 4 issue in twentytwenty theme * fix some importer issues * shows module: itunes category can be set per show = 2.9.7 = * update JavaScript dependencies = 2.9.6 = * update PHP dependencies (including User Agent library for download analytics) * add: expose voice attribute to transcript templates ([#1062](https://github.com/podlove/podlove-publisher/pull/1062)) * 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)) * fix: download list description in analytics on mobile ([#1056](https://github.com/podlove/podlove-publisher/issues/1056)) * fix: JS issue when selecting transcript voices * fix: escaping error in contributor comments ([#1081](https://github.com/podlove/podlove-publisher/issues/1081)) = 2.9.5 = * Slacknotes: reactivate date picker = 2.9.4 = * fix: error on "file types" settings page **IAB Conformity** When 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. There 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. See also: [docs.podlove.org: IAB Conformity](https://docs.podlove.org/podlove-publisher/guides/download-analytics.html#iab-conformity) This feature is sponsored by [Lage der Nation](https://lagedernation.org). = 2.9.3 = * add quick edit for episode number [#1096](https://github.com/podlove/podlove-publisher/pull/1069) * fix settings tab issues when using a language in WordPress other than english ([e613e99](https://github.com/podlove/podlove-publisher/commit/e613e99bb4f07bb88234146567e76d21ce06f5ff)) * fix issue with category search / pages * fix auphonic module issue in Gutenberg editor = 2.9.2 = * update Podlove Web Player (fixes issue when sharing/embedding the player) * fix PHP notices [#1066](https://github.com/podlove/podlove-publisher/issues/1066) [#1064](https://github.com/podlove/podlove-publisher/issues/1064) = 2.9.1 = * fix web player sharing when using CDN player * fix duplicating posts: create new guid; do not copy analytics [#1048](https://github.com/podlove/podlove-publisher/issues/1048) = 2.9.0 = **New Apple iTunes Categories** Apple updated their list of available iTunes categories. Please check in `Podlove > Podcast Settings > Directory > iTunes Category` if you need or want to update your category. In case your previously selected category does not exist any more, a warning is shown. Only one category is selectable now (instead of previously 3) to conform with iTunes specifications. **Download tracking with Google Analytics** Set your Google Analytics Tracking ID in Podlove > Expert Settings > Tracking. Then every download intent will be forwarded to Google Analytics. [#1058](https://github.com/podlove/podlove-publisher/pull/1058) **Other** * fix: check if podlovePlayer function is available before calling it [#1060](https://github.com/podlove/podlove-publisher/pull/1060) = 2.8.10 = * update Podlove Web Player 4 to latest version = 2.8.9 = * update Podlove Web Player 4 to latest version * remove PHP dependency leth/ip-address = 2.8.8 = * update Podlove Web Player 4 to latest version = 2.8.7 = * update Podlove Web Player 4 to latest version * 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)) * fix [#1047 Use of PHP 5.6 feature in Shows module](https://github.com/podlove/podlove-publisher/issues/1047) * report duplicate guids in system report = 2.8.0 = **Transcripts** “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. See [https://forschergeist.de/podcast/fg066-klimaneutralitaet/](https://forschergeist.de/podcast/fg066-klimaneutralitaet/) for an example episode with transcripts in the web player. **Transcripts: Shortcode** The shortcode `[podlove-transcript]` displays a pretty html version of the transcript for your website. **Transcripts: Twig Template Support** Of course there is a fully featured template API for transcripts as well. For example: {% for group in episode.transcript %}
{{ group.contributor.image.html({width: 50}) }}
{{ group.contributor.name }}
{% for line in group.items %} {{ line.content }} {% endfor %}
{% endfor %} See [https://docs.podlove.org/podlove-publisher/reference/template-tags.html](https://docs.podlove.org/podlove-publisher/reference/template-tags.html "documentation") for all details. **Global Podcast Analytics** The following metrics are now available for the whole podcast: - downloads per month - top episodes - episode asset - podcast client - operating system - download source **Raw Analytics** I 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. Here is an example call that returns the number of downloads in March 2019: https://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 All requests take the same three parameters: - `action` defines what data you want - `date_from` is the start date in ISO 8601 - `date_end` is the end date in ISO 8601 Available actions are: - podlove-analytics-global-downloads-per-month - podlove-analytics-global-top-episodes - podlove-analytics-global-assets - podlove-analytics-global-clients - podlove-analytics-global-systems - podlove-analytics-global-sources You need to be logged in with admin permissions for the requests to work. Disclaimer: 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. **Other** - background jobs: add button to abort job - new tab style for chapter marks section - Podlove Web Player 4 fallback for old browsers and disabled JavaScript = 2.7.24 = * update Podlove Web Player 4 = 2.7.23 = * **slacknotes:** is now accessible to authors and editors * update some PHP dependencies (including Twig) * add ability to specify visible components in Podlove Web Player V4 ([#1032](https://github.com/podlove/podlove-publisher/pull/1032)) = 2.7.22 = * (maybe) fix Gutenberg issues when creating a new episode = 2.7.21 = **Bug Fixes** * **slacknotes:** avoid duplicate vue for-loop keys ([7578cdf](https://github.com/podlove/podlove-publisher/commit/7578cdf)) * **slacknotes:** date range filter ([2982f2b](https://github.com/podlove/podlove-publisher/commit/2982f2b)) * **slacknotes:** fix loading of datepicker component ([13ca12b](https://github.com/podlove/podlove-publisher/commit/13ca12b)) * **slacknotes:** follow redirects when resolving URLs ([5b39746](https://github.com/podlove/podlove-publisher/commit/5b39746)) * **slacknotes:** handle slack-resolved URLs in pipes format ([b08ec53](https://github.com/podlove/podlove-publisher/commit/b08ec53)) * **slacknotes:** hide link-fetch prompt while fetching ([f4e78e3](https://github.com/podlove/podlove-publisher/commit/f4e78e3)) * **slacknotes:** url re-fetching when changing dates ([344456d](https://github.com/podlove/podlove-publisher/commit/344456d)) **Features** * **slacknotes:** add setting for link ordering ([c4c824e](https://github.com/podlove/podlove-publisher/commit/c4c824e)) * **slacknotes:** show link time ([53077b1](https://github.com/podlove/podlove-publisher/commit/53077b1)) * **slacknotes:** when resolving URLs, use effective URL ([974c7f8](https://github.com/podlove/podlove-publisher/commit/974c7f8)) = 2.7.20 = **Slacknotes** This release is sponsored by [Lage der Nation](https://lagedernation.org). The new "Slacknotes" module extracts links and their metadata from a Slack channel and generates HTML that can be used as show notes. A short demo video is available [in the documentation](https://docs.podlove.org/podlove-publisher/guides/slacknotes.html). **Other** * the "Modules" screen has been redesigned * updated JavaScript and CSS processing library and other dependencies = 2.7.19 = We are now compatible to the new WordPress 5.0 Gutenberg block editor. You can choose to use the new editor or stay with the classic editor for now by installing the classic editor plugin by WordPress. * feed: do not include `` tag if it is empty (Apple Podcast requirement) * adjustments for Gutenberg compatibility: * Shows metabox moved from sidebar to main area * remove broken form field autogrow behavior * fix contributors UI initialization = 2.7.18 = * improve feed generation time when seasons are used ([#1010](https://github.com/podlove/podlove-publisher/issues/1010)) * title migration module: * remove "episode type" selector, always use "full" * add warning when there might be too many form fields * new PHP constant `PODLOVE_DISABLE_TAG_AND_CATEGORY_SEARCH` ([#1017](https://github.com/podlove/podlove-publisher/issues/1017)) * feed item limit: "1" is now an option * add missing contributor template accessors: organisation, department, jobtitle * ensure Gutenberg editor is not used for episodes = 2.7.17 = **Downloads Data Export** Download data per episode can now be exported as JSON and CSV. On the Analytics page you will now find a simple export interface. Select the episodes you want in the export or don't select any to export them all at once. **WP REST API Support** Backbone for the data export is an implementation of the WP REST API. Endpoint for the episode custom post type: - /wp-json/wp/v2/episodes Custom endpoints for episode analytics: - /wp-json/podlove/v1/analytics/episodes/ - /wp-json/podlove/v1/analytics/episodes/123 - /wp-json/podlove/v1/analytics/episodes/123,82 All analytics are available as CSV by adding `?format=csv` as parameter, for example `/wp-json/podlove/v1/analytics/episodes/?format=csv` Analytics endpoints require the `podlove_read_analytics` permission, the same as viewing analytics in the admin. Please read https://developer.wordpress.org/rest-api/using-the-rest-api/authentication/ if you want to use these endpoints. **Other** * Fix deprecation warning when using multiple categories ([#1009](https://github.com/podlove/podlove-publisher/pull/1009)) = 2.7.16 = * update Podlove Web Player 4 = 2.7.15 = * automatically abort stuck background jobs * contributors now appear in feeds even if they don't have a URI [#939](https://github.com/podlove/podlove-publisher/issues/939#issuecomment-430248520) * Shows: custom language is now used for Podlove Subscribe Button = 2.7.14 = * update Podlove Web Player 4 = 2.7.12/13 = * 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)) * uninstall: be more specific which options are deleted ([#997](https://github.com/podlove/podlove-publisher/issues/997)) * new filter `podlove_network_module_activate` to force-enable network module ([#995](https://github.com/podlove/podlove-publisher/issues/995)) * new social services: Mastodon, Fediverse, Friendica ([#987](https://github.com/podlove/podlove-publisher/issues/987), [#968](https://github.com/podlove/podlove-publisher/issues/968)) * fix related episodes disappearing when using post scheduling ([#980](https://github.com/podlove/podlove-publisher/issues/980)) * fix seasons error when there are no episodes ([#963](https://github.com/podlove/podlove-publisher/issues/963)) * related episodes: order by post date ([#947](https://github.com/podlove/podlove-publisher/issues/947)) = 2.7.10/11 = * update Analytics JS frameworks, fixing [#982](https://github.com/podlove/podlove-publisher/issues/982) * add download location analytics chart * remove weekday chart * add option to disable average episode display = 2.7.9 = Too much slimming in 2.7.8. Undo. = 2.7.8 = * fix release workflow * slim down plugin size by removing unnecessary files from release * update Podlove Web Player 4 = 2.7.7 = Update 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. The 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. So what's the new tracking approach? Podlove 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. To 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 = 2.7.6 = No changes. = 2.7.5 = **Preparation for GDPR/DSGVO** If you are using Podlove Publisher Tracking/Analytics, an update to this version is highly recommended. Tracking 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: First, we anonymize the IP before generating the hash. So instead of using `171.23.11.209`, we use `171.23.11.0`. Second, 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. In 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);'`. **Other** * fix Podlove Subscribe Button language parameter * fix `rel="self"` link in show feeds * fix Podlove Subscribe Button not delivering show feeds * templates: handle episode.show access when there is no show * templates: allow episode filtering by show, for example: `{% for episode in podcast.episodes({show: "example"}) %}` = 2.7.4 = No changes, but the previous release is not delivered correctly by WordPress, so this is simply a re-release attempt to fix it. = 2.7.3 = * fix: geo database updater * update Podlove Web Player 2: remove Flash Fallback * update Podlove Web Player 4 = 2.7.2 = * fix: `itunes:image` tag in show feeds * fix: "Debug Tracking" choosing wrong media files to check availability * enhancement: "Debug Tracking" now suggests disabling SSL-peer-verification if URL cannot be resolved and https is used * system report: include active plugins = 2.7.1 = * fix: PHP warning when the_title filter is called with only one parameter * fix: handle colons in migration tool * fix: PWP4 warning when using shortcode * new service: letterboxd = 2.7.0 = **New Module: Shows** With 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. For 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. The 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'}) }}`. We do not recommend using Shows and Seasons at the same time. **Updated Metadata for Podcast/Episode/Seasons according to iOS11 Specification** Apple 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: - The podcast has a new "type" field where you can select between "episodic" and "serial", which may affect the order of episodes. The field `episodic` appears in the feed. - 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 `Interview with Somebody Infamous` will appear in the feed. - 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 `full` appears in the feed. - Episodes have a new "number" field. If used, `42` will appear in the feed. - Episodes in seasons will have an `2` field in the feed automatically. We 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. To 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. **Template API Changes** - `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`. - the post title of an episode can still be accessed via `episode.post.post_title` - new accessor: `episode.number` - new accessor: `episode.type` - new accessor: `podcast.mnemonic` - new accessor: `podcast.type` - new accessor: `season.mnemonic` **Podlove Web Player 4** The Shortcode `[podlove-web-player]` accepts several parameters, increasing its versatility. With `post_id` you can embed episodes on any page, for example `[podlove-web-player post_id="1234"]`. Every [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"]` You 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"]` You 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. Podlove Web Player 4 is the new default player. **Other** * analytics: show download totals for last 24 hours and last 7 days in overview * Podigee Player: add support for transcripts - create a Podigee Transcript asset - set this asset in Expert Settings > Web Player - See https://cdn.podigee.com/ppp/samples/transcript.txt for an example transcript * Podlove Web Player 4: support contributors * player settings: when no episode or files are available, use a "Podlove" demo sound * reduce Podlove Template Cache duration from 1 day to 1 hour for the following change: * new template accessor: `{{ episode.total_downloads }}` * 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. * when using the Podlove Subscribe Button CDN and the CDN is not reachable, fall back to the locally hosted script * fix Geo DB Updater: use our own Podlove CDN as download source * fix quotes in contributor fields * fix WordPress conditionals in episode archives * fix deleting related episodes ([#907](https://github.com/podlove/podlove-publisher/issues/907)) * 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)) * fix import getting stuck issue ([#910](https://github.com/podlove/podlove-publisher/issues/910)) * Bitlove module: remove all frontend functionality because it has been dysfunctional for a long time * fix Auphonic module showing wrong status message after file upload * fix Audacity chapter import when times contain commas * fix email notification issue where not emails were sent ([#938](https://github.com/podlove/podlove-publisher/issues/938)) * fix feed redirect issue for HTTP/1.0 clients * fix network module: only activate when the plugin is activated network-wide, not when the plugin in active within a multisite * fix calculation of contribution counts * 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. * enhance email error reporting * enhance open graph module: detects WP SEO plugin and does not output any tags to avoid conflicts * social services: add SlideShare * show warning if upload directory is not fully qualified * remove download section from default template (because it is included in PWP4) * image cache: instead of returning invalid URLs with 0 width and 0 height when something goes wrong, return the source URL instead * episode list: add display option to display episode number as a column add Liberapay as donation service * display current season in episode form = 2.6.4 = Podlove Web Player 2: - fix: Remove Flash and Silverlight fallbacks due to security issue - fix: resolve compatibility issue with mediaelement library shipped with WordPress Podlove Web Player 4: - fix: newlines in summary are not converted to HTML linebreaks = 2.6.3 = - 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. - update mediaelement dependency in Podlove Web Player 2 - update Podlove Web Player 4 = 2.6.2 = - send `HTTP 410 Gone` when accessing a download URL to a depublished episode - fix Podlove Web Player 4 appearing on wrong positions when multiple players are embedded on the same page = 2.6.1 = - fix template bug (when a template returned an empty result, the template title was displayed instead) **Flattr** - rename "Flattr Username" to "Flattr ID" - insert `flattr:id` meta tag to page heads, which is required for their new system = 2.6.0 = **New Module: E-Mail Notifications** This is a new module complementing the existing contributor module. Once activated, it enables you to send emails to contributors when an episode gets published. You can find the settings at `Contributors > E-Mail Notifications`. The settings are: - _email subject and message_, customizable with all available template tags - a _time delay_ until the message is sent after the episode was published - who will receive the message by using _groups and roles as a filter_ **Podlove Web Player 4 Alpha** After 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. Give 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). Once Podlove Web Player 4 is stable, it will be the only actively supported Podlove Player. Podlove Web Player 3 isn't being developed anymore. And finally, all web players can now be previewed on the settings screen. **Templates** * Assets now have an identifier `asset.id` to quickly access a file for an asset within an episode, for example: `episode.file("mp3")` * 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")` * DEPRECATED: id parameter in `podcast.contributors` to access a single contributor: `podcast.contributors({id: "jerry"})`. * Feeds can now be accessed the same way by their slug: `podcast.feed("mp3")` **Analytics** Download tracking is now turned on by default in new setups. **Bits & Pieces** * network dashboard statistics: fix average length and file size; remove "days between episodes" * episodes: prettify "detect episode duration" UI * episodes: enhance media file UI: * rename "update" button to "verify" * move "update all media files" button above "verify" buttons and rename it to "verify all" * show file URL even if url cannot be reached * show Bytes in human readable format * episodes: fix focus when adding contributors to episodes * episode asset settings: increase assets per page from 10 to 100 * episode asset settings: fix format filtering by type * episode assets: new "name" attribute for reference in templates, for example `episode.file('cover').url` where "cover" is the asset name * support page: add Publisher icon to "Get Professional Support" callout * contributors: services only get deleted on "Save Changes", not immediately * contributors: fix sorting by contribution count in the admin interface * contributors: fix minor PHP issue when creating new contributors * templates: immediately purge cache on updating any template * Podlove Subscribe Button: add module option to deliver locally instead of using the CDN, but continue to default to CDN * job dashboard: add mode description to jobs where necessary to distinguish between different run settings * be tolerant to missing PHP iconv module * fix Auphonic chapter import * update Hindenburg chapter parser * new PHP constant `PODLOVE_DISABLE_IMAGE_CACHE` to disable image caching * fix security vulnerability (thanks to DefenseCode, who found the vulnerability using their tool ThunderScan and kindly approached us) = 2.5.3 = * fix broken settings tabs (introduced by German translation) = 2.5.2 = * fix episode sorting by recording date (`podcast.episodes({orderby: 'recordingDate'})`) * services: use https URLs where available ([#911](https://github.com/podlove/podlove-publisher/pull/911)) * services: add Spreaker ([#912](https://github.com/podlove/podlove-publisher/pull/912)) * contributors: adjust description of "Public Name" because it was misleading ([#914](https://github.com/podlove/podlove-publisher/pull/914)) * services: fix URL encoding issue ([#915](https://github.com/podlove/podlove-publisher/pull/915)) * network: fix system report notice that ensures module is setup correctly ([#916](https://github.com/podlove/podlove-publisher/pull/916)) * remove ADN link from support page = 2.5.1 = **Enhance Chapter UI** * fix encoding of "&" character * add support for Hindenburg project files = 2.5.0 = **New Chapter Management UI** Until 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. The 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. **Module: Import/Export** Podcast 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. **Background Jobs** The jobs dashboard on the tools page now shows job statuses in realtime (refreshes automatically). Adjusted 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. Please refer to the new [Background Jobs page in the documentation](http://docs.podlove.org/podlove-publisher/developer/background-jobs.html) for more details. **Bits & Pieces** * remove module: App.net (because they [are shutting down](http://blog.app.net/2017/01/12/app-net-is-shutting-down/)) * fix tracking export * redirects (expert settings): redirect counter can be reset * contributor avatars use WordPress media picker * optimize use of JavaScript: - only load scripts on pages that require them - concatenate and minify some scripts = 2.4.6 / 2.4.7 = - Image cache: support non-pretty permalinks. - fix feed compatibility issue with [Relevanssi plugin](https://wordpress.org/plugins/relevanssi/) - services: fix lastfm url scheme (thanks [jazzcrack](https://github.com/jazzcrack)) = 2.4.5 = Image cache: change URL encoding method to fix Gravatar issues. = 2.4.4 = Further image cache improvements: - reject URLs that are not images - prefix query vars to avoid naming conflicts with other plugins - fix resizing sometimes not calculating the correct dimensions - enhancement: skip http when using images shipped with the Publisher; copy images from Publisher to cache directory on filesystem instead = 2.4.3 = Fix issue with broken images introduced in 2.4.2. = 2.4.2 = **Improve Image Caches** Images 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. This 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. Now 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. **Other** * set correct feed Content-Type in HEAD requests and redirects * enhancement: repair & clear cache tools print a notice about other cache plugins * fix "Last Month" download widget in analytics = 2.4.1 = * services: Playstation Network Account now links to `http://psnprofiles.com` * allow spaces in episode slugs * add help tab to analytics pages * fix Podlove Web Player 2 timecode share link * fix issue with image cache filenames ([PR 895](https://github.com/podlove/podlove-publisher/pull/895)) * fix WP-Rocket incompatibility with Podigee Player * fix Auphonic: when neither episode image nor post thumbnail are present, fallback to podcast cover art * fix PHP warnings in oembed module = 2.4.0 = **Background Jobs System** Crunching 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. To 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. **New Analytics Dashboard** * "Downloads per Day" is a stacked bar chart now so you can see which episode is responsible for peaks * Downloads table now shows downloads in time segments starting from the moment of episode release for better comparability * Show total number of all downloads in Analytics Dashboard * New information under downloads table: age of the data and when the next refresh is due * _MUCH_ better performance / page load times * New option to configure how many episodes per page should be shown **Podlove Web Player 2 Facelift** * simplified, modernised look * responsive layout for mobile devices * fix: updated mediaelement library to fix volume bar display bug **Podigee Podcast Player** The [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. **New "Tools" Menu** * A new maintenance section gathers tools like the "Repair" button in one place * There is a new section for analytics related maintenance * Import & Export is now in the "Tools" menu **Improved Logging Display** * Logging is now in the "Support" menu * add filtering for different severities * hide "info" entries by default * improve readability of data sections **Other** * add Emoji support for episode subtitle, summary and chapter marks (requires WordPress 4.2 or newer) * Web Player setttings moved from Expert Settings to Podcast Settings * When activating the plugin, add mp3 asset and feed to help users get over the most confusing part of the setup. * Post thumbnails can be used as episode covers (see settings in "Episode Assets"). This is the new default. * 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.) * add unmistakable warning if curl is not available and provide actionable steps for a solution * change feed setting "Include HTML Content" default to "on" * remove log entries for beginning and ending asset validations * move feed protection into separate module * move debug log from Dashboard to Support page * add ptm_request parameter to redirected tracking URLs which contains a uuid * add fyyd.de module * add option to enforce http or https in URLs inside feeds (enclosures, images) and the actual feed URLs; see `Expert Settings > Website` * improve tracking: ignore 1-byte requests * update user agent library (new/updated clients: Podcat, Downcast, iCatcher, BashPodder) * show total downloads per site in network dashboard * remove `` from feed (it disappeared from the specification) * remove module: "Feed Validator" * update recommended image size to 3000x3000 pixel * add heartbeat to keep note of when tracking is active * `shortcode_exists($shortcode_name)` is now available in Twig templates * system report: add notice if ALTERNATE_WP_CRON is active * fix tracking export: keep httprange * fix compatibility with other plugins relying on Spyc library * fix `{{ episode.duration.totalMilliseconds }}` * fix image caching issue (invisible characters) * 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. * fix: show podcast covers in network site switcher * fix: expert settings not saving on some systems = 2.3.18 = * fix Auphonic authentication (https certificate issue) = 2.3.17 = * fixes a bug that broke some settings pages = 2.3.16 = * security: fix SQL injection * security: remove several Cross-Site Scripting vulnerabilities All vulnerabilities require admin capabilities. That means they cannot be exploitet easily, but could be using Cross-site request forgery (CSRF). Thanks to [RIPS Technologies](https://www.ripstech.com/) for reporting these issues. The issues were found using their Static Source-Code Analyzer RIPS. = 2.3.15 = * ensure 3rd party PHP dependencies do not require PHP 5.5 or greater = 2.3.14 = * fix: send episode cover to Auphonic if available * fix: improved download logic for `geoip.mmdb` should prevent faulty downloads * enhancement: error message for faulty `geoip.mmdb` includes instructions on how to manually fix the file * enhancement: automatically switch off geo tracking when no valid geo database is available * enhancement: clarify episode image asset options = 2.3.13 = * fix: sort contributor names while ignoring uppercase/lowercase * fix: when exporting a podcast, don't call `htmlspecialchars` on arrays because it breaks things * fix: image caching issue (invisible characters) * fix: broken geolocation database does not prevent playing episodes * fix `{{ episode.duration.totalMilliseconds }}` * fix: `{{ episode.duration }}` returns "00:00" if no duration is set * fix: contributor avatar URLs with umlauts * enhancement: check for geolocation database validity in tracking debug section * enhancement: add current theme and feed URLs to system report * Podlove Subscribe Buttons: parameters in templates and shortcodes can override Publisher provided fields: 'title', 'subtitle', 'description', 'cover' = 2.3.12 = Design Update for Podlove Subscribe Button * The button now follows a flat design and has more options for customizability. * See [docs.podlove.org/podlove-subscribe-button](http://docs.podlove.org/podlove-subscribe-button/) for a range of possible display variants. * 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. * 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). = 2.3.11 = * fix feed issue that appeared with WordPress 4.5 (wrong content type) = 2.3.10 = * when activating the plugin, add mp3 asset and feed to help users get over the most confusing part of the setup * fix tracking export: keep httprange * fix compatibility with other plugins relying on Spyc library * improve tracking: ignore 1-byte requests * update user agent library (new/updated clients: Podcat, Downcast, iCatcher, BashPodder) * remove `` from feed (it disappeared from the specification) * update recommended image size to 3000x3000 pixel * fix Podlove Subscribe Button iTunes link * add new "getting started" video to readme = 2.3.9 = * fix `open_basedir` related issues = 2.3.8 = **Bugfixes** * fix '&' issue in some fields when exporting/importing * player: pass podcast language code to web player v3 * open graph: do not include non-downloadable assets * open graph: use tracking URLs if available * template editor: add scrolling when having many templates in the list * auphonic: disable "Open Production" button when no production is selected * player: chapters visibility setting now applies to v3 beta player * more defensive feed gzipping for compatibility with various caching plugins * fix feed discovery cache (can now handle both http and https at the same time) **Enhancements** * enhance error message when resolving URL fails = 2.3.7 = * fix "add new" contributor button * fix migration class error * fix migration system report display * use default WordPress background color in migration wizard = 2.3.6 = **Bugfixes** * When creating a new contributor, social and donation services are saved correctly * Deleting a contributor shows the correct confirmation message **Enhancements** * 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) * Detect and warn if an episode slug has been used before = 2.3.5 = Update Web Player v3 * beta.6 https://github.com/podlove/podlove-web-player/releases/tag/v3.0.0-beta.6 * rc.1 https://github.com/podlove/podlove-web-player/releases/tag/v3.0.0-rc.1 * rc.2 https://github.com/podlove/podlove-web-player/releases/tag/v3.0.0-rc2 * rc.3 https://github.com/podlove/podlove-web-player/releases/tag/v3.0.0-rc3 = 2.3.4 = **Web Player (v3 Beta)** The new web player can be selected in `Expert Settings > Web Player`. Please try it out and send us your feedback. Thanks! * update player * add theme player options * fix player permalink parameter * fix player width on Mobile Safari **Other** * fix Auphonic workflow bug: when finishing a production, media files would sometimes erroneously be detected as not existing * detect when the configured Auphonic Preset does not exist * fix focus when adding new related episode rows * chosen search fields allow partial searches * enable Twig date extension to allow `time_diff` filter; see [Date Extension Documentation](http://twig.sensiolabs.org/doc/extensions/date.html) = 2.3.3 = Updating all the things for your pleasure. = 2.3.2 = * add template accessor `episode.post` to get WordPress post object * fix: template call `episode.chapters` returns an empty list when there are no chapters * fix: deleting image cache when no image cache directory exists * fix: cache purge also deletes timeout entries * fix: cache purge affects downloads table * fix: JavaScript event for secondary download button * fix: default template assignment on plugin activation * fix: unpublished relates episodes do not appear when using the shortcode or template accessor = 2.3.1 = * simplify download buttons (`[podlove-episode-downloads style="buttons"]`) style to better adapt to themes * fix: missing "Show URL" download button in twentyfifteen theme * fix: URL structure for YouTube channels * fix: player visibility when JavaScript is disabled * fix: stop loading nonexisting player assets in WordPress admin area * enhanced system report: change wording for `open_basedir` issue to clarify that it _should_ be fixed but a workaround exists * enhanced plugin loading * 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. * 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. = 2.3.0 = **New Module: Seasons** Do 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. New Template accessors: - `episode.season` returns the season for the episode - `podcast.seasons` returns a list of all seasons - `season.episodes` returns a list of all episodes in a season **New Module: Flattr** Everything Flattr related was moved into its own module. If you don't use Flattr, you can turn it off and it gets out of your way. * 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. * 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. **New Module: Related Episodes** You 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`. **Templates & Themes** If 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: - `\Podlove\get_episode()` - `\Podlove\get_podcast()` - `\Podlove\get_flattr()` - `\Podlove\get_network()` Please see the ["Understanding Templates" guide](http://docs.podlove.org/guides/understanding-templates/) for more details. **Other** * 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. * Analytics: Update & improve user agent detection library so you can have more accurate analytics. * 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. * News from podlove.org are displayed in the Podlove Dashboard * 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. * Contributors can now be edited in _Contributor Settings_ (instead of _Episodes > Contributors_) * Contributors Social Services: It is now possible to add a YouTube "Channel", not just user profiles * Contributors Social Donations: Add "paypal.me" option * 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. * 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. * 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. * Podcast cover image can now be uploaded using the WordPress media uploader. * Add `contributor.gender` template accessor * Rename network list "description" to "summary" for consistency. In templates `list.description` is now deprecated. Please use `list.summary` instead. * 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. * export files are now gzipped if possible * 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)!) * fix: failing geo-lookup does not break tracking links * fix: Remove WordPress favicon (since WP 4.3) from podcast feeds if a podcast image is set * fix: pasting into a template creates change-marker * fix: tracking import does not skip the last few entries = 2.2.4 = * fix: erratically missing chapter information in RSS feeds * fix: "Allow to skip feed redirects" setting was sometimes ignored = 2.2.3 = * fix: web player image fallback to podcast image when an episode image asset is defined but unused * fix: gzip compression: only set content type if headers have not been sent * fix: in networks, don't schedule template cleanups for blogs without an active Publisher = 2.2.2 = * fix: template cache issue where duplicate purge cronjobs could flood the cron system * fix: image cache validation (didn't work due to missing library) = 2.2.1 = * fix: App.net announcement preview in modules * fix: asset validations are always scheduled properly * fix: Remove method calls that require WordPress 4.0+ (wpdb::esc_like) = 2.2.0 = **Image Caching, Resizing & Retina Support** We now take better control of podcast images, episode images, contributor avatars and our own social icons. We are able to *resize* them to ideal sizes, which results in *faster page load times* for your users. *Retina images* for higher-resolution displays are also supported. We do this automatically, so all you need to do is click update, lean back and enjoy. Read 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/) This update increases the WordPress requirement from 3.0 to 3.5 (due to the required image editing functionality). **Other** * fix: duplicate feed discovery * fix: ignore incomplete feed configurations * fix: don't include network admin module css in frontend * fix: dashboard episode edit links * fix: when deleting WordPress Network sites, trigger plugin uninstall to remove database tables * fix: web player flash fallback * fix: network templates now also appear in the template widget and template auto-insert setting * fix: issue where some database tables were not created * fix: podcast covers are displayed in frontend admin menu bar * show Twig template errors in dashboard log * web player template tag can set tracking context: `episode.player({context: 'landing-page'})` * add `episode.categories` template tag **Deprecations** - deprecated `episode.imageUrl`, use `episode.image` instead - deprecated `episode.imageUrlWithFallback`, use `episode.image({fallback: true})` instead - deprecated `podcast.imageUrl`, use `podcast.image` instead - deprecated `service.logoUrl`, use `service.image` instead - deprecated `contributor.avatar`, use `contributor.image` instead While 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})`. = 2.1.3 = * add warning in system report for users with default permalink settings (which is problematic for some podcast clients) * enhancement: delete caches in all blogs when changing a network template * enhancement: delete caches when changing the template default assignment * enhancement: do not rely on openssl module * fix: add flattr setting to contributors general tab * fix: duplicate episodes when using `podlove.episodes` template accessor * fix: correctly fire plugin activation hooks in network mode * fix: ensure network module is activated correctly * fix: "Add New" link in empty list tables = 2.1.2 = * fix issue with users that have open_basedir set, which lead to all assets being invalid = 2.1.1 = * fix: remove obsolete "Add New" template button from network templates screen * fix: template autoinsert does not use deprecated "id" parameter * fix: template widget does not use deprecated "id" parameter * fix: duplicate episodes in feeds * 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. * fix: XSS vulnerabilities in contributors search * fix: Template accessor `contributor.id` now correctly returns the id, not the uri. `contributor.uri` is the new accessor to get the uri. * 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. * add "Add New Contributor" item to contributor select list. Selecting it opens the screen to add a new contributor. * add Twig version to system report = 2.1.0 = **Networks: WordPress Multisite Support is Here** - dedicated WordPress Multisite support - "My Sites" menu features podcast covers and menus include often used pages like "Podlove Dashboard" and episodes - Network Dashboard provides a birds-eye view over your podcast empire - Network-Templates that are accessible in every podcast - 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 **Widgets** We added a happy bunch of widgets to make your life easy. * Podcast Information: Display cover, subtitle and summary of your podcast * Recent Episodes: Display a list of recent episodes, with cover art and duration if you like * Template: Display any Publisher template in a widget area * Podcast License The 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. **Templates** * add accessors `{{ podcast.landingPageUrl }}`, `{{ podcast.subscribe_button }}` (see http://docs.podlove.org/reference/template-tags/#podcast) * add accessor `{{ flattr.button }}` (see http://docs.podlove.org/reference/template-tags/#flattr) * add accessor `{{ episode.podcast }}` * add query parameters to ``{{ contributor.episodes }}`: - group: Filter by contribution group. Default: ''. - role: Filter by contribution role. Default: ''. - post_status: Publication status of the post. Defaults to 'publish' - order: Designates the ascending or descending order of the 'orderby' parameter. Defaults to 'DESC'. - 'ASC' - ascending order from lowest to highest values (1, 2, 3; a, b, c). - 'DESC' - descending order from highest to lowest values (3, 2, 1; c, b, a). - orderby: Sort retrieved episodes by parameter. Defaults to 'publicationDate'. - 'publicationDate' - Order by publication date. - 'recordingDate' - Order by recording date. - 'title' - Order by title. - 'slug' - Order by episode slug. - 'limit' - Limit the number of returned episodes. **Other** * add gender contribution statistics to dashboard * add expert setting "Allow to skip feed redirects" * add warning in tracking settings when default permalink structure is used * add support for Auphonic cover art * add support for Jetpack "Publicize" module to podcast post type * add warning when open_basedir is set to system report * add daily cleanup of logging table (only keep entries of previous 4 weeks) * contributor editing has a tabbed interface * improved Podlove Dashboard performance * 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. * fix: remove Jetpack "Site Icon" from podcast feeds * fix: empty template editor when last template is deleted * fix: empty caches when a scheduled episode gets published * fix analytics episode average calculation for ancient episodes **API changes** * Flattr parameter in `[podlove-episode-contributor-list]` now defaults to "no". If you need to reactivate it, use `[podlove-episode-contributor-list flattr="yes"]` * `[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. * `[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. = 2.0.5 = * fix: template editor cursor position in Safari (by changing to a different theme that doesn't use bold styles) * fix: double escaped feed enclosure URLs when using non-pretty-permalinks = 2.0.4 = * fix: missing flattr attribute for contributors * fix: subscribe button description is properly wrapped in p-tags * fix: faulty valid file if check returns "unreachable" but includes a Content-Length header * fix: more thoughtful handling of ETags when validating files prevents failing updates * fix: "NaN" analytics should display properly now * fix: off-by-one display in analytics * fix: don't HTML-encode quotes in episode title/subtitle/summary since it leads to invalid feeds * add trakt.tv to the services list * add support for RSS channel image tag = 2.0.3 = *Allow Non-Admins to access Analytics* Analytics have a new capability called "podlove_read_analytics". You can provide access to, for example, editors, using the following code snippet: function podsnip_add_capability() { // default roles: editor, author, contributor, subscriber $role = get_role('editor'); $role->add_cap('podlove_read_analytics'); } add_action( 'admin_init', 'podsnip_add_capability'); You can add snippets using the "Code Snippets" plugin. *Bugfixes* * fix: use proper HTTP method to create/update/delete templates * fix: don't remove URLs from chapter marks when saving * fix: optional episode form elements can be saved = 2.0.2 = * fix: include missing YAML library * fix: namespacing issue in uninstall procedure * fix: debug tracking example file must be downloadable = 2.0.1 = **Bugfixes** * fix: properly sanitize episode form data (fixes "A wild Backslash appears") **Enhancements** * format download numbers in episode list * remove check for PHP setting `allow_url_fopen` because we don't rely on it any more = 2.0.0 = **Download Analytics** You want to know more about who listens to your podcast? We got you covered. We 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_. If you are interested in all the technical details, head over to http://docs.podlove.org/guides/download-analytics/. But 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! We 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/ **Bugfixes** * fix: use `home_url()` instead of `site_url()` to generate tracking URLs * fix: tracking export does not get stuck forever when it fails once * fix: disappearing podcast description settings * fix: add function to repair button that removes duplicate episode entries * fix: template editor does not forget changes if you reselect a template after changing it * fix: improve uninstall routine * fix: wrong month when choosing Auphonic productions * fix: deactivate Jetpack's OpenGraph when the Publisher OpenGraph module is active **Other Changes** * add services: miiverse, prezi * add missing services via repair button * Bitlove: add `` to RSS feed and use this to identify files * moved episode GUID regeneration into separate metabox because it's rarely required * always check media files when opening an episode edit page * move podcast cover art from media tab to description tab * Improved feed settings * check for missing and duplicate slugs * check for missing asset assignment * show prominent warning for detected problems * provide contextual help to better understand what's required and why **Removed Functionality** * removed module "Auphonic Production Data" * removed the following shortcodes (use [Template Tags](http://docs.podlove.org/reference/template-tags/) instead) * `[podlove-episode-subtitle]` * `[podlove-episode-summary]` * `[podlove-episode-slug]` * `[podlove-episode-duration]` * `[podlove-episode-chapters]` * `[podlove-episode field="..."]` * `[podlove-podcast field="..."]` * `[podlove-show field="..."]` * `[podlove-podcast-license]` * `[podlove-episode-license]` * `[podlove-contributors]` (use `[podlove-episode-contributor-list]` instead) * `[podlove-contributor-list]` (use `[podlove-episode-contributor-list]` instead) * removed the following template tags * `{{ contributor.publicemail }}` (use social module instead) * `{{ license.html }}` (use `{% include '@core/license.twig' %}` instead) = 1.12.1 = * fix: catch failed IP categorizations * fix: solve PHP notice * add custom icon to close template fullscreen mode * add custom contributor css to look nicely in twentyfifteen theme = 1.12 = - 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()` - enable episode filtering by category slug: `podcast.episodes({category: "kitten"})` - redesigned template editor interface - fix feed cache issue which lead to enclosure URL mixups - display PHP deprecation warning aggressively for everyone below 5.4 = 1.11.2 = - Cache feed items. This drastically reduces load when no feed proxy is used; especially in a "full feed" with many episodes. - Add Luxembourgish to languages = 1.11.1 = Subscribe Button fixes & enhancements: - don't pass undiscoverable feeds to the button - don't show a button if no feed is available - change defaults to "big-logo" and "autowidth" - fix issue with internal format = 1.11 = Say 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. More info on those sites: * Homepage: http://podlove.org/podlove-subscribe-button/ * Help Translate: http://translate.podlove.org * GitHub: https://github.com/podlove/podlove-subscribe-button **Other Changes** * fix `contributor.episodes`: only show published episodes * fix redirect form: remove url validation * fix HEAD requests for download URLs * redirects are counted and displayed in the redirect settings = 1.10.23 = **Bugfixes** * fix social repair module * empty rss feeds now render properly * fix issue of randomly breaking URLs * fix missing files when using auto-publish feature by automatically validating files before publishing * fix "open" link for last contributor donations item * fix javascript error in license ui **New Features** * 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. * add support for scientific networks: ResearchGate, ORCiD, Scopus * add explicit support for "Duplicate Post" plugin: duplicated episodes now regenerate GUIDs and contributions are copied, too **Enhancements & Others** * contributors form: * switch public name and real name fields * remove public email field (see deprecations) * move contact email field to general section * ADN module: add option to not fall back on episode cover when no episode image is present * adjust Bitlove script so it plays well with https sites * include date in tracking export filename * move web player settings to expert settings * public contributor emails are handled by the social module now, instead of being a contributor attribute **Deprecations & Migration** If you are using `{{ contributor.publicemail }}` in your templates, you should change it to something like the following: {% for service in contributor.services({type: "email"}) %} {{ service.rawValue }} {% endfor %} = 1.10.22 = * fix bug in contribution counting * simplify internal cache key handling to avoid technical issues * support more licenses (CC4.0, CC0, Public Domain) * tracking: don't count HEAD requests * tracking: add manual migration notice to delete accidentally recorded HEAD requests = 1.10.21 = * improve HHVM compatibility * resolve bug concerning internal article linking * 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) = 1.10.20 = **Episode Form Improvements** * Reorder components * Display episode title in episode meta box * 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. **Other** * Podlove Dashboard supports screen options * fix contribution counting in contributor table (you may have to hit the "repair" button in `Podlove > Support` if you still see wrong numbers) * fix tracking data export * fix missing OpenGraph metadata * improved redirects: added sortability and individual entries can be deactivated without being deleted * `contributor.id` is accessible via template API now As 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/ = 1.10.19 = * fix caching issue (cache keys were too long in last update, resulting in no cache hits at all) * fix error when creating a new episode = 1.10.18 = **Improvements to media file slugs** * Slugs may contain slashes now. This allows storing asset files in subfolders and using the WordPress media uploader to manage files. * Media file validation is more consistent: when you get a green checkmark, the file is guaranteed to be valid and reachable. **Other** * 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. * Rename a method to avoid a bug in early PHP 5.3 versions = 1.10.17 = * tracking now includes range headers * plugin-migrations are more robust now * add caching for OpenGraph module * fix escaping in database logger * fix feed validator for sites not using "pretty permalinks" * fix dashboard box state saving * fix generation of faulty URLs when tracking was on but pretty permalinks off * fix auto-insertion of nonexisting templates * fix routing issues when `/%category%/%postname%` is used as permalink structure * fix rare cache concurrency issues by introducing a 24h auto-expiry * remove "Critical Podlove Warnings" — they are scary and don't help a lot = 1.10.16 = * Hotfix: remove wrong output in HTML sites * rework support page = 1.10.15 = **Various Fixes and Enhancements** * Supply web player API with more data: "publicationDate" contains an ISO-8601 date and "show.url" the URL to the show. * Auphonic UI improvement: When selecting a production, the "Select existing production" option disappears. * Don't pass `redirect=no` parameter to feed URLs * Ensure web player IDs are unique to avoid rendering bugs * Fix caching bug that lead to disappearing web player and download buttons * Fix redirection UI bug * Flush rewrite rules after migrations to avoid broken links = 1.10.14 = **Performance** A 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. This 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. Caching can be deactivated in the `wp-config.php` with the following line: `define('PODLOVE_TEMPLATE_CACHE', false);` * Cache Publisher templates * Cache feed discovery header * Cache Bitlove widget * Other minor performance improvements **Templates** * There is now a default template containing the player and download section * Episode contributions can be sorted by comment and position, for example: `episode.contributors({orderby: "comment", order: "DESC"})` or episode.contributors({orderby: "position", order: "ASC"}) * Iterate over the list of episode tags: `{% for tag in episode.tags({order: "DESC", orderby: "count"}) %} {{ tag.name }} {% endfor %}` **Other** * Display available processing time in Auphonic production box * Episode slugs may contain a wider variety of characters now, such as umlauts. * Feeds now only contain contributors with an URI. Also, output of contributors in feeds can be filtered by group and/or role. * New donation option for Auphonic Credits * Remove scary debug output on failed media file validations. This can be found in the log now. * Fix Auphonic authentication issue by providing the whole certificate chain * Fix contributor related feed rendering issue = 1.10.13 = We 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: 1) 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). 2) 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. But 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). In *Apache*, you need the `headers` module (`a2enmod headers` on Debian-ish distributions). Then you can add this to your configuration: Header set Content-Disposition attachment *lighttpd*: $HTTP["url"] =~ "\.(mp3|m4a|ogg|oga|ogv|opus|mpg|m4v|webm|flac|pdf|epub|zip)$" { setenv.add-response-header = ("Content-Disposition" => "attachment") } *Nginx*: if ($request_filename ~ "\.(mp3|m4a|ogg|oga|ogv|opus|mpg|m4v|webm|flac|pdf|epub)$"){ add_header Content-Disposition 'attachment'; } **Other Changes** * Trim whitespace around some URLs that appear in the podcast feed. * Update certificate for auth.podlove.org * Fix an issue with saving contributors in `Podlove > Podcast Settings > Contributors` = 1.10.12 = **Tracking** * 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). * Handle empty user agent strings * Do not write anything to tracking-database unless tracking is in analytics mode. **Other** * Compress export files via gzip. * Add tracking data to export files. = 1.10.11 = **Tracking** * For increased compatibility (we are looking at you, iTunes), new file URLs without parameters are used when analytics are active. * Add `&ptm_file=.` parameter to the end of Parameter-URLs, so tools like wget generate a filename with a correct extension by default. * 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. * Fix PHP glitch that caused tracking to go into "Tracking URL Parameters" mode even when it was disabled **ADN Module** * Fix issue that could lead to repostings * Fix tags description * Messages longer than 256 characters will be shortened now and "…" will be appended **Other** * Fix: reenable "force download" option = 1.10.10 = We 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. If you're of the curious type, feel free to activate it and tell us any issues you run into. Thanks! = 1.10.9 = * 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` = 1.10.8 = * 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_. * 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. * 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. * Feature: Allow manual posting of ADN announcements * Feature: Add contributor support to ADN announcements * 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. * Feature: The feed `` 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. * Enhancement: remove encryption for "protected feed" password to prevent autofill browser features to destroy contents * Enhancement: default WordPress search now covers episode subtitle, summary and chapters * Enhancement: add Vimeo, Gittip and about.me to services * 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. * Enhancement: sort chapters imported from Auphonic by time * Enhancement: Changes to feed list: redirect URL is shown and added screen options to hide columns * 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. * Fix: enable group and role selection in contributor shortcodes * Fix: failing delayed ADN broadcast * Fix: stop sending ADN announcements for old episodes * Fix: refresh of Auphonic presets keeps current preset * Fix: `contributor.episodes` does not return duplicate episodes any more * Fix: Jabber URL scheme is now prefixed with `jabber:` * Fix: Display podcast subtitle in feed description (it was the blog description before) * Fix: Hide contributors missing a URI from feeds * Fix: Escaping issue when saving podcast description settings = 1.10.7 = * Feature: Direct episode access in templates via `{{ podcast.episodes({slug: 'pod001'}).title }}` * 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) * Feature: Direct contributor access in templates via `{{ podcast.contributors({id: 'john'}).name }}` * Feature: Add shortcode `[podlove-podcast-social-media-list]`, which lists all social media accounts for the podcast * Feature: Add shortcode `[podlove-podcast-donations-list]`, which lists all donation accounts for the podcast * Feature: Add tag support for Auphonic * Enhancement: Add "Save and Continue Editing" buttons to all table based management screens * Enhancement: Use translations for month and day names in formatted template dates (if a language other than english is used) * Enhancement: Add refresh buttons for Auphonic preset selector * Enhancement: Pass more data to web player (as preparation for the next release) * 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. * Remove default content for new templates * Fix: "Network Activate" works now * Fix: group and role filters for `[podlove-podcast-contributor-list]` shortcode work as expected now * Fix: Add services and donations to export format * Fix: `episode.player` in episode loops, outside the WordPress loop works now * Fix: Auphonic chapter integration issue * Fix: Instagram URL scheme = 1.10.6 = * Fix: contributor services will be saved correctly * Enhancement: add a donation column to contributor management table = 1.10.5 = **Changes to the Templating System** `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. **Other Changes** * Enhancement: Add refresh buttons for ADN patter and broadcast channel selectors * Fix: Avoid "Grey Goo" scenario of self-replicating contributors = 1.10.4 = * Hotfix: solve migration issue = 1.10.3 = **Changes to the Templating System** * 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"; * For consistency `{{ contributor.avatar }}` is now an object. To render an HTML image tag, use `{% include '@contributors/avatar.twig' with {'avatar': contributor.avatar} only %}`. * `{{ 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. __DEPRECATIONS/WARNINGS__ * `{{ 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) }}` * `{{ 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} %}`. **Other Changes** * Feature: ADN Module supports broadcasts * Enhancement: Contributor shortcode defaults to `donations="yes"` to avoid confusion * Enhancement: `[podlove-episode-downloads]` now uses templates internally * Enhancement: Added 500px, Last.fm, OpenStreetMap and Soup to Services * Enhancement: Use custom contributor social/donation titles as icon titles * Enhancement: Template form has a "Save Changes and Continue Editing" button now * Enhancement: feed validation is asynchronous now and has improved performance * 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. * Enhancement: Default MySQL character set is utf8 now when creating tables * Enhancement: Add datepicker for episode recording date * Fix: all default contributors appear in new episodes again * Fix: change Tumblr URLs from https to http since Tumblr does not support them * Fix: `[podlove-podcast-contributor-list]` shows the correct contributors now * Fix: internal template warning when accessing empty contributor roles or groups * Fix: episode rendering when no files are available * Fix: flattr script in rss feeds * Fix: importer issue where sometimes modules would not activate properly = 1.10.2 = * Feature: add template filter `formatBytes` to format an integer as kilobytes, megabytes etc. Example: `{{ file.size|formatBytes }}` * Feature: New accessor `{{ file.id }}`. This is required to generate download forms. * 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"]` * Fix: division by zero bug in statistics dashboard * Fix: parse time in statistics dashboard correctly as normalplaytime * Fix: add missing template accessor `{{ episode.recordingDate }}` * Remove separate "publication date" field in episodes. Instead, use the episode post publication date maintained by WordPress. It can be accessed via `{{ episode.publicationDate }}` * Fix: missing contributor-edit-icon on last entries = 1.10.1 = * Fix: podlove-episode-contributor-list shortcode: add support for "group" and "role" attributes * Fix: podlove-episode-contributor-list shortcode: fix broken flattr button * Fix: feed widget: only compress if zlib extension is loaded = 1.10.0 = **All-new, mighty Templating system** You 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. If 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: Instead of `[podlove-web-player]`, write `{{ episode.player }}`. Instead of `[podlove-podcast-license]`, write `{{ podcast.license.html }}`. Instead of `[podlove-episode-license]`, write `{{ episode.license.html }}`. Instead 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). Changing 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). **Other Changes** * 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. * 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. * Feature: Support for the oEmbed format * 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. * New shortcode: `[podlove-feed-list]` lists all public feeds * New shortcode: `[podlove-global-contributor-list]` shows all podcast contributors and lists related episodes. * New shortcode: `[podlove-podcast-contributor-list]` shows regular podcast contributors * Enhancement: The feed title may now include the asset title for easier discovery. This setting can be found at `Podlove > Feed Settings` * Changed shortcode: `[podlove-contributor-list]` is _DEPRECATED_. Please use `[podlove-episode-contributor-list]` instead. * Enhancement: add "autogrow" feature to chaptermarks text field * Enhancement: globally hide the migration-tool banner once dismissed rather than per-client via cookie * Fix: When setting the chapter asset to manual, delete all chapter caches to avoid hiccups * Fix: Contributor links in the backend use an ID now rather than the contributor slug. That way they work when no slug is set. * Fix ADN backslash escaping issue in post titles * Fix: all contributions can be deleted = 1.9.12 = * Enhancement: Take over chapters when switching from chapter asset to manual * Enhancement: Contributor tables look better in a wider range of themes * Fix: Auphonic module: Buttons cannot be clicked again while the corresponding action is in progress = 1.9.11 = * Enhancement: Split podcast settings into tabs. * Enhancement: Import/Export module supports contributors and contributions * 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]`. * Enhancements: Plethora of adjustments in contributor interfaces to avoid confusions and smoothen workflows * Feature: Contributions may have a public comment (to describe the context of the person), which can be displayed in contributor lists. * Fix: Skip contributions with missing contributors. = 1.9.10 = * Fix: episode images when using manual entry * Fix: do not include episodes in blog feed * Fix: paged feed calculation of number of pages when using global Publisher default * Fix: remove unused IDs from contributor lists = 1.9.9 = * Fix: several contributor episode form bugs * Fix: sum of all media file sizes in dashboard statistics * Add lost bugfix: Bundle crt file to avoid StartSSL trust issues. = 1.9.8 = * Enhancement: WordPress has an option to close commenting for posts after a certain amount of days. This now also applies to podcast episodes. * Enhancement: Fallback for Contributor Names. * 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). * fix sum of all media file sizes in dashboard statistics * fix license URLs * fix feed paging issue * Fix: Feed Item Limit is now displayed correctly * Fix: Ignore deleted contributors if they were assigned to an Episode or Podcast * Fix: activation / deactivation of multiple modules at once works as expected now * add filter "podlove_enable_gzip_for_feeds" to disable gzip feed compression * Contributor role and group columns will be hidden if no roles or groups were added = 1.9.7 = * fix and enhance dashboard statistics * gender statistics: use episode contributions instead of contributors for counting = 1.9.6 = * fix redirect issue after podcast migrations * fix legacy ADN module publishing issue * only show `itunes:complete` in feeds if it is set avoid a feedvalidator.org bug * add experimental episode fun facts in dashboard * add PayPal Button link in contributor settings * other contributor admin enhancements * contributor public name defaults to real name now = 1.9.5 = * Contributor Module improvements * New icon graphics * "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. * 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) * The contributors defined in `Podcast Settings > Contributors` are now the default contributors for new episodes * Reworked contributor management table. Better use of space, hideable columns, avatars and more. * Reworked episode contributor table. Avatars, edit links and more. * Support for more services * ... and a bunch of other tweaks * Web Player Update: compatible with WordPress theme "Twenty Fourteen" * Fix: don't gzip feeds when zlib compression is active * Fix: episode media file checkbox width for WP3.8 * Fix: menu icons for WP3.8 = 1.9.4 = * Fix: gzip feeds on compatible systems only (avoids failing feed generation) * Fix: Feed paging (again) = 1.9.3 = * Fix: provide global feed limit default on setup * Fix: managing contributor roles no longer outputs permission issues * Fix: corrected a faulty "Add New" contributor link * Fix: paged feeds were broken = 1.9.2 = * Fix: _Module: Contributors_ prevent initial migration to import duplicate contributors * Fix: _Module: Contributors_ Fix faulty default roles = 1.9.0 / 1.9.1 = **New Module: Contributors** Podcasts 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). **Simple Protected Feeds** You 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. **License Selector** We 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. **Other Changes** * 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. * Feature: add global setting to configure feed item limits * Feature: Set "itunes:explicit" tag per episode if you want to (you have to activate the feature in the expert settings) * Enhancement: Feeds are delivered with gzip compression if possible * Enhancement: Support for temporary redirects in expert settings * Fix: keep ?redirect=no flag in paged feeds * Fix: _Module: Import/Export_ Importing episodes no longer causes floods of ADN posts. * Fix: _Module: Auphonic_ respect Auphonic chapter offset * _DEPRECATED_: `podlove-contributors` shortcode. Use `podlove-contributor-list` instead = 1.8.13 = * Feature: Update Web Player to 2.0.17 (for realsies). It fixes an issue with icon/font display. = 1.8.12 = * Feature: Update Web Player to 2.0.17 * Bugfix: Fix PHP 5.3 issue in import module = 1.8.11 = * Feature: New module for Import/Export. Now you can easily move all your podcast data to another WordPress instance. * Feature: Add support for `` tag. If there won't be any additional episodes, you can go to `Podlove > Podcast Settings` and activate this setting. * Bugfix: Bundle crt file to avoid StartSSL trust issues. = 1.8.10 = * Hotfix: Removes incompletely updated license feature which wasn't supposed to be in that release in the first place. Sorry! = 1.8.9 = * Feature: Update Web Player to 2.0.16 * Enhancement: Render Twitter and OpenGraph tags using a DOM-Generator to avoid all possible escaping issues. * Enhancement: Allow multiple mime types for web player config slots. Fixes an issue with Firefox and Opus. * Enhancement: I CAN HAZ SECURETEH?! auth.podlove.org haz https nao. * Bugfix: Module settings screen rendering issue with PHP 5.3 * Bugfix: Fix link to shortcode documentation = 1.8.7 / 1.8.8 = * 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 * Hotfix: escaping issue = 1.8.6 = * Enhancement: Change feed redirect hook and priority so it works better with Domain Mapping plugin * Enhancement: Extend OpenGraph metadata by post thumbnail and episode description (thanks smichaelsen!) * Feature: Update Web Player to 2.0.15 * Fix: Solve rare issue where first chapter line would be ignored * Fix: Firefox display issue in migration assistant = 1.8.5 (2013-08-11) = * Fix: JavaScript issue preventing certain UI elements from working correctly (Tagging, Auphonic, …) = 1.8.4 (2013-07-27) = * Fix: Performance issue in Auphonic plugin = 1.8.3 (2013-07-27) = * Enhancement: dates with leading zeros in Auphonic module * Enhancement: Auphonic UI smoothifications * Enhancement: Update assets after successful production = 1.8.2 (2013-07-27) = Auphonic integration Enhancements * Preset is only applied once * Add Text for "Open Production" button * "Start Production" button more prominent = 1.8.1 (2013-07-27) = * Fix Release = 1.8.0 (2013-07-27) = * 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. * App.net Module Update. Support for Patter, language annotations and delayed posting. * Enhancement: Control sequence in which audio elements are printed in the web player. This encourages browsers to use superior codecs (rather than mp3). = 1.7.3 (2013-07-18) = * Enhancement: Show expected and actual mime type in log when an error occurs * Bugfix: Fix Bitlove integration * Bugfix: Correctly hide content in password protected posts * Bugfix: ADN Plugin announced new episode every time the episode got saved * Fix some PHP 5.4 Strict warnings = 1.7.2 (2013-07-11) = * Feature: Update Web Player to 2.0.13 * Bugfix: Feed web player with existing/valid files only * Bugfix: Downloads work without JavaScript enabled * Bugfix: Episode previews should work now * Bugfix: Migration Assistant: you are now able to import file slugs containing dots * Bugfix: Fix podlove_alternate_url issue = 1.7.1 (2013-07-06) = * Logging Module: Deactivate sending of mails until we figure out what causes some misbehaviours * Enhancement: System Report: check for SimpleXML availability * Bugfix: ADN Announcements should work with all kinds of templates now = 1.7.0-alpha (2013-07-03) = * 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!) * 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!) * 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. * Enhancement: Remove download button styles so the style adjusts based on used browser and theme * Bugfix: Fix incompatibility to some file name schemes * Bugfix: Fix 404 status for paged feedburner feeds = 1.6.11-alpha = * Bugfix: use NPT library = 1.6.10-alpha = * Fix release issues = 1.6.7-alpha = * Enhancement: Move file types settings to expert settings * Enhancement: Saving a template redirects to template list * Enhancement: System Report is a readonly textarea * Enhancement: Group modules * Enhancement: When creating an asset: if that web player slot is not taken yet, assign it automatically * Enhancement: Accept time formats with minutes > 59 if no hours are given * Bugfix: Fix "Chapters Visibility" setting = 1.6.6-alpha = * Enhancement: When validating, ignore timeouts (so files don't disappear from feeds just because one request took too long) * Enhancement: When episode permalinks are invalid, try to autoresolve by switching to "Use Post Permastruct" * Bugfix: Fix some expert setting migration issues * Bugfix: Hide invalid media files from downloads = 1.6.5-alpha = * Feature: Feeds are sortable * Feature: You can revalidate single media files in the dashboard * Enhancement: Use pretty status icons * Enhancement: Add "sortable handle" for asset and feed lists, so the sortability feature is more discoverable * Enhancement: Add "Podlove" entry to WordPress toolbar * Enhancement: Organize "Expert Settings" into tabs * Enhancement: Don't log "File not Modified" * Bugfix: Activate feature "Activate asset for all existing episodes" for pending episodes * Bugfix: Solve issue with chapter asset cache invalidation * Bugfix: Solve chapter encoding issue when chapters start with umlauts * Bugfix: Fix video display in some themes * Other small UI changes in various places = 1.6.4-alpha = * Bugfix: use manual chapter entries if available * Bugfix: PSC assets work properly * Bugfix: URL magic doesn't interfere with other post types * Bugfix: deactivate preload in web player = 1.6.3-alpha = * Bugfix: "Display episodes on front page together with blog posts" works again * Bugfix: chapters at 0 seconds are not ignored any more * Bugfix: correctly show feed title in deletion confirmation * Bugfix: handle missing/invalid PSC file with appropriate grace * Bugfix: remove player from feed * Bugfix: fix false negatives in error log; reenable logging-mails * Bugfix: fix timezone in logs = 1.6.2-alpha = * Bugfix: fix template autoinsert migration issue = 1.6.1-alpha = * Bugfix: fix call-time pass-by-reference * Bugfix: deactivate logging-mails until we find out what's wrong = 1.6.0-alpha = * 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. * Feature: always print PSC in feed if any chapter format is available (psc, mp4chaps, json) * Feature: upgrade web player to v2.0.10 * Enhancement: template autoinsert settings are on templates page now * Enhancement: correctly fall back to podcast image when episode image is activated but missing * Enhancement: various UI fixes (thanks @MaZderMind) * Enhancement: improve feed deletion dialogue * Enhancement: default title for episode assets is file format title * Bugfix: solve permalink issue after migrations * Bugfix: migrate comment hierarchy correctly = 1.5.4-alpha = * Feature: PubSubHubbub support via new module * Enhancement: Check for iconv availability in system report * Turn permalink compatibility up to eleven = 1.5.3-alpha = * Bugfix: more robust permalink fix = 1.5.2-alpha = * Bugfix: Fix using the same permalink structure / 404 on pages = 1.5.1-alpha = * Enhancement: episodes may share the same permalink structure with WordPress posts * Enhancement: episode archive url can be configured * Enhancement: run system report more intelligently * Enhancement: Auphonic module works more smoothly for new episodes * Enhancement: Fallback to 302 redirects for HTTP/1.0 clients * Enhancement: Confirm before deleting feeds and templates * Enhancement: Parse time strictly following the NPT specification: http://www.w3.org/TR/media-frags/#npttimedef * Bugfix: don't use feed redirect when a feed archive page is specified = 1.4.8-alpha = Minor fixes and improvements: * feed: remove style tags from content:encoded (feedvalidator.org warning) * feed: ensure description precedes content:encoded (feedvalidator.org warning) * prevent feed proxy issue * `HEAD` requests for paged feeds return correct responses * enable paging for `/podcast` archives * add description to redirect settings * rename "record date" to "recording date" = 1.4.7-alpha = * Hotfix: ignore empty redirect rules = 1.4.6-alpha = * Bugfix: The podcast archive is available via `/podcast` again. = 1.4.5-alpha = * Enhancement: always show critical errors found by system report * Enhancement: flush rewrite rules after migration and feed changes * Enhancement: redirect settings support URL parameters = 1.4.4-alpha = * Feature: configure permanent redirects in Expert Settings * Bugfix: fix feed url generation for "default style" permalinks * Bugfix: migration assistant shows enclosure errors/warnings * Bugfix: add missing atom prefix in feed link elements * Bugfix: generate valid episode permalinks for "Default"/"Not Pretty" permalink settings * 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. = 1.4.3-alpha = * Bugfix: fix system report issue * Bugfix: fix feed setting "No limit. Include all items." = 1.4.2-alpha = * Bugfix: add Auphonic metadata file type * Bugfix: fix bug regarding limiting feed items = 1.4.1-alpha = * Bugfix: reactivate /podcast url = 1.4.0-alpha = * 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! * Feature: Support paged feeds (RFC5005) so clients may always fetch all episodes even if the default feed only contains the most recent episodes * 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. * Feature: New module "Auphonic Production Data". Thanks @tobybaier! * Enhancement: Update Web Player to v2.0.7 * Enhancement: open graph title is podcast title = 1.3.30-alpha = * Feature: Option to autoinsert web player at beginning or end of post * Feature: Add "Support" page including a system report * Enhancement: Add .post class to article-classes list to improve theme compatibility * Bugfix: Fix feed validation mixup * Bugfix: Support "future publishing" of episodes (thanks Marc!) = 1.3.29-alpha = * Bugfix: Fix some media file mixups = 1.3.28-alpha = * Feature: Two new episode fields `publication_date` and `record_date`. Accessible via episode shortcode. Must be enabled in expert settings. * Feature: Assets can be sorted via drag'n'drop. Influences download button/list order. * Bugfix: fix "No More Enclosures" feature. I was using a deprecated hook * Enhancement: upgrade Podlove Web Player to 2.0.5 * Enhancement: move episode asset url to expert settings * Change: Drop support for Atom feeds * Change: Remove support for mnemonic and Episode Assistant module In the beginning, everything evolved around the episode numbers and the mnemonic. Then, it made sense to support this concept by something like the episode assistant. Now, the mnemonic is merely an afterthought. It's used by no part of the system except the episode assistant. And this doesn't do a lot that can't be done without it either. So we decided to drop both for now. A similar concept might return once we tackle stuff like seasons. = 1.3.27-alpha = * Enhancement: enforce trailing slash at the end media file base url * Enhancement: fix huge download-select-font * Enhancement: doublecheck curl availability * Bugfix: double quote escaping for Web Player title, subtitle and summary = 1.3.26-alpha = * Enhancement: upgrade Podlove Web Player to 2.0.4 = 1.3.25-alpha = * Feature: Setting for Web Player to show or hide chapters by default * Enhancement: Open Graph now correctly excludes non-audio assets * Enhancement: "File not found" errors now result in some debug output which may help tracing the issue * Enhancement: upgrade Podlove Web Player * Bugfix: Generated Template shortcodes now use the "id" attribute rather than "title" = 1.3.24-alpha = * Enhancement: remove mediaelementjs demo files = 1.3.23-alpha = * Enhancement: upgrade Podlove Web Player * Enhancement: improve handling of url_fopen setting * Enhancement: feed item limit is now a select box. default is now "all" instead of "WordPress Default" = 1.3.22-alpha = * Hotfix: solve White Screen of Death issue for PHP 5.4 = 1.3.21-alpha = * Bugfix: allow deletion of unused assets * Enhancement: if an asset shouldn't be deleted, display where it's in use (allow deletion anyway) * Enhancement: Downloads redirect to file if `allow_url_fopen` is disabled. = 1.3.20-alpha = * Enhancement: always add a trailing slash to media file base url * Bugfix: trying to fix escaping part whatnotsoever = 1.3.19-alpha = * Hotfix: slugs are not forced into lowercase any more = 1.3.18-alpha = * Feature: Module for Bitlove.org support! Adds links to torrent-files to the downloads-section of your episodes. * Feature: add video support for web player * Enhancement: fix a (possibly rare) memory bug when downloading files * Enhancement: enable episodes on home page by default * Enhancement: change default download widget style to the select-thingy * Bugfix: fix feed warning = 1.3.17-alpha = * Bugfix: fix issue with 3rd party custom post types * Enhancement: improve Feed Settings screen = 1.3.16-alpha = * Feature: new style for file downloads `[podlove-episode-downloads style="select"]` * Enhancement: Solve feed url issues: ** ensure validity on save ** support non-pretty url format * Enhancement: un-default some modules: episode assistant, twitter card summary * Enhancement: fix asset & feed setting redirect issue * Enhancement: add caption file types * Enhancement: new icons! * Enhancement: allow underscores and dots in slugs * Bugfix: fix issue with multiple backslash-escapings = 1.3.15-alpha = * Hotfix: fix 404 issue concerning episode prefixes and posts = 1.3.14-alpha = * Feature: ajaxy asset revalidation in dashboard * Feature: duration support for web player * Feature: add option to provide web players with opus format * Enhancement: slightly improved web player settings pane * Enhancement: deprecate [podlove-template title=""] in favor of [podlove-template id=""] for clarity * Enhancement: move category support for episodes into a module * Enhancement: force feed & episode slugs into url conformity * update plugin description and add a FAQ section = 1.3.13-alpha = * Bugfix: Podcast model works with `switch_to_blog` now = 1.3.12-alpha = * Enhancement: don't embed cover image fallback in feed as episode image when there is no episode image * Feature: add action link for assets to enable it for all existing episodes. useful when adding a new asset for an existing podcast = 1.3.11-alpha = * Enhancement: Image input fields try to show pasted image immediately * Enhancement: remove unused "post episode to show" setting * Bugfix: fix asset preview glitch when changing the episode slug * Bugfix: fix GUID upgrade migration = 1.3.10-alpha = * Hotfix: too much escaping when `get_magic_quotes` is on = 1.3.9-alpha = * Enhancement: rectify feed generator title * Bugfix: add missing sql escaping = 1.3.8-alpha = * Bugfix: fix episode image fallback to podcast image = 1.3.7-alpha = * Enhancement: In feed settings, URL preview updates live now * Enhancement: "Add New" button in blank list table views * Enhancement: display `` tag in RSS channel and correct xml:lang in ATOM * Enhancement: forbid asset deletion when used in feed or web player * Bugfix: Templates list view highlights template preview correctly now for more than one entry * Bugfix: remove duplicate rel="self" entry from RSS feeds * Bugfix: correct escaping for all input fields * Bugfix: fix 404s when using an empty episode url prefix = 1.3.6-alpha = * Bugfix: Minor WordPress 3.5 compatibility issue * Bugfix: Use correct shortcodes in default template * Enhancement: Add support for `[podlove-episode field="title"]` * 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. = 1.3.5-alpha = * Bugfix: pages and menu items don't appear unexpectedly in main loop any more * Bugfix: when using the WordPress importer, don't create new GUIDs * Enhancement: rename GUID meta so it doesn't appear as custom field = 1.3.4-alpha = * Hotfix: fix asset creation issue = 1.3.3-alpha = * Enhancement: Use episode image fallback to podcast image in webplayer. = 1.3.2-alpha = * 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 `). = 1.3.1-alpha = * update web player to 1.2.1 = 1.3.0-alpha = * Feature: [Podlove Deep Linking](http://podlove.org/deep-link/) support * Feature: support for new web player * Bugfix: enable tag and category search results for all post types * Bugfix: Feed item limit setting works now * Bugfix: avoid rare curl warning * Bugfix: improve feed validity * Enhancement: remove unused feed setting `show description` * Enhancement: Podlove feeds don't override /feed/* WordPress feeds any more * Enhancement: Rename plugin to "Podlove Podcast Publisher" * Enhancement: Move asset assignments from podcast settings to asset settings = 1.2.24-alpha = * Bugfix: don't show milliseconds in feed so feedvalidator.org stops complaining = 1.2.22/23-alpha = * Fix deployment bug, delete unused files from SVN = 1.2.21-alpha = * Bugfix: check for asset relations (not just media file relations) when trying to delete assets * Bugfix: asset form can handle file types using brackets now * 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. * 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"]` * Enhancement: curl requests set user agent = 1.2.20-alpha = * Bugfix: forbid deletion of episode assets referenced by existing media files * Bugfix: fix episode asset type selector = 1.2.19-alpha = * Feature: add episode image shortcode `[podlove-episode field="image"]` * Bugfix: fix some bugs * Enhancement: when creating new form entries, the user is now redirected to the index page rather than the edit form = 1.2.18-alpha = * Feature: 4 new podcast fields: publisher_name, publisher_url, license_name, license_url * Feature: Shortcode `[podlove-podcast]` to access podcast data. See [Shortcode Documentation](https://github.com/eteubert/podlove/wiki/Shortcodes) for more details. * 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. * Feature: Add support for tags and categories in episodes. * Feature: Chapter File (txt and psc) as episode asset * Feature: Feed redirects can be a) turned off and b) permanent c) temporary * Feature: Module for Twitter Card support * Enhancement: Minor template editor enhancements and updated default template. * Enhancement: Enable revisions for episodes. * Enhancement: RSS/Atom cleanup. Less WordPress, more Podlove. * Enhancement: UI improvements in episode asset forms * Enhancement: Menu reorganisation. Moved important stuff up, expert stuff down. Separate site for modules. = 1.2.17-alpha = * Nothing. Just some WordPress-Plugin-Directory-Thingamajig-Version-Foobar. = 1.2.16-alpha = * Feature: Episode templates. Go to `Podlove > Templates` to find out more. See [Shortcode Documentation](https://github.com/eteubert/podlove/wiki/Shortcodes) for more details. * 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. * Enhancement: remove episode excerpt support in favor of episode summary * Bugfix: Short Episode Routing compatibility = 1.2.15-alpha = * Bugfix: remove all Show model references for now * Enhancement: proper summary/description feed elements = 1.2.14-alpha = * Enhancement: rename "media locations" to "episode assets" for clarity * Enhancement: rename "podlove formats" to "file types" for clarity * Enhancement: start to rework validation section * Enhancement: check for episode files when slug changes = 1.2.13-alpha = * Enhancement: use episode summary as excerpt if available * Bugfix: episode assistant file slugs respect mnemonic case * Bugfix: solve 404 issue with pages = 1.2.12-alpha = * Bugfix: Minor JavaScript glitch = 1.2.11-alpha = * New Module: Contributors Taxonomy — display with shortcode `[podlove-contributors]` (go to `Podlove > Settings` to activate the module) = 1.2.10-alpha = * Feature: Add Shortcodes to display episode data: `[podlove-episode-subtitle] [podlove-episode-summary] [podlove-episode-slug] [podlove-episode-duration] [podlove-episode-chapters]` * 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/)) * Feature: Show red warning in dashboard if one of the following podlove settings is missing: `title`, `mnemonic`, `base url` * Enhancement: Remove pagination from formats settings page == Upgrade Notice == = 2.0.0 = Upgrade only if you are on PHP 5.4 or higher. = 1.2.0-alpha = Before you update, delete all shows but one to ensure your important data stays. Watch out: Your feed URLs will change! ================================================ FILE: client/.tool-versions ================================================ nodejs 18.18.2 ================================================ FILE: client/config.local.template.js ================================================ // Template for local development configuration // Copy this file to config.local.js and customize window.devConfig = { user: 'YOUR_USERNAME', applicationPassword: 'YOUR_APPLICATION_PASSWORD', baseUrl: 'http://publisher.local', } ================================================ FILE: client/index.html ================================================ Podlove Publisher Client Development Environment
================================================ FILE: client/package.json ================================================ { "scripts": { "build": "vue-tsc --noEmit && NODE_ENV=production vite build --base=/wp-content/plugins/podlove-publisher/client/dist/", "serve": "vite build --watch --base=/wp-content/plugins/podlove-publisher/client/dist/", "dev": "vite" }, "devDependencies": { "@babel/types": "7.24.7", "@tailwindcss/forms": "^0.5.11", "@types/lodash": "^4.17.24", "@types/redux-actions": "^2.6.5", "@types/uuid": "^9.0.7", "@vitejs/plugin-vue": "^6.0.5", "autoprefixer": "^10.4.27", "cssnano": "^7.1.3", "postcss": "^8.4.33", "postcss-import": "^16.1.1", "postcss-prefix-selector": "^2.1.1", "prettier": "^3.8.1", "tailwindcss": "^3.4.19", "typescript": "^5.9.3", "uuid": "^9.0.0", "vite": "^8.0.3", "vue-tsc": "^3.2.6" }, "dependencies": { "@babel/types": "7.24.7", "@headlessui/vue": "^1.7.23", "@heroicons/vue": "^2.2.0", "@podlove/utils": "5.12.2", "@podlove/web-player": "^5.13.0", "@popperjs/core": "^2.11.8", "axios": "^1.13.6", "lodash": "^4.17.23", "redux": "^5.0.1", "redux-actions": "^3.0.3", "redux-saga": "^1.4.2", "redux-vuex": "^4.0.1", "reselect": "^5.1.1", "vue": "^3.5.31", "vue3-popper": "^1.5.0" } } ================================================ FILE: client/postcss.config.js ================================================ module.exports = { plugins: [ require('postcss-import'), require('tailwindcss/nesting'), require('tailwindcss'), require('autoprefixer'), ...(process.env.NODE_ENV === 'production' ? [ require('cssnano') ] : []), require('postcss-prefix-selector')({ prefix: '*[data-client="podlove"]' }), ] } ================================================ FILE: client/src/assets/index.d.ts ================================================ declare module '*.png' { const value: any; export = value; } ================================================ FILE: client/src/client.ts ================================================ import { createApp } from 'vue' import { provideStore } from 'redux-vuex' import { store } from '@store' import modules from './modules' import { init } from './store/lifecycle.store' import translationPlugin from './plugins/translations' import './style.css' window.addEventListener('load', () => { document.querySelectorAll('[data-client="podlove"]:not([data-loaded="true"])').forEach((elem) => { elem.setAttribute('data-loaded', 'true') const app = createApp({ components: { ...modules, } }) provideStore({ store, app }) app.use(translationPlugin) app.mount(elem) }); }); (globalThis as any).initPodloveUI = (data: any) => { store.dispatch(init(data)) } ================================================ FILE: client/src/components/button/Button.vue ================================================ ================================================ FILE: client/src/components/combobox/Combobox.vue ================================================ ================================================ FILE: client/src/components/icons/Avatar.vue ================================================ ================================================ FILE: client/src/components/modal/Modal.vue ================================================ ================================================ FILE: client/src/components/module/Module.vue ================================================ ================================================ FILE: client/src/components/popover/Popover.vue ================================================ ================================================ FILE: client/src/components/steps/Steps.vue ================================================ ================================================ FILE: client/src/components/tabs/Tab.vue ================================================ ================================================ FILE: client/src/components/tabs/TabsContainer.vue ================================================ ================================================ FILE: client/src/components/tabs/index.ts ================================================ // @ts-ignore import Tab from './Tab.vue' // @ts-ignore import TabsContainer from './TabsContainer.vue' export { Tab, TabsContainer } ================================================ FILE: client/src/components/tag/Tag.vue ================================================ ================================================ FILE: client/src/components/tooltip/Tooltip.vue ================================================ ================================================ FILE: client/src/lib/api.ts ================================================ import { curry } from 'lodash' export const addQuery = (url: string, query: { [key: string]: any } = {}) => { const params = Object.keys(query) .map((k) => encodeURIComponent(k) + '=' + encodeURIComponent(query[k])) .join('&') return (url += (url.indexOf('?') === -1 ? '?' : '&') + params) } export const responseParser = (errorHandler: Function = console.error) => async (response: Response) => { let result try { result = await response.json() } catch (err) { result = {} } if (response.status >= 300) { errorHandler(result) return { error: result, } } return { result, } } export interface ApiOptions { headers?: { [key: string]: string } query?: { [key: string]: string } limit?: number minimal_data?: boolean hooks?: { onUploadProgress?: any } } const defaultHeaders = ( { nonce, auth, bearer }: { nonce?: string; auth?: string; bearer?: string }, headers: { [key: string]: string } = {} ) => ({ 'Content-Type': 'application/json', Accept: 'application/json', ...(bearer ? { Authorization: `Bearer ${bearer}` } : {}), ...(auth ? { Authorization: `Basic ${auth}` } : {}), ...(nonce ? { 'X-WP-Nonce': nonce } : {}), ...headers, }) const readApi = ({ errorHandler, nonce, auth, bearer, method, urlProcessor, }: { errorHandler?: Function nonce?: string auth?: string bearer?: string method: 'GET' | 'DELETE' urlProcessor?: (url: string) => string }) => (url: string, { headers, query }: ApiOptions = {}) => fetch(addQuery(urlProcessor ? urlProcessor(url) : url, query), { method, headers: defaultHeaders({ nonce, auth, bearer }, headers), }).then(responseParser(errorHandler)) const createApi = ({ errorHandler, nonce, auth, bearer, method, urlProcessor, }: { errorHandler?: Function nonce?: string auth?: string bearer?: string method: 'POST' | 'PUT' urlProcessor?: (url: string) => string }) => (url: string, data: any, { headers, query }: ApiOptions = {}) => { return fetch(addQuery(urlProcessor ? urlProcessor(url) : url, query), { method, headers: defaultHeaders({ nonce, auth, bearer }, headers), body: JSON.stringify(data), }).then(responseParser(errorHandler)) } export interface PodloveApiClient { get: (url: string, options?: ApiOptions) => Promise<{ result: any; error: any }> delete: (url: string, options?: ApiOptions) => Promise<{ result: any; error: any }> post: (url: string, data: any, options?: ApiOptions) => Promise<{ result: any; error: any }> put: (url: string, data: any, options?: ApiOptions) => Promise<{ result: any; error: any }> } export const podlove = curry( ({ base, version, nonce, auth, bearer, errorHandler, }: { errorHandler: Function base: string version: string nonce?: string auth?: string bearer?: string }) => ({ get: readApi({ nonce, auth, bearer, method: 'GET', errorHandler, urlProcessor: (endpoint) => `${base}/${version}/${endpoint}`, }), delete: readApi({ nonce, auth, bearer, errorHandler, method: 'DELETE', urlProcessor: (endpoint) => `${base}/${version}/${endpoint}`, }), post: createApi({ nonce, auth, bearer, errorHandler, method: 'POST', urlProcessor: (endpoint) => `${base}/${version}/${endpoint}`, }), put: createApi({ nonce, auth, bearer, errorHandler, method: 'PUT', urlProcessor: (endpoint) => `${base}/${version}/${endpoint}`, }), }) ) ================================================ FILE: client/src/lib/array.ts ================================================ export const arrayMove = (arr: T[], fromIndex: number, toIndex: number) => { const newArr = [...arr]; newArr.splice(toIndex, 0, newArr.splice(fromIndex, 1)[0]); return newArr; }; ================================================ FILE: client/src/lib/auphonic.api.ts ================================================ import { curry } from 'lodash' import axios, { AxiosProgressEvent } from 'axios' import { addQuery, responseParser, ApiOptions } from './api' // TODO: replace fetch with axios const defaultHeaders = ( { bearer }: { bearer?: string }, headers: { [key: string]: string } = {} ) => ({ 'Content-Type': 'application/json', Accept: 'application/json', ...authHeaders({ bearer }), ...headers, }) const authHeaders = ({ bearer }: { bearer?: string }) => ({ ...(bearer ? { Authorization: `Bearer ${bearer}` } : {}), }) const readApi = ({ errorHandler, bearer, method, urlProcessor, }: { errorHandler?: Function bearer?: string method: 'GET' | 'DELETE' urlProcessor?: (url: string) => string }) => (url: string, { headers, query }: ApiOptions = {}) => fetch(addQuery(urlProcessor ? urlProcessor(url) : url, query), { method, headers: defaultHeaders({ bearer }, headers), }).then(responseParser(errorHandler)) const createApi = ({ errorHandler, bearer, method, urlProcessor, }: { errorHandler?: Function bearer?: string method: 'POST' | 'PUT' urlProcessor?: (url: string) => string }) => (url: string, data: any, { headers, query }: ApiOptions = {}) => { return fetch(addQuery(urlProcessor ? urlProcessor(url) : url, query), { method, headers: defaultHeaders({ bearer }, headers), body: JSON.stringify(data), }).then(responseParser(errorHandler)) } const deleteApi = ({ errorHandler, bearer, method, urlProcessor, }: { errorHandler?: Function bearer?: string method: 'DELETE' urlProcessor?: (url: string) => string }) => (url: string, data: any, { headers, query }: ApiOptions = {}) => { return fetch(addQuery(urlProcessor ? urlProcessor(url) : url, query), { method, headers: defaultHeaders({ bearer }, headers), body: JSON.stringify(data), }).then(responseParser(errorHandler)) } function defaultProgressHandler(e: AxiosProgressEvent) { console.log('default progress', e) } const uploadApi = ({ errorHandler, bearer, urlProcessor, }: { errorHandler?: Function bearer?: string urlProcessor?: (url: string) => string }) => (url: string, data: any, { query, hooks }: ApiOptions = {}) => { const formData = new FormData() const onUploadProgress = hooks?.onUploadProgress || defaultProgressHandler // audio file upload if (data.file) { // track id for multitrack, 'input_file' for single track const id = data.track_id || 'input_file' formData.append(id, data.file) } // cover poster upload if (data.image) { formData.append('image', data.image) } return axios.post(addQuery(urlProcessor ? urlProcessor(url) : url, query), formData, { headers: { ...authHeaders({ bearer }), }, onUploadProgress: onUploadProgress, }) } export interface AuphonicApiClient { get: (url: string, options?: ApiOptions) => Promise<{ result: any; error: any }> post: (url: string, data: any, options?: ApiOptions) => Promise<{ result: any; error: any }> delete: (url: string, options?: ApiOptions) => Promise<{ result: any; error: any }> upload: (url: string, data: any, options?: ApiOptions) => Promise<{ result: any; error: any }> } export const auphonic = curry( ({ base, bearer, errorHandler }: { base: string; bearer?: string; errorHandler: Function }) => ({ get: readApi({ bearer, method: 'GET', errorHandler, urlProcessor: (endpoint) => `${base}/${endpoint}`, }), post: createApi({ bearer, errorHandler, method: 'POST', urlProcessor: (endpoint) => `${base}/${endpoint}`, }), delete: deleteApi({ bearer, errorHandler, method: 'DELETE', urlProcessor: (endpoint) => `${base}/${endpoint}`, }), upload: uploadApi({ bearer, errorHandler, urlProcessor: (endpoint) => `${base}/${endpoint}`, }), }) ) ================================================ FILE: client/src/lib/chapters.ts ================================================ import * as npt from './normalplaytime' import { PodloveChapter } from '../types/chapters.types' export function parseMp4Chapters(input: string): PodloveChapter[] { const pattern = /^([\d\.:]+)\s(.*)$/ return input .trim() .split(/(\r?\n)/) .reduce(function (all: PodloveChapter[], chapter: string) { var matches = chapter.match(pattern) if (matches) { var time = npt.parse(matches[1]) if (time !== null) { all.push({ title: matches[2].trim(), start: time, }) } } return all }, []) } export function parseAudacityChapters(input: string): PodloveChapter[] { const pattern = /^([\d\.,]+)\s+([\d\.,]+)\s+(.*)$/ return input .trim() .split(/(\r\n|\r|\n)/) .reduce(function (all: PodloveChapter[], chapter: string) { var matches = chapter.match(pattern) if (matches) { var time = npt.parse(matches[1].replace(',', '.')) var title = matches[3].trim() if (time !== null) { all.push({ title: title, start: time, }) } } return all }, []) } export function parseHindeburgChapters(input: string) { const parser = new window.DOMParser() const xml = parser.parseFromString(input, 'text/xml') const chapterTags = xml.getElementsByTagName('Marker') let chapters = Array.from(chapterTags).reduce((result: PodloveChapter[], tag) => { if ( !tag || !tag.getAttribute('Type') || tag.getAttribute('Type')?.toLowerCase() !== 'chapter' ) { return result } const start = npt.parse(tag.getAttribute('Time') || '') const title = tag.getAttribute('Name') || '' const href = tag.getAttribute('URL') || '' if (start !== null) { result.push({ start: start, title: title.trim(), ...(href ? { href: href.trim() } : {}) }) } return result }, []) chapters.sort(function (chapterA, chapterB) { return chapterA.start - chapterB.start }) return chapters } export function parsePodloveChapters(input: string): PodloveChapter[] { const parser = new window.DOMParser() const xml = parser.parseFromString(input, 'text/xml') const chapterTags = xml.getElementsByTagNameNS('http://podlove.org/simple-chapters', 'chapter') return Array.from(chapterTags).reduce((result: PodloveChapter[], tag) => { var start = npt.parse(tag.getAttribute('start') || '') var title = tag.getAttribute('title') || '' var href = tag.getAttribute('href') || '' var image = tag.getAttribute('image') || '' if (start !== null) { result.push({ start: start, title: title.trim(), ...(href ? { href: href.trim() } : {}), ...(image ? { image: image.trim() } : {}), }) } return result }, []) } ================================================ FILE: client/src/lib/errorHandling.ts ================================================ /** * Common error handling utilities for file processing operations */ export interface FileWithUrl { filename?: string name?: string download_url?: string localUrl?: string } export interface ErrorResponse { success: false status: 'failed' filename: string download_url: string message: string } /** * Creates a standardized error response for failed file operations */ export const createErrorResponse = (file: FileWithUrl, error: any): ErrorResponse => ({ success: false, status: 'failed', filename: file.filename || file.name || 'unknown', download_url: file.download_url || file.localUrl || 'unknown', message: error.message || 'Processing failed' }) /** * Extracts error message from API response with fallback */ export const getApiErrorMessage = (response: any, fallback: string = 'Request failed'): string => { return response.error?.message || response.message || response.result?.message || fallback } /** * Creates a transfer failed error response with descriptive message */ export const createTransferErrorResponse = (file: FileWithUrl, errorMessage: string): ErrorResponse => ({ success: false, status: 'failed', filename: file.filename || file.name || 'unknown', download_url: file.download_url || file.localUrl || 'unknown', message: `Transfer failed: ${errorMessage}` }) ================================================ FILE: client/src/lib/license.ts ================================================ import { PodloveLicense, PodloveLicenseOptionCommercial, PodloveLicenseOptionModification, PodloveLicenseVersion, PodloveLicenseOptionJurisdication, } from '../types/license.types' import pdImage from '../assets/pd.png' import pdMarkImage from '../assets/pdmark.png' import cc_0_0_Image from '../assets/0_0.png' import cc_0_1_Image from '../assets/0_1.png' import cc_1_0_Image from '../assets/1_0.png' import cc_1_1_Image from '../assets/1_1.png' import cc_10_0_Image from '../assets/10_0.png' import cc_10_1_Image from '../assets/10_1.png' export function getLicenseUrl(input: PodloveLicense): string | null { if (input.type === null || input.type != 'cc') return null if (input.version === PodloveLicenseVersion.cc0) return 'http://creativecommons.org/publicdomain/zero/1.0/' if (input.version === PodloveLicenseVersion.pdmark) return 'http://creativecommons.org/publicdomain/mark/1.0/' if (input.version === PodloveLicenseVersion.cc3) { let cc3 = 'http://creativecommons.org/licenses/by' if (input.optionCommercial === PodloveLicenseOptionCommercial.no) cc3 = cc3 + '-nc' if (input.optionModification === PodloveLicenseOptionModification.yes) cc3 = cc3 + '/' else if (input.optionModification === PodloveLicenseOptionModification.no) cc3 = cc3 + '-nd/' else cc3 = cc3 + '-sa/' if (input.optionJurisdication === null || input.optionJurisdication.symbol === 'international') cc3 = cc3 + '3.0/' else cc3 = cc3 + input.optionJurisdication.version + '/' + input.optionJurisdication.symbol + '/' return cc3 + 'deed.en' } if (input.version === PodloveLicenseVersion.cc4) { let cc4 = 'http://creativecommons.org/licenses/by' if (input.optionCommercial == PodloveLicenseOptionCommercial.no) cc4 = cc4 + '-nc' if (input.optionModification == PodloveLicenseOptionModification.yes) cc4 = cc4 + '/' else if (input.optionModification == PodloveLicenseOptionModification.no) cc4 = cc4 + '-nd/' else cc4 = cc4 + '-sa/' return cc4 + '4.0' } return null } export function getImageUrl(input: PodloveLicense, baseUrl: string): string | null { if (input.type === null || input.type !== 'cc') return null if (input.version === PodloveLicenseVersion.cc0) return pdImage; if (input.version === PodloveLicenseVersion.pdmark) return pdMarkImage; if (input.optionModification === null || input.optionCommercial === null) return null switch(input.optionModification) { case PodloveLicenseOptionModification.no: return input.optionCommercial === PodloveLicenseOptionCommercial.no ? cc_0_0_Image : cc_0_1_Image case PodloveLicenseOptionModification.yes: return input.optionCommercial === PodloveLicenseOptionCommercial.no ? cc_1_0_Image : cc_1_1_Image case PodloveLicenseOptionModification.yesbutshare: return input.optionCommercial === PodloveLicenseOptionCommercial.no ? cc_10_0_Image : cc_10_1_Image } } export function getLicenseFromUrl(url: string): PodloveLicense { const urlLowerCase = url.toLowerCase() // only parse cc licenses if (urlLowerCase.indexOf('creativecommons.org') < 0) { return { type: null, version: null, optionCommercial: null, optionModification: null, optionJurisdication: null, } as PodloveLicense } let version: PodloveLicenseVersion | null = null if (urlLowerCase.indexOf('/publicdomain/zero/') >= 0) { version = PodloveLicenseVersion.cc0 } else { if (urlLowerCase.indexOf('/publicdomain/mark/') >= 0) { version = PodloveLicenseVersion.pdmark } else { if (urlLowerCase.indexOf('/4.0') >= 0) { version = PodloveLicenseVersion.cc4 } else { version = PodloveLicenseVersion.cc3 } } } const urlData = urlLowerCase.split('/').slice(4) let commercial: PodloveLicenseOptionCommercial = PodloveLicenseOptionCommercial.yes if (urlData[0].includes('nc')) { commercial = PodloveLicenseOptionCommercial.no } let modification: PodloveLicenseOptionModification = PodloveLicenseOptionModification.yes if (urlData[0].includes('sa')) { modification = PodloveLicenseOptionModification.yesbutshare } else { if (urlData[0].includes('nd')) { modification = PodloveLicenseOptionModification.no } } let jurisdication = PodloveLicenseOptionJurisdication[0] if (urlData.length > 2) { const idx: number = PodloveLicenseOptionJurisdication.findIndex( (item) => item.symbol === urlData[2] ) if (idx > 0) jurisdication = PodloveLicenseOptionJurisdication[idx] } return { type: 'cc', version: version, optionCommercial: commercial, optionModification: modification, optionJurisdication: jurisdication, } as PodloveLicense } ================================================ FILE: client/src/lib/normalplaytime.ts ================================================ const parse_ms_string = function (msstring: string) { if (!msstring) { return 0 } switch (msstring.length) { case 0: return 0 break case 1: return parseInt(msstring, 10) * 100 break case 2: return parseInt(msstring, 10) * 10 break default: return parseInt(msstring.substr(0, 3), 10) break } } export const parse = function (timestring: string): number | null { timestring = (timestring || '').trim() const pattern_seconds = /^(\d+)(?:\.(\d+))?$/ const pattern_minutes = /^(\d+):(\d\d?)(?:\.(\d+))?$/ const pattern_hours = /^(\d+):(\d\d?):(\d\d?)(?:\.(\d+))?$/ let matches let ms = 0 let sec = 0 let min = 0 let hr = 0 if ((matches = timestring.match(pattern_seconds))) { ms = parse_ms_string(matches[2]) sec = matches[1] ? parseInt(matches[1], 10) : 0 } else if ((matches = timestring.match(pattern_minutes))) { ms = parse_ms_string(matches[3]) sec = matches[2] ? parseInt(matches[2], 10) : 0 min = matches[1] ? parseInt(matches[1], 10) : 0 } else if ((matches = timestring.match(pattern_hours))) { ms = parse_ms_string(matches[4]) sec = matches[3] ? parseInt(matches[3], 10) : 0 min = matches[2] ? parseInt(matches[2], 10) : 0 hr = matches[1] ? parseInt(matches[1], 10) : 0 } else { return null } return ((hr * 60 + min) * 60 + sec) * 1000 + ms } ================================================ FILE: client/src/lib/popper.ts ================================================ import { ref, onMounted, watchEffect } from "vue"; import { createPopper, Options } from "@popperjs/core"; export function usePopper(options: Partial) { let reference = ref<{ el: HTMLElement } | null>(null); let popper = ref<{ el: HTMLElement } | null>(null); onMounted(() => { watchEffect((onInvalidate) => { if (!popper.value) return; if (!reference.value) return; let popperEl = popper.value.el || popper.value; let referenceEl = reference.value.el || reference.value; if (!(referenceEl instanceof HTMLElement)) return; if (!(popperEl instanceof HTMLElement)) return; let { destroy } = createPopper(referenceEl, popperEl, options); onInvalidate(destroy); }); }); return [reference, popper]; } ================================================ FILE: client/src/lib/statusHelpers.ts ================================================ /** * Common status determination utilities for file processing operations */ export type ProcessingStatus = 'completed' | 'completed_with_errors' | 'failed' export type MigrationStatus = 'finished' | 'error' /** * Determines the final transfer status based on success/failure counts */ export const determineTransferStatus = ( hasErrors: boolean, successCount: number ): ProcessingStatus => { if (!hasErrors) return 'completed' if (successCount > 0) return 'completed_with_errors' return 'failed' } /** * Determines the final migration status based on error state */ export const determineMigrationStatus = (hasErrors: boolean): MigrationStatus => { return hasErrors ? 'error' : 'finished' } /** * Checks if a processing result indicates success */ export const isSuccessResult = (result: any): boolean => { if (typeof result === 'boolean') return result if (result && typeof result === 'object') { return result.success === true // only true is success, null (pending) and false are not } return false } /** * Counts successful results from an array of processing results */ export const countSuccessfulResults = (results: any[]): number => { return results.filter(isSuccessResult).length } ================================================ FILE: client/src/lib/timestamp.ts ================================================ import * as npt from './normalplaytime' export default class Timestamp { constructor(public totalMs: number) {} get totalSeconds() { return Math.floor(this.totalMs / 1000) } get totalMinutes() { return Math.floor(this.totalSeconds / 60) } get totalHours() { return Math.floor(this.totalMinutes / 60) } get milliseconds() { return this.totalMs % 1000 } get seconds() { return this.totalSeconds % 60 } get minutes() { return this.totalMinutes % 60 } get hours() { return this.totalHours % 60 } get pretty() { return ( this.pad(this.totalHours) + ':' + this.pad(this.minutes) + ':' + this.pad(this.seconds) + '.' + this.pad(this.milliseconds, '000') ) } get prettyShort() { if (this.totalHours) { return this.pad(this.totalHours) + ':' + this.pad(this.minutes) + ':' + this.pad(this.seconds) } else { return this.pad(this.minutes) + ':' + this.pad(this.seconds) } } pad(num: number, pad = '00') { let str = '' + num if (str.length < pad.length) { return pad.substring(0, pad.length - str.length) + str } else { return num } } static fromString(t: string | number) { let ms = 0 if (t == parseInt(t as string, 10)) { ms = parseInt(t as string, 10) } else { ms = npt.parse(t as string) || 0 } return new Timestamp(ms) } } ================================================ FILE: client/src/lib/wordpress.ts ================================================ import { get } from 'lodash' export const store = get(window, ['wp', 'data'], null) export const media = get(window, ['wp', 'media'], null) export const postTitleInput: HTMLInputElement | null = document.querySelector('input[name="post_title"]') export const postTitleListener = (cb: (title: string) => any) => postTitleInput?.addEventListener('change', event => cb(get(event, ['target', 'value']))) ================================================ FILE: client/src/modules/auphonic/Auphonic.vue ================================================ ================================================ FILE: client/src/modules/auphonic/components/FileChooser.vue ================================================ ================================================ FILE: client/src/modules/auphonic/components/Logo.vue ================================================ ================================================ FILE: client/src/modules/auphonic/components/ManageProductionForm.vue ================================================ ================================================ FILE: client/src/modules/auphonic/components/SelectPreset.vue ================================================ ================================================ FILE: client/src/modules/auphonic/components/SelectProduction.vue ================================================ ================================================ FILE: client/src/modules/auphonic/components/StartScreen.vue ================================================ ================================================ FILE: client/src/modules/auphonic/components/WebhookToggle.vue ================================================ ================================================ FILE: client/src/modules/auphonic/components/production_form/DonePage.vue ================================================ ================================================ FILE: client/src/modules/auphonic/components/production_form/PlusTransferStatus.vue ================================================ ================================================ FILE: client/src/modules/auphonic/components/production_form/TransferFileItem.vue ================================================ ================================================ FILE: client/src/modules/auphonic/components/production_form/TransferFileList.vue ================================================ ================================================ FILE: client/src/modules/auphonic/components/production_form/TransferHeader.vue ================================================ ================================================ FILE: client/src/modules/auphonic/components/production_form/TransferStatusPanel.vue ================================================ ================================================ FILE: client/src/modules/auphonic/index.ts ================================================ import Auphonic from './Auphonic.vue' export default Auphonic ================================================ FILE: client/src/modules/chapters/Chapters.vue ================================================ ================================================ FILE: client/src/modules/chapters/components/Export.vue ================================================ ================================================ FILE: client/src/modules/chapters/components/Form.vue ================================================ ================================================ FILE: client/src/modules/chapters/components/Import.vue ================================================ ================================================ FILE: client/src/modules/chapters/index.ts ================================================ import Chapters from './Chapters.vue'; export default Chapters; ================================================ FILE: client/src/modules/contributors/Contributors.vue ================================================ ================================================ FILE: client/src/modules/contributors/components/AddContribution.vue ================================================ ================================================ FILE: client/src/modules/contributors/components/Contribution.vue ================================================ ================================================ FILE: client/src/modules/contributors/index.ts ================================================ import Contributors from './Contributors.vue'; export default Contributors; ================================================ FILE: client/src/modules/description/Description.vue ================================================ ================================================ FILE: client/src/modules/description/components/EpisodeContent.vue ================================================ ================================================ FILE: client/src/modules/description/components/EpisodeNumber.vue ================================================ ================================================ FILE: client/src/modules/description/components/EpisodePoster.vue ================================================ ================================================ FILE: client/src/modules/description/components/EpisodeSubtitle.vue ================================================ ================================================ FILE: client/src/modules/description/components/EpisodeSummary.vue ================================================ ================================================ FILE: client/src/modules/description/components/EpisodeTitle.vue ================================================ ================================================ FILE: client/src/modules/description/components/EpisodeType.vue ================================================ ================================================ FILE: client/src/modules/description/index.ts ================================================ import Description from './Description.vue'; export default Description; ================================================ FILE: client/src/modules/index.ts ================================================ import PodloveDescription from './description' import PodloveChapters from './chapters' import PodloveTranscripts from './transcripts' import PodloveAuphonic from './auphonic' import PodloveContributors from './contributors' import PodloveMediaFiles from './mediafiles' import PodlovePlusFileMigration from './plus_file_migration' import PodlovePlusFeatures from './plus_features' import PodlovePlusToken from './plus_token' import PodloveRelatedEpisodes from './related' import PodloveSoundbite from './soundbite' import PodloveShowSelect from './shows' import PodloveLicense from './license' export default { PodloveDescription, PodloveChapters, PodloveTranscripts, PodloveAuphonic, PodloveContributors, PodloveMediaFiles, PodloveRelatedEpisodes, PodlovePlusFileMigration, PodlovePlusFeatures, PodlovePlusToken, PodloveSoundbite, PodloveShowSelect, PodloveLicense, } ================================================ FILE: client/src/modules/license/License.vue ================================================ ================================================ FILE: client/src/modules/license/components/LicenseName.vue ================================================ ================================================ FILE: client/src/modules/license/components/LicenseSelector.vue ================================================ ================================================ FILE: client/src/modules/license/components/LicenseSelectorButton.vue ================================================ ================================================ FILE: client/src/modules/license/components/LicenseUrl.vue ================================================ ================================================ FILE: client/src/modules/license/components/LicenseView.vue ================================================ ================================================ FILE: client/src/modules/license/index.ts ================================================ import License from './License.vue'; export default License; ================================================ FILE: client/src/modules/mediafiles/MediaFiles.vue ================================================ ================================================ FILE: client/src/modules/mediafiles/components/AssetsEmptyState.vue ================================================ ================================================ FILE: client/src/modules/mediafiles/components/AssetsTable.vue ================================================ ================================================ FILE: client/src/modules/mediafiles/components/MediaSlug.vue ================================================ ================================================ FILE: client/src/modules/mediafiles/components/MediaUpload.vue ================================================ ================================================ FILE: client/src/modules/mediafiles/components/PlusMediaUpload.vue ================================================ ================================================ FILE: client/src/modules/mediafiles/index.ts ================================================ import MediaFiles from './MediaFiles.vue' export default MediaFiles ================================================ FILE: client/src/modules/plus_features/Feature.vue ================================================ ================================================ FILE: client/src/modules/plus_features/PlusFeatures.vue ================================================ ================================================ FILE: client/src/modules/plus_features/index.ts ================================================ import PlusFeatures from './PlusFeatures.vue' export default PlusFeatures ================================================ FILE: client/src/modules/plus_file_migration/PlusFileMigration.vue ================================================ ================================================ FILE: client/src/modules/plus_file_migration/index.ts ================================================ import PlusFileMigration from './PlusFileMigration.vue' export default PlusFileMigration ================================================ FILE: client/src/modules/plus_token/PlusToken.vue ================================================ ================================================ FILE: client/src/modules/plus_token/TokenInput.vue ================================================ ================================================ FILE: client/src/modules/plus_token/index.ts ================================================ import PlusToken from './PlusToken.vue' export default PlusToken ================================================ FILE: client/src/modules/related/RelatedEpisodes.vue ================================================ ================================================ FILE: client/src/modules/related/index.ts ================================================ import RelatedEpisodes from './RelatedEpisodes.vue'; export default RelatedEpisodes; ================================================ FILE: client/src/modules/shows/ShowSelect.vue ================================================ ================================================ FILE: client/src/modules/shows/index.ts ================================================ import ShowSelect from './ShowSelect.vue' export default ShowSelect ================================================ FILE: client/src/modules/soundbite/Soundbite.vue ================================================ ================================================ FILE: client/src/modules/soundbite/components/Clear.vue ================================================ ================================================ FILE: client/src/modules/soundbite/components/Form.vue ================================================ ================================================ FILE: client/src/modules/soundbite/index.ts ================================================ import Soundbite from './Soundbite.vue'; export default Soundbite; ================================================ FILE: client/src/modules/transcripts/Transcripts.vue ================================================ ================================================ FILE: client/src/modules/transcripts/components/Delete.vue ================================================ ================================================ FILE: client/src/modules/transcripts/components/Export.vue ================================================ ================================================ FILE: client/src/modules/transcripts/components/Import.vue ================================================ ================================================ FILE: client/src/modules/transcripts/components/List.vue ================================================ ================================================ FILE: client/src/modules/transcripts/components/Voices.vue ================================================ ================================================ FILE: client/src/modules/transcripts/index.ts ================================================ import Transcripts from './Transcripts.vue'; export default Transcripts; ================================================ FILE: client/src/plugins/translations.ts ================================================ import { get } from 'lodash' import { App } from 'vue' declare module '@vue/runtime-core' { interface ComponentCustomProperties { __: (translation: string, domain: string) => string; } } const translate = get(window, ['wp', 'i18n', '__'], (translation: string) => translation) export const __ = translate; export default { install(app: App) { app.config.globalProperties['__'] = (translation: string, domain: string) => translate(translation, domain) }, } ================================================ FILE: client/src/sagas/admin.sagas.ts ================================================ import { fork, select, put, takeEvery } from 'redux-saga/effects' import { PodloveApiClient } from '@lib/api' import { createApi } from './api' import * as adminStore from '@store/admin.store' import { takeFirst } from './helper' import { selectors } from '@store' interface AdminData { bannerHide: boolean | null type: string | null feedUrl: string | null } function* adminSaga() { const apiClient: PodloveApiClient = yield createApi() yield fork(initialize, apiClient) yield takeEvery(adminStore.UPDATE_TYPE, save, apiClient) } function* initialize(api: PodloveApiClient) { const { result }: { result: AdminData } = yield api.get('admin/onboarding') if (result) { yield put(adminStore.set(result)) } } function* save(api: PodloveApiClient, action: {type: string}) { const typeValue: string = yield select(selectors.admin.type) yield api.put('admin/onboarding', {type: typeValue}) } export default function() { return function* () { yield takeFirst(adminStore.INIT, adminSaga) } } ================================================ FILE: client/src/sagas/api.ts ================================================ import { call, select } from 'redux-saga/effects' import { podlove } from '../lib/api' import { selectors, store } from '@store' import { notify } from '@store/notification.store' import { waitFor } from './helper' export function* createApi() { yield call(waitFor, selectors.lifecycle.bootstrapped) const base: string = yield select(selectors.runtime.base) const nonce: string = yield select(selectors.runtime.nonce) const auth: string = yield select(selectors.runtime.auth) const bearer: string = yield select(selectors.runtime.bearer) const errorHandler = function (errorData: any) { let message = 'An error occurred' if (typeof errorData === 'string') { message = errorData } else if (errorData && typeof errorData === 'object') { if (errorData.code && errorData.message) { message = `${errorData.code}: ${errorData.message}` } else { message = errorData.message || errorData.code || 'An error occurred' } } store.dispatch(notify({ type: 'error', message })) } return podlove({ base, version: 'v2', nonce, auth, bearer, errorHandler }) } ================================================ FILE: client/src/sagas/auphonic.api.ts ================================================ import { selectors, store } from '@store' import { notify } from '@store/notification.store' import { select } from 'redux-saga/effects' import { auphonic } from '../lib/auphonic.api' export function* createApi() { const base: string = 'https://auphonic.com/api' const bearer: string = yield select(selectors.auphonic.token) const errorHandler = function (response: any) { store.dispatch(notify({ type: 'error', message: `Auphonic: ${response.error_message}` })) } return auphonic({ base, bearer, errorHandler }) } ================================================ FILE: client/src/sagas/auphonic.sagas.ts ================================================ import * as auphonic from '@store/auphonic.store' import * as episode from '@store/episode.store' import * as progress from '@store/progress.store' import * as plus from '@store/plus.store' import { takeFirst, createAndWatchProgressChannel, ProgressPayload, createProgressHandler, } from '../sagas/helper' import { delay, put, take, fork, takeEvery, select, all, call, race } from 'redux-saga/effects' import { createApi } from '../sagas/api' import { createApi as createAuphonicApi } from '../sagas/auphonic.api' import { PodloveApiClient } from '@lib/api' import { AuphonicApiClient } from '@lib/auphonic.api' import { selectors } from '@store' import { v4 as uuidv4 } from 'uuid' import { State } from '../store' import { get } from 'lodash' import Timestamp from '@lib/timestamp' import { createErrorResponse, createTransferErrorResponse, getApiErrorMessage } from '@lib/errorHandling' import { determineTransferStatus, countSuccessfulResults } from '@lib/statusHelpers' import { Channel } from 'redux-saga' import { verifyAll } from './mediafiles.verification.sagas' function* auphonicSaga(): any { const apiClient: PodloveApiClient = yield createApi() yield fork(initialize, apiClient) yield takeEvery(auphonic.UPDATE_FILE_SELECTION, handleFileSelection) yield takeEvery(auphonic.SET_SERVICE_FILES, handleServiceFilesAvailable) yield put(plus.init()) } function* initialize(api: PodloveApiClient) { const { result }: { result: string } = yield api.get(`auphonic/token`) if (result) { yield put(auphonic.setToken(result)) yield fork(initializeAuphonicApi) yield takeEvery(auphonic.SET_PRODUCTION, initializeWebhookConfig, api) yield takeEvery(auphonic.UPDATE_WEBHOOK, updateWebhookConfig, api) yield takeEvery(auphonic.SET_PRODUCTION, memorizeSelectedProduction, api) yield takeEvery(auphonic.DESELECT_PRODUCTION, forgetSelectedProduction, api) yield takeEvery(auphonic.SET_PRESET, memorizeSelectedPreset) yield takeEvery(auphonic.START_PRODUCTION, markProductionAsRunning, api) yield takeEvery(auphonic.STOP_POLLING, markProductionAsNotRunning, api) yield takeEvery(auphonic.DESELECT_PRODUCTION, markProductionAsNotRunning, api) yield takeEvery(auphonic.TRIGGER_PLUS_TRANSFER, handleTriggerPlusTransfer, api) yield takeEvery(auphonic.LOAD_PLUS_TRANSFER_STATUS, handleLoadPlusTransferStatus, api) yield takeEvery(auphonic.SET_PLUS_TRANSFER_STATUS, handlePlusTransferStatusChange, api) } } function* initializeAuphonicApi() { const auphonicApi: AuphonicApiClient = yield createAuphonicApi() const { result: { data: presets }, } = yield auphonicApi.get(`presets.json`) const { result: { data: productions }, } = yield auphonicApi.get(`productions.json`, { limit: 50, minimal_data: true }) let { result: { data: services }, } = yield auphonicApi.get(`services.json`) yield put(auphonic.setProductions(productions)) yield put(auphonic.setPresets(presets)) yield put( auphonic.setServices([ { uuid: 'url', display_name: 'From URL', email: '', incoming: true, outgoing: false, type: 'url', }, { uuid: 'file', display_name: 'Upload from computer', email: '', incoming: true, outgoing: false, type: 'file', }, ...services, ]) ) yield call(maybeRestoreProductionSelection) yield call(maybeRestorePresetSelection) yield put(auphonic.initDone()) yield takeEvery(auphonic.CREATE_PRODUCTION, handleCreateProduction, auphonicApi) yield takeEvery( auphonic.CREATE_MULTITRACK_PRODUCTION, handleCreateMultitrackProduction, auphonicApi ) yield takeEvery(auphonic.selectService, fetchServiceFiles, auphonicApi) yield takeEvery(auphonic.saveProduction, handleSaveProduction, auphonicApi) yield takeEvery(auphonic.startProduction, handleStartProduction, auphonicApi) yield takeEvery(auphonic.deselectProduction, handleDeselectProduction, auphonicApi) yield takeEvery(auphonic.removeTrack, handleRemoveTrack, auphonicApi) // poll production updates while production is running // TODO: start polling when loading a production that is in production yield call(pollWatcherSaga, auphonicApi) } function* pollWatcherSaga(auphonicApi: AuphonicApiClient) { let isAuphonicProductionRunning: boolean = yield select( selectors.episode.isAuphonicProductionRunning ) // Start polling on page load if the production is already running if (isAuphonicProductionRunning) { yield race([call(pollProductionSaga, auphonicApi), take(auphonic.STOP_POLLING)]) } while (true) { yield take(auphonic.START_POLLING) yield race([call(pollProductionSaga, auphonicApi), take(auphonic.STOP_POLLING)]) } } function* pollProductionSaga(auphonicApi: AuphonicApiClient): any { while (true) { let uuid: string = yield select(selectors.auphonic.productionId) if (!uuid) { yield put(auphonic.stopPolling()) } let { result: { data: production }, } = yield auphonicApi.get(`production/${uuid}.json`) yield put(auphonic.setProduction(production)) // DONE if (production.status == 3) { yield put(episode.update({ prop: 'slug', value: production.output_basename })) // NOTE: is there a race condition here? because the file transfer uses // the slug form the database // trigger PLUS transfer when production finishes const plusFeatures = yield select(selectors.plus.features) if (plusFeatures.fileStorage) { yield put(auphonic.triggerPlusTransfer({ production_uuid: production.uuid })) } } // see https://auphonic.com/api/info/production_status.json const in_progress_status = [0, 1, 4, 5, 6, 7, 8, 12, 13, 14] if (!in_progress_status.includes(production.status)) { yield put(auphonic.stopPolling()) } yield delay(2500) } } function* handleDeselectProduction(auphonicApi: AuphonicApiClient) { const { result: { data: productions }, } = yield auphonicApi.get(`productions.json`, { limit: 10, minimal_data: true }) yield put(auphonic.setProductions(productions)) } function* handleRemoveTrack( auphonicApi: AuphonicApiClient, action: { type: string; payload: any } ) { let uuid: string = yield select(selectors.auphonic.productionId) yield auphonicApi.delete(`production/${uuid}/multi_input_files/${action.payload}.json`) } function* handleStartProduction( auphonicApi: AuphonicApiClient, action: { type: string; payload: any } ) { const uuid = action.payload.uuid yield call(handleSaveProduction, auphonicApi, { type: auphonic.SAVE_PRODUCTION, payload: { uuid: uuid }, }) const webhookConfig: WebhookConfig | null = yield select(selectors.episode.auphonicWebhookConfig) const isWebhookEnabled: boolean = yield select(selectors.auphonic.publishWhenDone) const baseUrl: String = yield select(selectors.runtime.baseUrl) const postId: Number = yield select(selectors.post.id) const webhookUrl = baseUrl + '/?podlove-auphonic-production=' + postId + '&authkey=' + webhookConfig?.authkey const productionPayload = { webhook: webhookConfig && isWebhookEnabled ? webhookUrl : '', } // update webhook config const { result: { data: _production }, } = yield auphonicApi.post(`production/${uuid}.json`, productionPayload) // TODO: for productions with webhook enabled, should I explicitly re-fetch // the episode when production is done (or poll for a bit)? Otherwise backend // and frontend might be out of sync because the webhook overrides some // episode data. // start production const response: { result: any; error: any } = yield auphonicApi.post( `production/${uuid}/start.json`, {} ) if (response.result) { yield put(auphonic.setProduction(response.result.data)) } else { console.warn(response.error.error_message) } yield put(auphonic.startPolling()) } function* handleSaveTrack( auphonicApi: AuphonicApiClient, uuid: String, trackWrapper: any, handleProgress: any ) { let payload = trackWrapper.payload const id_old = payload.id const id_new = payload.id_new const needs_upload = !!trackWrapper.upload?.file delete payload.id_new payload.id = id_new const progressHandler = handleProgress(payload.id) switch (trackWrapper.state) { case 'edited': yield auphonicApi.post(`production/${uuid}/multi_input_files/${id_old}.json`, payload) if (needs_upload) { yield auphonicApi.upload(`production/${uuid}/upload.json`, trackWrapper.upload, { hooks: { onUploadProgress: progressHandler }, }) } break case 'new': yield auphonicApi.post(`production/${uuid}.json`, { multi_input_files: [trackWrapper.payload], }) if (needs_upload) { yield auphonicApi.upload(`production/${uuid}/upload.json`, trackWrapper.upload, { hooks: { onUploadProgress: progressHandler }, }) } break } } type PreparedFileSelection = { service?: string | null value?: string | null } const prepareFile = (selection: auphonic.FileSelection): PreparedFileSelection => { if (!selection) { return {} } switch (selection.currentServiceSelection) { case 'url': return { service: 'url', value: selection.urlValue } case 'file': return { service: 'file', value: selection.fileValue } default: return { service: selection.currentServiceSelection, value: selection.fileSelection } } } function getFileSelectionsForSingleTrack(state: State): PreparedFileSelection { const selections = get(state, ['auphonic', 'file_selections']) const production_uuid = get(state, ['auphonic', 'production', 'uuid'], '') return prepareFile(get(selections, production_uuid)) } function getFileSelectionsForMultiTrack(state: State): PreparedFileSelection[] { const selections = get(state, ['auphonic', 'file_selections']) const production_uuid = get(state, ['auphonic', 'production', 'uuid'], '') const tracks = get(state, ['auphonic', 'tracks'], []) //@ts-ignore return tracks.reduce((agg, _track, index) => { //@ts-ignore agg.push(prepareFile(get(selections, `${production_uuid}_t${index}`))) return agg }, []) } function getTracksPayload(state: State): any { const isMultitrack = get(state, ['auphonic', 'production', 'is_multitrack'], false) const tracks = get(state, ['auphonic', 'tracks'], []) if (!isMultitrack) { return [] } const fileSelections = getFileSelectionsForMultiTrack(state) return tracks .map((track, index) => { const state = track.save_state if (state == 'unchanged') { return {} } let upload = {} // FIXME: currently service is always url when selecting an existing production let fileReference = {} if (fileSelections[index].service == 'url') { fileReference = { input_file: fileSelections[index].value, } } else if (fileSelections[index].service == 'file') { upload = { track_id: track.identifier_new, file: fileSelections[index].value, } } else { fileReference = { service: fileSelections[index].service, input_file: fileSelections[index].value, } } return { state, upload, payload: { type: 'multitrack', id: track.identifier, id_new: track.identifier_new, ...fileReference, algorithms: { denoise: track.noise_and_hum_reduction, filtering: track.filtering, backforeground: track.fore_background, }, }, } }) .filter((t) => Object.keys(t).length > 0) } function getProductionPayload(state: State): object { let payload = get(state, ['auphonic', 'productionPayload'], {}) // remove output_files from payload, because it doubles them const { output_files, ...newPayload } = payload const episode_poster = selectors.episode.effectivePoster(state) const maybe_output_basename = state.episode.slug ? { output_basename: state.episode.slug } : {} return { ...newPayload, ...maybe_output_basename, // NOTE: image is not actually sent; it's sent as a separate upload and // removed from the payload before saving metadata. reason: Auphonic may not // have access to the URL here (for example in local development), so // sending the file as upload is more reliable. image: episode_poster, metadata: { ...newPayload.metadata, title: state.episode.title || state.post.title, subtitle: state.episode.subtitle, summary: state.episode.summary, artist: state.podcast.author_name, album: state.podcast.title, url: state.podcast.link, track: state.episode.number, }, chapters: state.chapters.chapters.map((chapter) => { const payload: { title: string url?: string start: string image?: string } = { title: chapter.title, url: chapter.href, start: new Timestamp(chapter.start).pretty, } if (chapter.image) { payload.image = chapter.image } return payload }), } } function getSaveProductionPayload(state: State): object { const isMultitrack = get(state, ['auphonic', 'production', 'is_multitrack'], false) const productionPayload = getProductionPayload(state) let fileReference = {} // for single track, add file selection to payload if (!isMultitrack) { const fileSelections = getFileSelectionsForSingleTrack(state) if (fileSelections.service == 'url') { fileReference = { input_file: fileSelections.value, } } else if (fileSelections.service == 'file') { fileReference = { input_file: fileSelections.value, } } else { fileReference = { service: fileSelections.service, input_file: fileSelections.value, } } } return { ...productionPayload, ...fileReference, } } async function uploadProductionImage( auphonicApi: AuphonicApiClient, uuid: string, posterFile: string | null | undefined, handleProgress: (key: string) => (progress: any) => void ) { if (!posterFile) { return } const res = await fetch(posterFile) if (!res.ok) { throw new Error(`Failed to fetch production image: ${res.status} ${res.statusText}`) } const blob = await res.blob() if (!blob.type.startsWith('image/')) { throw new Error(`Invalid production image response type: ${blob.type || 'unknown'}`) } const ext = blob.type.includes('png') ? 'png' : 'jpg' const filename = `image.${ext}` const imageFile = new File([blob], filename, { type: blob.type }) await auphonicApi.upload( `production/${uuid}/upload.json`, { image: imageFile }, { hooks: { onUploadProgress: handleProgress('poster') } } ) } function shouldInlineChapterImage(imageUrl: string): boolean { try { const resolvedUrl = new URL(imageUrl, window.location.href) const localHosts = new Set(['localhost', '127.0.0.1', '::1']) return ( resolvedUrl.origin === window.location.origin || localHosts.has(resolvedUrl.hostname) ) } catch (_error) { return false } } async function blobToBase64(blob: Blob): Promise { return await new Promise((resolve, reject) => { const reader = new FileReader() reader.onloadend = () => { if (typeof reader.result === 'string') { const [, base64] = reader.result.split(',', 2) if (base64) { resolve(base64) } else { reject(new Error('Failed to extract base64 data from chapter image')) } } else { reject(new Error('Failed to convert chapter image to base64')) } } reader.onerror = () => reject(reader.error || new Error('Failed to read chapter image')) reader.readAsDataURL(blob) }) } async function inlineChapterImages(chapters: any[] | undefined): Promise { if (!chapters?.length) { return chapters } return await Promise.all( chapters.map(async (chapter) => { if (!chapter.image || !shouldInlineChapterImage(chapter.image)) { return chapter } try { const response = await fetch(chapter.image) if (!response.ok) { throw new Error(`Failed to fetch chapter image: ${response.status} ${response.statusText}`) } const blob = await response.blob() if (!blob.type.startsWith('image/')) { throw new Error(`Invalid chapter image response type: ${blob.type || 'unknown'}`) } return { ...chapter, image: await blobToBase64(blob), } } catch (error) { console.warn('Skipping Auphonic chapter image after inline conversion failed', error) const { image, ...chapterWithoutImage } = chapter return chapterWithoutImage } }) ) } function* handleSaveProduction( auphonicApi: AuphonicApiClient, action: { type: string; payload: any } ): any { yield put(auphonic.startSaving()) try { const uuid = action.payload.uuid //@ts-ignore const productionPayload = yield select(getSaveProductionPayload) //@ts-ignore const tracksPayload = yield select(getTracksPayload) productionPayload.chapters = yield call(inlineChapterImages, productionPayload.chapters) // delete all existing chapters, otherwise we append them //@ts-ignore yield auphonicApi.delete(`production/${uuid}/chapters.json`) const progressChannel: Channel = yield call( createAndWatchProgressChannel, progress.setProgress ) const handleProgress = createProgressHandler(progressChannel) // save multi_input_files by saving/updating each track individually yield all( tracksPayload.map((trackWrapper: any) => call(handleSaveTrack, auphonicApi, uuid, trackWrapper, handleProgress) ) ) // handle single track if input_file is set // FIXME: only upload when changed, see multitrack logic const input_file = productionPayload.input_file if (typeof input_file == 'object') { yield call( auphonicApi.upload, `production/${uuid}/upload.json`, { file: input_file }, { hooks: { onUploadProgress: handleProgress('singletrack') } } ) delete productionPayload.input_file } // Keep poster upload best-effort so external URLs without CORS do not block saving metadata. const poster_file = productionPayload.image try { yield call(uploadProductionImage, auphonicApi, uuid, poster_file, handleProgress) } catch (error) { console.warn('Skipping Auphonic production image upload after fetch/upload failed', error) } delete productionPayload.image // after the tracks, update all other metadata const { result: { data: production }, } = yield auphonicApi.post(`production/${uuid}.json`, productionPayload) yield put(auphonic.setProduction(production)) } finally { yield put(auphonic.stopSaving()) } } function* fetchServiceFiles( auphonicApi: AuphonicApiClient, action: { type: string; payload: string } ) { const uuid = action.payload if (uuid == 'file' || uuid == 'url') { return } const { result } = yield auphonicApi.get(`service/${uuid}/ls.json`) yield put(auphonic.setServiceFiles({ uuid, files: result.data })) } function* titleWithFallback() { const episodeTitle: string = yield select(selectors.episode.title) const postTitle: string = yield select(selectors.post.title) return episodeTitle || postTitle || `New Production` } function* handleCreateProduction(auphonicApi: AuphonicApiClient) { const presetUUID: string = yield select(selectors.auphonic.preset) const title: string = yield titleWithFallback() const { result } = yield auphonicApi.post(`productions.json`, { preset: presetUUID, metadata: { title: title }, }) const production = result.data yield put(auphonic.setProduction(production)) } function* handleCreateMultitrackProduction(auphonicApi: AuphonicApiClient) { const presetUUID: string = yield select(selectors.auphonic.preset) const title: string = yield titleWithFallback() const { result } = yield auphonicApi.post(`productions.json`, { preset: presetUUID, metadata: { title: title }, is_multitrack: true, }) const production = result.data yield put(auphonic.setProduction(production)) } function* handleServiceFilesAvailable(action: { type: string payload: { uuid: string; files: string[] } }) { const currentKey: string = yield select(selectors.auphonic.currentFileSelection) //@ts-ignore const selection: any = yield select(selectors.auphonic.fileSelections) // set default, but only if necessary if (!selection[currentKey].fileSelection) { // select first available file yield put( auphonic.updateFileSelection({ key: currentKey, prop: 'fileSelection', value: action.payload.files[0], }) ) } } function* handleFileSelection(action: { type: string payload: { key: string; prop: string; value: any } }) { const { prop, value } = action.payload if (prop === 'currentServiceSelection') { yield put(auphonic.selectService(value)) } } export type WebhookConfig = { authkey: String enabled: boolean } function* updateWebhookConfig(api: PodloveApiClient) { const config: WebhookConfig | null = yield select(selectors.episode.auphonicWebhookConfig) const enabled: boolean = yield select(selectors.auphonic.publishWhenDone) // skip if nothing changed if (!config || config.enabled == enabled) { return } yield put( episode.update({ prop: 'auphonic_webhook_config', value: { ...config, enabled: enabled } }) ) } function* initializeWebhookConfig(api: PodloveApiClient) { const config: WebhookConfig | null = yield select(selectors.episode.auphonicWebhookConfig) const enabled: boolean = yield select(selectors.auphonic.publishWhenDone) // skip if it already exists if (config && config.authkey) { return } const authkey = uuidv4() yield put( episode.update({ prop: 'auphonic_webhook_config', value: { authkey, enabled: enabled || false, }, }) ) } function* memorizeSelectedProduction(api: PodloveApiClient) { const episodeId: string = yield select(selectors.episode.id) const uuid: string = yield select(selectors.auphonic.productionId) yield api.put(`episodes/${episodeId}`, { auphonic_production_id: uuid }) } function* forgetSelectedProduction(api: PodloveApiClient) { const episodeId: string = yield select(selectors.episode.id) yield api.put(`episodes/${episodeId}`, { auphonic_production_id: '' }) } function* markProductionAsRunning(api: PodloveApiClient) { const episodeId: string = yield select(selectors.episode.id) yield api.put(`episodes/${episodeId}`, { is_auphonic_production_running: true }) } function* markProductionAsNotRunning(api: PodloveApiClient) { const episodeId: string = yield select(selectors.episode.id) yield api.put(`episodes/${episodeId}`, { is_auphonic_production_running: false }) } function* maybeRestoreProductionSelection() { const episodeId: string = yield select(selectors.episode.id) const memorizedProductionId: string = yield select(selectors.episode.auphonicProductionId) const selectedProductionId: string = yield select(selectors.auphonic.productionId) const productions: auphonic.Production[] = yield select(selectors.auphonic.productions) if (!selectedProductionId && memorizedProductionId && episodeId) { const production = productions.find((production) => production.uuid == memorizedProductionId) if (production) { yield put(auphonic.setProduction(production)) } } } function* memorizeSelectedPreset() { const preset: string = yield select(selectors.auphonic.preset) if (localStorage) { localStorage.setItem('podlove-auphonic-preset', preset) } } function* maybeRestorePresetSelection() { let savedPreset: string | null = null if (localStorage) { savedPreset = localStorage.getItem('podlove-auphonic-preset') if (savedPreset) { yield put(auphonic.setPreset(savedPreset)) } } } function* handleTriggerPlusTransfer( api: PodloveApiClient, action: { type: string; payload: { production_uuid: string } } ): any { const { production_uuid } = action.payload const postId: Number = yield select(selectors.post.id) try { yield put( auphonic.setPlusTransferStatus({ production_uuid, status: 'in_progress', }) ) // Phase 1: Get transfer queue const response = yield api.post( `auphonic/init-plus-file-transfer/${production_uuid}/${postId}`, {} ) if (response.result && response.result.success && response.result.transfer_queue) { const transferQueue = response.result.transfer_queue if (transferQueue.length === 0) { yield put( auphonic.setPlusTransferStatus({ production_uuid, status: 'completed', files: [], }) ) return } // Phase 2: Process files sequentially yield call(processTransferQueue, api, production_uuid, postId, transferQueue) } else { yield put( auphonic.setPlusTransferStatus({ production_uuid, status: 'failed', errors: getApiErrorMessage(response, 'Failed to initialize transfer'), }) ) } } catch (error: any) { yield put( auphonic.setPlusTransferStatus({ production_uuid, status: 'failed', errors: error.message || 'Failed to trigger transfer', }) ) } } // Helper function to get remaining pending files function getPendingFiles(transferQueue: any[], completedCount: number): any[] { return transferQueue.slice(completedCount).map(file => ({ success: null, status: 'pending' as const, filename: file.filename, download_url: file.download_url, message: 'Waiting to transfer...' })) } // Helper function to create file with processing state function createProcessingFile(file: any): any { return { success: null, status: 'processing' as const, filename: file.filename, download_url: file.download_url, message: 'Transferring...' } } // Helper function to update file result with proper status function updateFileResult(result: any): any { return { ...result, status: result.success ? 'completed' as const : 'failed' as const } } function* processTransferQueue( api: PodloveApiClient, production_uuid: string, postId: Number, transferQueue: any[] ): any { const production: auphonic.Production | null = yield select(selectors.auphonic.production) const productionChangeTime = production?.change_time || null let transferredFiles = 0 let hasErrors = false const transferResults: any[] = [] // Show initial transfer UI with all files as pending const initialFiles = transferQueue.map(file => ({ success: null, status: 'pending' as const, filename: file.filename, download_url: file.download_url, message: 'Waiting to transfer...' })) yield put( auphonic.setPlusTransferStatus({ production_uuid, status: 'in_progress', files: initialFiles, }) ) for (let i = 0; i < transferQueue.length; i++) { const file = transferQueue[i] // Mark current file as processing const filesWithProcessing = [ ...transferResults.map(updateFileResult), createProcessingFile(file), ...getPendingFiles(transferQueue, i + 1) ] yield put( auphonic.setPlusTransferStatus({ production_uuid, status: 'in_progress', // Keep as in_progress during transfer files: filesWithProcessing, }) ) try { const result = yield call(transferFile, api, production_uuid, postId, file) transferResults.push(result) if (result.success) { transferredFiles++ } else { hasErrors = true } // Update UI after each file transfer (but still in_progress if more files remain) const isLastFile = i === transferQueue.length - 1 const currentStatus = isLastFile ? determineTransferStatus(hasErrors, transferredFiles) : 'in_progress' yield put( auphonic.setPlusTransferStatus({ production_uuid, status: currentStatus, files: [...transferResults.map(updateFileResult), ...getPendingFiles(transferQueue, transferResults.length)], }) ) } catch (error: any) { hasErrors = true const errorResult = createTransferErrorResponse(file, error.message) transferResults.push(errorResult) console.error('Error transferring file:', error) // Update UI after error (but still in_progress if more files remain) const isLastFile = i === transferQueue.length - 1 const currentStatus = isLastFile ? determineTransferStatus(hasErrors, transferredFiles) : 'in_progress' yield put( auphonic.setPlusTransferStatus({ production_uuid, status: currentStatus, files: [...transferResults.map(updateFileResult), ...getPendingFiles(transferQueue, transferResults.length)], }) ) } } const finalStatus = determineTransferStatus(hasErrors, transferredFiles) // Store final status in backend for page reload persistence try { const payload: any = { status: finalStatus, files: transferResults } if (finalStatus === 'completed' && productionChangeTime) { payload.change_time = productionChangeTime } // Only include errors parameter if there are errors if (hasErrors) { if (transferredFiles === 0) { payload.errors = 'All file transfers failed' } else { const failedCount = transferResults.length - transferredFiles payload.errors = `${failedCount} of ${transferResults.length} file transfers failed` } } yield api.post(`auphonic/set-plus-transfer-status/${production_uuid}/${postId}`, payload) } catch (error: any) { console.error('Failed to store final transfer status:', error) } // Final UI update with only completed results (no pending files) yield put( auphonic.setPlusTransferStatus({ production_uuid, status: finalStatus, files: transferResults.map(updateFileResult), }) ) if (finalStatus === 'completed' && productionChangeTime) { yield put( episode.update({ prop: 'auphonic_plus_transfer_change_time', value: productionChangeTime, }) ) } } function* transferFile( api: PodloveApiClient, production_uuid: string, postId: Number, fileData: any ): any { const response = yield api.post(`auphonic/transfer-single-file/${production_uuid}/${postId}`, { file_data: fileData, }) if (response.result) { return response.result } else { return createErrorResponse(fileData, { message: getApiErrorMessage(response, 'Transfer failed') }) } } function* handleLoadPlusTransferStatus( api: PodloveApiClient, action: { type: string; payload: { production_uuid: string } } ): any { const { production_uuid } = action.payload try { const episodeId: string = yield select(selectors.episode.id) if (!episodeId) { console.error('Episode ID not available for loading transfer status') return } const episodeData = yield api.get(`episodes/${episodeId}`) const transferStatus = episodeData.result.auphonic_plus_transfer_status const transferFiles = episodeData.result.auphonic_plus_transfer_files const transferErrors = episodeData.result.auphonic_plus_transfer_errors if (transferStatus) { yield put( auphonic.setPlusTransferStatus({ production_uuid, status: transferStatus, files: transferFiles, errors: transferErrors, }) ) } } catch (error) { console.error('Error loading PLUS transfer status:', error) } } function* handlePlusTransferStatusChange( api: PodloveApiClient, action: { type: string; payload: { production_uuid: string; status: string } } ): any { const { status } = action.payload if (status === 'completed') { yield call(verifyAll, api) } } export default function () { return function* () { yield takeFirst(auphonic.INIT, auphonicSaga) } } ================================================ FILE: client/src/sagas/chapters.sagas.ts ================================================ import { TakeableChannel } from '@redux-saga/core' import { select, takeEvery, call, put, fork } from 'redux-saga/effects' import keyboard from '@podlove/utils/keyboard' import { selectors } from '@store' import Timestamp from '@lib/timestamp' import { PodloveApiClient } from '@lib/api' import { notify } from '@store/notification.store' import * as chapters from '@store/chapters.store' import * as wordpress from '@store/wordpress.store' import { parseAudacityChapters, parseMp4Chapters, parseHindeburgChapters, parsePodloveChapters } from '@lib/chapters' import { PodloveChapter } from '../types/chapters.types' import { channel, takeFirst } from './helper' import { __ } from '../plugins/translations' import { createApi } from './api' function* chaptersSaga(): any { const apiClient: PodloveApiClient = yield createApi() yield fork(initialize, apiClient) yield takeEvery([chapters.PARSE], handleImport) yield takeEvery(chapters.DOWNLOAD, handleExport) yield takeEvery( [chapters.UPDATE, chapters.PARSED, chapters.ADD, chapters.REMOVE, chapters.SET_IMAGE], save, apiClient ) const onKeyDown: TakeableChannel = yield call(channel, keyboard.utils.keydown) yield takeEvery(onKeyDown, handleKeydown) yield takeEvery(chapters.SELECT_IMAGE, selectImageFromLibrary) } function* initialize(api: PodloveApiClient) { const episodeId: string = yield select(selectors.episode.id) if (!episodeId) { return } const { result }: { result: { chapters: PodloveChapter[] } } = yield api.get( `chapters/${episodeId}` ) if (result) { yield put( chapters.set( result.chapters.map((chapter) => ({ ...chapter, start: Timestamp.fromString(chapter.start).totalMs, })) ) ) } } function* save(api: PodloveApiClient) { const episodeId: string = yield select(selectors.episode.id) const chapters: PodloveChapter[] = yield select(selectors.chapters.list) yield api.put(`chapters/${episodeId}`, { chapters: chapters.map((chapter) => ({ ...chapter, start: new Timestamp(chapter.start).pretty, })), }) } // Export handling function* handleExport(action: { type: string; payload: 'psc' | 'mp4' }) { const chapters: PodloveChapter[] = yield select(selectors.chapters.list) switch (action.payload) { case 'psc': download('chapters.psc', generatePscDownload(chapters)) break case 'mp4': download('chapters.txt', generateMp4Download(chapters)) break } } function generatePscDownload(chapters: PodloveChapter[]): string { const serializer = new XMLSerializer() const psc = '' const parser = new DOMParser() const xmlDoc = parser.parseFromString(psc, 'text/xml') // need both tries for Chrome/Firefox compatibility let pscDoc: any = xmlDoc.getElementsByTagName('chapters') if (!pscDoc.length) { pscDoc = xmlDoc.getElementsByTagName('psc:chapters') } pscDoc = pscDoc[0] chapters.forEach((chapter: PodloveChapter) => { let node = xmlDoc.createElement('psc:chapter') node.setAttribute('title', chapter.title || '') node.setAttribute('start', chapter.start ? new Timestamp(chapter.start).pretty : '00:00:00') if (chapter.href) { node.setAttribute('href', chapter.href) } if (chapter.image) { node.setAttribute('image', chapter.image) } pscDoc.appendChild(node) }) let serialized = serializer.serializeToString(xmlDoc) // poor man's formatting let formatted = serialized .replace(/\', '\n') return formatted } function generateMp4Download(chapters: PodloveChapter[]): string { const timestamp = (chapter: PodloveChapter): string => { if (isNaN(chapter.start)) { return '' } return new Timestamp(chapter.start).pretty } const href = (chapter: PodloveChapter): string => { return chapter.href ? '<' + chapter.href + '>' : '' } return ( chapters .reduce((result: string[], chapter) => { let line = timestamp(chapter) + ' ' + chapter.title + ' ' + href(chapter) return [...result, line.trim()] }, []) .join('\n') + '\n' ) } function download(name: string, data: any) { var blob = new Blob([data], { type: 'text/plain' }) const a = document.createElement('a') a.href = window.URL.createObjectURL(blob) a.download = name document.body.appendChild(a) a.click() document.body.removeChild(a) } // Import handling function* handleImport(action: { type: string; payload: string }) { const parser: ((text: string) => PodloveChapter[])[] = [ parseMp4Chapters, parseAudacityChapters, parseHindeburgChapters, parsePodloveChapters, ] let parsedChapters: PodloveChapter[] | null = [] parser.forEach((parseFn) => { if (parsedChapters !== null && parsedChapters.length > 0) { return } try { parsedChapters = parseFn(action.payload) } catch (err) { parsedChapters = null } }) if (parsedChapters === null) { yield put(notify({ type: 'error', message: __('Unable to parse PSC chapters.', 'podlove-podcasting-plugin-for-wordpress') })) return } yield put(chapters.parsed(parsedChapters)) } // Key event handling function* handleKeydown(input: { key: string ctrl: boolean shift: boolean meta: boolean alt: boolean }) { const selectedIndex: number = yield select(selectors.chapters.selectedIndex) if (selectedIndex === null) { return } const chaptersList: PodloveChapter[] = yield select(selectors.chapters.list) switch (true) { case input.key === 'up': if (selectedIndex === 0) { yield put(chapters.select(chaptersList.length - 1)) } else { yield put(chapters.select(selectedIndex - 1)) } break case input.key === 'down': if (selectedIndex === chaptersList.length - 1) { yield put(chapters.select(0)) } else { yield put(chapters.select(selectedIndex + 1)) } break case input.key === 'esc': yield put(chapters.select(null)) break } } function* selectImageFromLibrary() { yield put(wordpress.selectMediaFromLibrary({ onSuccess: { type: chapters.SET_IMAGE } })) } export default function () { return function* () { yield takeFirst(chapters.INIT, chaptersSaga) } } ================================================ FILE: client/src/sagas/contributors.sagas.ts ================================================ import { fork, put, select, takeEvery, throttle } from 'redux-saga/effects' import { get, toInteger } from 'lodash' import * as contributors from '@store/contributors.store' import * as episode from '../store/episode.store' import { PodloveApiClient } from '@lib/api' import { takeFirst } from './helper' import { createApi } from './api' import { selectors } from '@store' import { PodloveRole, PodloveGroup } from '../types/contributors.types' import { PodloveEpisode, PodloveEpisodeContribution } from '../types/episode.types' import { Action } from 'redux' import { __ } from '../plugins/translations' function* contributorsSaga() { const apiClient: PodloveApiClient = yield createApi() yield fork(fetchContributors, apiClient) yield fork(fetchRoles, apiClient) yield fork(fetchGroups, apiClient) yield fork(fetchEpisodeContributions, apiClient) yield takeEvery(episode.CREATE_CONTRIBUTION, createEpisodeContribution, apiClient) yield throttle( 3000, [ episode.MOVE_CONTRIBUTION_DOWN, episode.MOVE_CONTRIBUTION_UP, episode.DELETE_CONTRIBUTION, episode.UPDATE_CONTRIBUTION, episode.ADD_CONTRIBUTION, ], updateEpisodeContributions, apiClient ) } function* fetchEpisodeContributions(api: PodloveApiClient) { const episodeId: string = yield select(selectors.episode.id) if (!episodeId) { return } const { result }: { result: PodloveEpisode } = yield api.get( `episodes/${episodeId}/contributions` ) if (!result) { return } yield put(episode.set({ contributions: get(result, ['contribution'], []) })) } function* updateEpisodeContributions(api: PodloveApiClient) { const episodeId: string = yield select(selectors.episode.id) if (!episodeId) { return } const roles: PodloveRole[] = yield select(selectors.contributors.roles) const groups: PodloveGroup[] = yield select(selectors.contributors.groups) const data: PodloveEpisodeContribution[] = yield select(selectors.episode.contributions) let contributors = data.map(function ({ contributor_id, role_id, group_id, position, comment }) { return { contributor_id: toInteger(contributor_id), role_id: toInteger(role_id) == 0 && roles.length > 0 ? roles[0].id : toInteger(role_id), group_id: toInteger(group_id) == 0 && groups.length > 0 ? groups[0].id : toInteger(group_id), position: toInteger(position), comment: comment || '' } }) yield api.put(`episodes/${episodeId}/contributions`, { contributors }) } function* fetchContributors(api: PodloveApiClient) { const { result } = yield api.get('contributors', { query: { filter: 'all' } }) if (!result) { return } yield put(contributors.setContributors(get(result, 'contributors', []))) } function* fetchRoles(api: PodloveApiClient) { const { result } = yield api.get('contributors/roles') if (!result) { return } yield put(contributors.setRoles(get(result, 'roles', []))) } function* fetchGroups(api: PodloveApiClient) { const { result } = yield api.get('contributors/groups') if (!result) { return } yield put(contributors.setGroups(get(result, 'groups', []))) } function* createEpisodeContribution(api: PodloveApiClient, action: Action) { const { result: createContributorResult, error: createContributorError } = yield api.post( `contributors`, {} ) if (createContributorError) { return } const contributorId = createContributorResult?.id const realname: string = get(action, ['payload']) const { error: updateContributorError } = yield api.put(`contributors/${contributorId}`, { realname }) if (updateContributorError) { return } const contributor = { id: contributorId, realname } yield put(contributors.addContributor(contributor)) yield put(episode.addContribution(contributor)) } export default function () { return function* () { yield takeFirst(contributors.INIT, contributorsSaga) } } ================================================ FILE: client/src/sagas/episode.sagas.ts ================================================ import { PodloveApiClient } from '@lib/api' import { selectors } from '@store' import { get, isEmpty } from 'lodash' import { Action } from 'redux' import { debounce, fork, put, select, takeEvery } from 'redux-saga/effects' import { PodloveEpisode } from '../types/episode.types' import * as auphonic from '../store/auphonic.store' import * as episode from '../store/episode.store' import * as mediafiles from '../store/mediafiles.store' import * as wordpress from '../store/wordpress.store' import { createApi } from './api' import { WebhookConfig } from './auphonic.sagas' import { takeFirst } from './helper' let EPISODE_UPDATE: { [key: string]: any } = {} function* episodeSaga(): any { const apiClient: PodloveApiClient = yield createApi() yield fork(initialize, apiClient) yield takeEvery(episode.UPDATE, collectEpisodeUpdate) yield debounce(1000, episode.UPDATE, save, apiClient) yield debounce(50, episode.QUICKSAVE, save, apiClient) yield takeEvery(episode.SAVED, maybeMarkSlugAsChanged) yield takeEvery(episode.SELECT_POSTER, selectImageFromLibrary) yield takeEvery(episode.SET_POSTER, updatePoster) yield takeEvery(wordpress.UPDATE, updatePosterFromGutenberg) yield takeEvery(episode.SET, updateAuphonicWebhookConfig) } function* updateAuphonicWebhookConfig() { const config: WebhookConfig | null = yield select(selectors.episode.auphonicWebhookConfig) if (config) { yield put(auphonic.updateWebhook(config.enabled)) } } function* initialize(api: PodloveApiClient) { const episodeId: string = yield select(selectors.episode.id) if (!episodeId) { return } const { result: episodesResult }: { result: PodloveEpisode } = yield api.get( `episodes/${episodeId}` ) if (episodesResult) { if (episodesResult.slug === null) { yield put(mediafiles.enableSlugAutogen()) } yield put(episode.set(episodesResult)) } } function* collectEpisodeUpdate(action: Action) { const prop = get(action, ['payload', 'prop']) const value = get(action, ['payload', 'value'], null) if (!prop) { return } // If trying to update slug when frozen, block the update if (prop === 'slug') { const slugFrozen: boolean = yield select(selectors.episode.slugFrozen) if (slugFrozen) { console.warn('Attempted to update frozen slug - update blocked') return } } EPISODE_UPDATE[prop] = value } function* save(api: PodloveApiClient, action: Action) { const episodeId: string = yield select(selectors.episode.id) if (isEmpty(EPISODE_UPDATE)) { return } yield api.put(`episodes/${episodeId}`, EPISODE_UPDATE, { query: { skip_validation: '1' } }) yield put(episode.saved(EPISODE_UPDATE)) EPISODE_UPDATE = {} } function* maybeMarkSlugAsChanged(action: { type: string; payload: object }) { if (Object.keys(action.payload).includes('slug')) { yield put(episode.slugChanged()) } } function* selectImageFromLibrary() { yield put(wordpress.selectMediaFromLibrary({ onSuccess: { type: episode.SET_POSTER } })) } function* updatePoster(action: Action) { yield put(episode.update({ prop: 'episode_poster', value: get(action, ['payload']) })) } function* updatePosterFromGutenberg(action: { type: string; payload: object }) { const poster_setting: string = yield select(selectors.settings.imageAsset) // only apply if the featured media is used for the episode cover if (poster_setting != 'post-thumbnail') { return } // only apply if the current event is about featured_media if (get(action, ['payload', 'prop']) != 'featured_media') { return } const img_url = get(action, ['payload', 'value', 'source_url']) yield put(episode.update({ prop: 'poster', value: img_url })) } export default function () { return function* () { yield takeFirst(episode.INIT, episodeSaga) } } ================================================ FILE: client/src/sagas/helper.ts ================================================ import { eventChannel, Channel, channel as reduxChannel } from 'redux-saga' import { fork, take, call, select, spawn, cancelled, put } from 'redux-saga/effects' import { AxiosProgressEvent } from 'axios' export const channel = (host: Function) => eventChannel((emitter) => { const pipe = (args: any[]) => { emitter(args || {}) } host(pipe) return () => {} }) export function* takeFirst(pattern: string, saga: any, ...args: any[]) { // @ts-ignore const task = yield fork(function* () { while (true) { const action: { type: string; payload: any } = yield take(pattern) yield call(saga, ...args.concat(action)) } }) return task } export function sleep(sec: number): Promise { return new Promise((resolve) => setTimeout(resolve, sec * 1000)) } export function* waitFor(selector: any) { const tester: boolean = yield select(selector) if (tester) return // (1) while (true) { yield take('*') const tester: boolean = yield select(selector) if (tester) return // (1b) } } export type ProgressPayload = { key: string progress: number } export interface ProgressData { key: string progress: number } export function* watchProgressChannel( progressChannel: Channel, progressAction: Function ) { try { while (true) { const payload: ProgressPayload = yield take(progressChannel) // TODO: reset when selecting a file // TODO: reset when using the source picker if (progressAction.constructor.name === 'GeneratorFunction') { yield call(function* () { yield* progressAction(payload) }) } else { const action = progressAction(payload) if (action) { yield put(action) } } } } finally { if ((yield cancelled()) as boolean) { progressChannel.close() } } } export function* createAndWatchProgressChannel(progressAction: Function) { const progressChannel: Channel = yield call(reduxChannel) yield spawn(watchProgressChannel, progressChannel, progressAction) return progressChannel } export const createProgressHandler = (progressChannel: Channel) => { return (key: string) => (progressEvent: AxiosProgressEvent) => { if (progressEvent.total) { const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total) const payload: ProgressPayload = { key, progress: percentCompleted } progressChannel.put(payload) } } } ================================================ FILE: client/src/sagas/lifecycle.sagas.ts ================================================ import { eventChannel, END, EventChannel } from 'redux-saga' import { call, takeEvery, put } from 'redux-saga/effects' import * as lifecycle from '@store/lifecycle.store' function lifecycleSaga(): () => any { return function* () { const saveChannel: EventChannel = yield call(clickListener, 'click', 'button.editor-post-publish-button') yield takeEvery(saveChannel, save) yield takeEvery(lifecycle.INIT, ready) } } function* save() { yield put(lifecycle.save()) } function* ready() { yield put(lifecycle.ready()); } function clickListener(eventName: string, selector: string) { return eventChannel(emitter => { let target: HTMLElement const eventListener = (event: MouseEvent) => { emitter(event) }; window.addEventListener('load', () => { target = document.querySelector(selector) as HTMLElement; target?.addEventListener(eventName, eventListener as EventListener); }) return () => { target?.removeEventListener(eventName, eventListener as EventListener) emitter(END) } }) } export default lifecycleSaga ================================================ FILE: client/src/sagas/mediafiles.duration.sagas.ts ================================================ import { PodloveApiClient } from '@lib/api' import { selectors } from '@store' import { put, select } from 'redux-saga/effects' import * as episode from '@store/episode.store' import { MediaFile } from '@store/mediafiles.store' export function* maybeUpdateDuration(api: PodloveApiClient) { const files: MediaFile[] = yield select(selectors.mediafiles.files) const duration: string = yield select(selectors.episode.duration) const enabledFiles = files.filter((file) => file.enable && file.size && file.url) const audioFiles = enabledFiles.filter((file) => file.url.match(/\.(mp3|mp4|m4a|ogg|oga|opus)$/)) let newDuration if (audioFiles.length === 0) { newDuration = '0' } else { const url = audioFiles[0].url const result: number = yield fetchDuration(url) newDuration = result.toString() } if (parseFloat(duration) !== parseFloat(newDuration)) { yield put(episode.update({ prop: 'duration', value: newDuration })) } } async function loadMeta(audio: HTMLAudioElement) { return new Promise((resolve) => (audio.onloadedmetadata = () => resolve())) } async function fetchDuration(src: string) { var audio = new Audio() audio.setAttribute('preload', 'metadata') audio.setAttribute('src', src) audio.load() await loadMeta(audio) return audio.duration } ================================================ FILE: client/src/sagas/mediafiles.enable.sagas.ts ================================================ import { PodloveApiClient } from '@lib/api' import { selectors } from '@store' import { put, select } from 'redux-saga/effects' import * as mediafiles from '@store/mediafiles.store' import * as episode from '@store/episode.store' import { MediaFile } from '@store/mediafiles.store' export function* handleEnable(api: PodloveApiClient, action: { type: string; payload: number }) { const episodeId: string = yield select(selectors.episode.id) const asset_id = action.payload const { result } = yield api.put(`episodes/${episodeId}/media/${asset_id}/enable`, {}) const fileUpdate: Partial = { asset_id: asset_id, url: result.file_url, size: result.file_size, enable: true, } yield put(mediafiles.update(fileUpdate)) // Update episode freeze status if it was returned from enable if (typeof result.slug_frozen !== 'undefined') { yield put(episode.update({ prop: 'slug_frozen', value: result.slug_frozen })) } } export function* handleDisable(api: PodloveApiClient, action: { type: string; payload: number }) { const episodeId: string = yield select(selectors.episode.id) const asset_id = action.payload yield api.put(`episodes/${episodeId}/media/${asset_id}/disable`, {}) } ================================================ FILE: client/src/sagas/mediafiles.fileselection.sagas.ts ================================================ import { PodloveApiClient } from '@lib/api' import { call, put, select, delay, fork } from 'redux-saga/effects' import * as mediafiles from '@store/mediafiles.store' import * as episode from '@store/episode.store' import * as progress from '@store/progress.store' import { Action } from 'redux' import { get } from 'lodash' import { selectors } from '@store' export function* handleFileSelection(api: PodloveApiClient, action: Action): Generator { const { files, episodeSlug } = get(action, ['payload']) const existingSelectedFiles = yield select(selectors.mediafiles.selectedFiles) const existingFileObjects = existingSelectedFiles.map((fileInfo: any) => fileInfo.file) const newFiles = rejectExistingFiles(files, existingFileObjects) if (newFiles.length > 0) { const currentSlug = yield call(setEpisodeSlugIfNeeded, newFiles, episodeSlug) const episodeId = yield select(selectors.episode.id) // Immediately show files with original names (no file existence check yet) const immediateFileInfos = newFiles.map(file => ({ file, originalName: file.name, newName: file.name, fileExists: null, // Will be determined after filename generation })) const allFileInfos = [...existingSelectedFiles, ...immediateFileInfos] // Show files immediately yield put({ type: mediafiles.SET_FILE_INFO, payload: allFileInfos, }) // Generate filenames in the background for each new file for (const file of newFiles) { yield fork(generateFilenameForFile, api, file, episodeId) } } } function rejectExistingFiles(files: File[], existingFiles: File[]): File[] { return files.filter((file: File) => !existingFiles.some((existing: File) => existing.name === file.name && existing.size === file.size ) ) } function extractSlugFromFilename(fileName: string): string { return fileName.split('.').slice(0, -1).join('.') } function* setEpisodeSlugIfNeeded(files: File[], providedSlug: string | null): Generator { if (providedSlug) { return providedSlug } if (files.length === 0) { return '' } const firstFilename = files[0].name const extractedSlug = extractSlugFromFilename(firstFilename) yield put(episode.update({ prop: 'slug', value: extractedSlug })) return extractedSlug } export function* checkFileExists(api: PodloveApiClient, fileInfo: any): Generator { const { result: fileExists } = yield api.post(`plus/check_file_exists`, { filename: fileInfo.file.name, }) return { ...fileInfo, fileExists, } } /** * Generate filename for a single file in the background and update the UI */ export function* generateFilenameForFile(api: PodloveApiClient, file: File, episodeId: string): Generator { const progressKey = `filename-generation-${file.name}` try { // Start loading state yield put(progress.setProgressStatus({ key: progressKey, status: 'in_progress', message: 'Generating filename...' })) const { result } = yield api.post('plus/generate_filename', { original_filename: file.name, episode_id: episodeId, }) const newFileName = result.generated_filename const newFile = new File([file], newFileName, { type: file.type, lastModified: file.lastModified, }) // Check if file exists with the new filename const fileInfo = { file: newFile, originalName: file.name, newName: newFileName, } const fileInfoWithExistenceCheck = yield call(checkFileExists, api, fileInfo) // Update the specific file in the selectedFiles array yield call(updateFileInSelection, file.name, fileInfoWithExistenceCheck) // Complete loading state yield put(progress.setProgressStatus({ key: progressKey, status: 'finished', message: 'Filename generated' })) // Clean up progress state after a short delay yield fork(cleanupProgressState, progressKey, 2000) } catch (error) { // Error state yield put(progress.setProgressStatus({ key: progressKey, status: 'error', message: 'Failed to generate filename' })) // Clean up error state after a delay yield fork(cleanupProgressState, progressKey, 5000) console.warn('Failed to generate filename via API, keeping original:', error) } } /** * Update a specific file in the selectedFiles array */ export function* updateFileInSelection(originalFileName: string, updatedFileInfo: any): Generator { const selectedFiles = yield select(selectors.mediafiles.selectedFiles) const updatedSelectedFiles = selectedFiles.map((fileInfo: any) => fileInfo.originalName === originalFileName ? updatedFileInfo : fileInfo ) yield put({ type: mediafiles.SET_FILE_INFO, payload: updatedSelectedFiles, }) } export function* cleanupProgressState(progressKey: string, delayMs: number): Generator { yield delay(delayMs) yield put(progress.resetProgress(progressKey)) } ================================================ FILE: client/src/sagas/mediafiles.sagas.ts ================================================ import { PodloveApiClient } from '@lib/api' import { selectors } from '@store' import { fork, put, select, takeEvery, takeLatest, debounce, throttle, } from 'redux-saga/effects' import * as mediafiles from '@store/mediafiles.store' import * as episode from '@store/episode.store' import * as wordpress from '@store/wordpress.store' import { MediaFile } from '@store/mediafiles.store' import { takeFirst } from './helper' import { createApi } from './api' import { get } from 'lodash' // Import handlers from other saga modules import { handleEnable, handleDisable } from './mediafiles.enable.sagas' import { handleVerify, verifyAll } from './mediafiles.verification.sagas' import { maybeUpdateDuration } from './mediafiles.duration.sagas' import { maybeUpdateSlug, updateSelectedFileNames, handleUnfreezeSlug } from './mediafiles.slug.sagas' import { handleFileSelection } from './mediafiles.fileselection.sagas' import { selectMediaFromLibrary, triggerPlusUpload, setUploadMedia } from './mediafiles.upload.sagas' function* mediafilesSaga(): any { const apiClient: PodloveApiClient = yield createApi() yield fork(initialize, apiClient) } function* initialize(api: PodloveApiClient) { const episodeId: string = yield select(selectors.episode.id) let files: MediaFile[] = [] if (episodeId) { const { result } = yield api.get(`episodes/${episodeId}/media`) files = get(result, ['results'], []) } yield put(mediafiles.set(files)) yield takeEvery(mediafiles.ENABLE, handleEnable, api) yield takeEvery(mediafiles.DISABLE, handleDisable, api) yield takeEvery(mediafiles.VERIFY, handleVerify, api) yield takeEvery(mediafiles.VERIFY_ALL, verifyAll, api) yield takeLatest(episode.SLUG_CHANGED, verifyAll, api) yield takeLatest(episode.SLUG_CHANGED, updateSelectedFileNames, api) yield debounce(2000, wordpress.UPDATE, maybeUpdateSlug, api) yield takeEvery(mediafiles.FILE_SELECTED, handleFileSelection, api) yield throttle( 2000, [mediafiles.ENABLE, mediafiles.DISABLE, mediafiles.UPDATE], maybeUpdateDuration, api ) yield takeEvery(mediafiles.UPLOAD_INTENT, selectMediaFromLibrary) yield takeEvery(mediafiles.PLUS_UPLOAD_INTENT, triggerPlusUpload, api) yield takeEvery(mediafiles.SET_UPLOAD_URL, setUploadMedia, api) yield takeEvery(mediafiles.UNFREEZE_SLUG, handleUnfreezeSlug, api) yield put(mediafiles.initDone()) } export default function () { return function* () { yield takeFirst(mediafiles.INIT, mediafilesSaga) } } ================================================ FILE: client/src/sagas/mediafiles.slug.sagas.ts ================================================ import { PodloveApiClient } from '@lib/api' import { selectors } from '@store' import { put, select, fork } from 'redux-saga/effects' import * as mediafiles from '@store/mediafiles.store' import * as episode from '@store/episode.store' import { generateFilenameForFile } from './mediafiles.fileselection.sagas' export function* maybeUpdateSlug( api: PodloveApiClient, action: { type: string; payload: { prop: string; value: any } } ) { const episodeId: boolean = yield select(selectors.episode.id) const oldSlug: boolean = yield select(selectors.episode.slug) const enabled: boolean = yield select(selectors.mediafiles.slugAutogenerationEnabled) if (enabled && action.payload.prop == 'title' && action.payload.value) { const newTitle = action.payload.value const { result } = yield api.get(`episodes/${episodeId}/build_slug`, { query: { title: newTitle }, }) if (oldSlug != result.slug) { yield put(episode.update({ prop: 'slug', value: result.slug })) } } } export function* updateSelectedFileNames(api: PodloveApiClient): Generator { const selectedFiles: any[] = yield select(selectors.mediafiles.selectedFiles) const newSlug: string = yield select(selectors.episode.slug) if (selectedFiles.length > 0 && newSlug) { // Recreate file infos with original names first const originalFiles = selectedFiles.map(fileInfo => { return new File([fileInfo.file], fileInfo.originalName, { type: fileInfo.file.type, lastModified: fileInfo.file.lastModified, }) }) // Immediately update files with original names (no file existence check yet) const immediateFileInfos = originalFiles.map(file => ({ file, originalName: file.name, newName: file.name, fileExists: null, // Will be determined after filename generation })) // Show files immediately yield put({ type: mediafiles.SET_FILE_INFO, payload: immediateFileInfos, }) // Generate new filenames in the background const episodeId = yield select(selectors.episode.id) for (const file of originalFiles) { yield fork(generateFilenameForFile, api, file, episodeId) } } } export function* handleUnfreezeSlug(api: PodloveApiClient): Generator { const episodeId: string = yield select(selectors.episode.id) if (!episodeId) { return } try { const { result } = yield api.post(`episodes/${episodeId}/unfreeze_slug`, {}) yield put(episode.set({ slug_frozen: result.slug_frozen })) } catch (error) { console.error('Failed to unfreeze slug:', error) } } ================================================ FILE: client/src/sagas/mediafiles.upload.sagas.ts ================================================ import { PodloveApiClient } from '@lib/api' import { selectors } from '@store' import { call, put, select } from 'redux-saga/effects' import * as mediafiles from '@store/mediafiles.store' import * as episode from '@store/episode.store' import * as wordpress from '@store/wordpress.store' import * as progress from '@store/progress.store' import { createAndWatchProgressChannel, createProgressHandler, ProgressPayload, } from './helper' import { Action } from 'redux' import { get } from 'lodash' import axios, { AxiosResponse } from 'axios' import { Channel } from 'redux-saga' export function* selectMediaFromLibrary() { yield put(wordpress.selectMediaFromLibrary({ onSuccess: { type: mediafiles.SET_UPLOAD_URL } })) } /** * Uploads a file to Podlove Plus service * * This saga: * 1. Requests a pre-signed upload URL from the Plus API * 2. Uploads the file directly to the provided URL * 3. Extracts the permanent file URL and dispatches it via setUploadUrl action * 4. Tracks upload progress */ export function* triggerPlusUpload(api: PodloveApiClient, action: Action): Generator { const file = get(action, ['payload']) const progressKey = `plus-upload-${file.name}` // Reset any previous progress for this file yield put(progress.resetProgress(progressKey)) try { const uploadUrl = yield call(getUploadUrl, api, file.name) const fileUrl = yield call(uploadFileToUrl, uploadUrl, file, progressKey) yield put(mediafiles.setUploadUrl(fileUrl)) const completeResult = yield call(completeFileUpload, api, file.name) console.log('completeResult', completeResult) } catch (error) { console.error('File upload failed:', error) yield put( progress.setProgressStatus({ key: progressKey, status: 'error', message: error instanceof Error ? error.message : 'File upload failed', }) ) } } /** * Gets a pre-signed upload URL from the Plus API */ function* getUploadUrl(api: PodloveApiClient, filename: string): Generator { const { result: upload_url } = yield api.post(`plus/create_file_upload`, { filename, }) if (!upload_url) { throw new Error('Failed to get upload URL') } return upload_url } /** * Uploads file to the provided URL with progress tracking */ function* uploadFileToUrl(uploadUrl: string, file: File, progressKey: string): Generator { const progressChannel: Channel = yield call( createAndWatchProgressChannel, handleProgressUpdate ) const handleProgress = createProgressHandler(progressChannel) const response: AxiosResponse = yield call(axios.put, uploadUrl, file, { headers: { 'Content-Type': file.type }, onUploadProgress: handleProgress(progressKey), }) const fileUrl = response.config.url?.split('?')[0] if (!fileUrl) { throw new Error('Failed to extract file URL from response') } return fileUrl } /** * Completes the file upload process via Plus API */ function* completeFileUpload(api: PodloveApiClient, filename: string): Generator { const { result: completeResult } = yield api.post(`plus/complete_file_upload`, { filename, }) if (!completeResult) { throw new Error('Failed to complete file upload') } return completeResult } function* handleProgressUpdate(value: ProgressPayload) { yield put(progress.setProgress(value)) yield put( progress.setProgressStatus({ key: value.key, status: value.progress == 100 ? 'finished' : 'in_progress', }) ) } export function* setUploadMedia(api: PodloveApiClient, action: Action) { const url = get(action, ['payload']) const slug = url.split('\\').pop().split('/').pop().split('.').shift() const currentSlug: string = yield select(selectors.episode.slug) if (!currentSlug) { yield put(episode.update({ prop: 'slug', value: slug })) yield put(episode.quicksave()) } else { // If slug is already set, verify the media files, which is otherwise a side // effect of saving the episode yield call(verifyAll, api) } } // Import verifyAll from verification saga import { verifyAll } from './mediafiles.verification.sagas' ================================================ FILE: client/src/sagas/mediafiles.verification.sagas.ts ================================================ import { PodloveApiClient } from '@lib/api' import { selectors } from '@store' import { all, fork, put, select } from 'redux-saga/effects' import * as mediafiles from '@store/mediafiles.store' import * as episode from '@store/episode.store' import { MediaFile } from '@store/mediafiles.store' export function* verifyAll(api: PodloveApiClient) { const episodeId: number = yield select(selectors.episode.id) const mediaFiles: MediaFile[] = yield select(selectors.mediafiles.files) // verify all yield all(mediaFiles.map((file) => fork(verifyEpisodeAsset, api, episodeId, file.asset_id))) } function* verifyEpisodeAsset(api: PodloveApiClient, episodeId: number, assetId: number) { const mediaFiles: MediaFile[] = yield select(selectors.mediafiles.files) const prevMediaFile: MediaFile | undefined = mediaFiles.find((mf) => mf.asset_id == assetId) yield put( mediafiles.update({ asset_id: assetId, is_verifying: true, }) ) const { result } = yield api.put(`episodes/${episodeId}/media/${assetId}/verify`, {}) // auto-enable if file size changed from zero to non-zero const enable = (!prevMediaFile?.size && result.file_size) || prevMediaFile?.enable const fileUpdate: Partial = { asset_id: assetId, url: result.file_url, size: result.file_size, enable: enable, is_verifying: false, } yield put(mediafiles.update(fileUpdate)) // Update episode freeze status if it was returned from verification if (typeof result.slug_frozen !== 'undefined') { yield put(episode.update({ prop: 'slug_frozen', value: result.slug_frozen })) } } export function* handleVerify(api: PodloveApiClient, action: { type: string; payload: number }) { const episodeId: number = yield select(selectors.episode.id) const assetId = action.payload yield verifyEpisodeAsset(api, episodeId, assetId) } ================================================ FILE: client/src/sagas/notification.saga.ts ================================================ import { takeEvery } from 'redux-saga/effects' import { get } from 'lodash' import { NOTIFY } from '@store/notification.store' function errorSaga() { return function* () { yield takeEvery(NOTIFY, showNotification) } } function* showNotification(action: { type: string, payload: { type: 'success' | 'info' | 'error' | 'warning', message: string } }) { const dispatch = get(globalThis, ['wp', 'data', 'dispatch']) if (dispatch) { wordPressError(dispatch, get(action, ['payload'])) } else { consoleError(action.payload) } } function wordPressError( dispatch: Function, { type, message }: { type: 'success' | 'warning' | 'error' | 'info'; message: string } ) { if (!message) { return } dispatch('core/notices').createNotice( type, // Can be one of: success, info, warning, error. message, // Text string to display. { type: 'snackbar', isDismissible: true, // Whether the user can dismiss the notice. } ) } function consoleError({ type, message, }: { type: 'success' | 'warning' | 'error' | 'info' message: string }) { switch (type) { case 'success': case 'info': console.log(message) break case 'warning': console.warn(message) break case 'error': console.error(message) break } } export default errorSaga ================================================ FILE: client/src/sagas/plus.sagas.ts ================================================ import * as plus from '@store/plus.store' import { takeFirst } from './helper' import { fork, put, select, call, takeEvery } from 'redux-saga/effects' import { PodloveApiClient } from '@lib/api' import { createApi } from './api' function* plusSaga() { const apiClient: PodloveApiClient = yield createApi() yield fork(initialize, apiClient) } function* initialize(api: PodloveApiClient) { const { result } = yield api.get(`admin/plus/features`) yield put(plus.setFeature({ feature: 'fileStorage', value: result.file_storage })) yield put(plus.setFeature({ feature: 'feedProxy', value: result.feed_proxy })) yield takeEvery(plus.SET_FEATURE, setFeature, api) yield takeEvery(plus.GET_TOKEN, getToken, api) yield takeEvery(plus.SAVE_TOKEN, saveToken, api) yield put(plus.getToken()) } function* setFeature(api: PodloveApiClient, action: ReturnType) { const { feature, value } = action.payload yield api.post(`admin/plus/set_feature`, { feature, value }) } function* getToken(api: PodloveApiClient) { try { yield put(plus.setLoading(true)) const { result } = yield api.get(`admin/plus/token`) yield put(plus.setToken(result.token || '')) if (result.token) { yield call(validateToken, api, result.token) } } catch (error) { console.error('Failed to get token:', error) yield put(plus.setToken('')) yield put(plus.setUser(null)) } finally { yield put(plus.setLoading(false)) } } function* validateToken(api: PodloveApiClient, token: string) { try { const { result } = yield api.get(`admin/plus/validate_token`) if (result.user) { yield put(plus.setUser(result.user)) } else { yield put(plus.setUser(null)) } } catch (error) { console.error('Failed to validate token:', error) yield put(plus.setUser(null)) } } function* saveToken(api: PodloveApiClient, action: ReturnType) { try { yield put(plus.setSaving(true)) const token = action.payload yield api.post(`admin/plus/save_token`, { token }) if (token) { yield call(validateToken, api, token) } else { yield put(plus.setUser(null)) } } catch (error) { console.error('Failed to save token:', error) } finally { yield put(plus.setSaving(false)) } } export default function () { return function* () { yield takeFirst(plus.INIT, plusSaga) } } ================================================ FILE: client/src/sagas/plusFileMigration.sagas.ts ================================================ import { takeFirst } from '../sagas/helper' import { fork, put, select, call } from 'redux-saga/effects' import { PodloveApiClient } from '@lib/api' import { createApi } from '../sagas/api' import * as plusFileMigration from '@store/plusFileMigration.store' import * as auphonic from '@store/auphonic.store' import { selectors } from '@store' import { determineMigrationStatus } from '@lib/statusHelpers' function* plusFileMigrationSaga() { const apiClient: PodloveApiClient = yield createApi() yield fork(initialize, apiClient) } function* initialize(api: PodloveApiClient): Generator { const { result: migrationStatusResult } = yield api.get(`plus/get_migration_status`) yield put( plusFileMigration.setMigrationComplete({ isMigrationComplete: migrationStatusResult.is_complete, }) ) const { result } = yield api.get(`admin/plus/episodes_for_migration`) const episodesWithFiles: plusFileMigration.EpisodeWithFiles[] = result.episodes.map( (episode: any) => { return { episodeName: episode.episode_title, files: episode.files.map((file: any) => { return { name: file.filename, localUrl: file.local_url, remoteUrl: file.plus_url, state: 'init', } }), } } ) yield put(plusFileMigration.setEpisodesWithFiles({ episodesWithFiles })) yield put(plusFileMigration.setTotalState({ totalState: 'ready' })) yield takeFirst(plusFileMigration.START_MIGRATION, startMigration, api) } function* migrateFile( api: PodloveApiClient, episodeIndex: number, fileIndex: number ): Generator { const episodesWithFiles: plusFileMigration.EpisodeWithFiles[] = yield select( selectors.plusFileMigration.episodesWithFiles ) const currentEpisode = episodesWithFiles[episodeIndex] const currentFile = currentEpisode.files[fileIndex] const currentEpisodeName = currentEpisode.episodeName const currentFileName = currentFile.name yield put( plusFileMigration.setCurrentMetadata({ currentEpisodeName: currentEpisodeName, currentFileName: currentFileName, }) ) yield put(plusFileMigration.setFileState({ filename: currentFileName, state: 'in_progress' })) try { const response = yield api.post(`plus/migrate_file`, { filename: currentFileName, file_url: currentFile.localUrl, }) if (response.result === false) { yield put(plusFileMigration.setFileState({ filename: currentFileName, state: 'error' })) } else { yield put(plusFileMigration.setFileState({ filename: currentFileName, state: 'finished' })) // Set auphonic transfer status to completed for UI consistency yield put(auphonic.setPlusTransferStatus({ production_uuid: 'migration', status: 'completed' })) } } catch (error) { yield put(plusFileMigration.setFileState({ filename: currentFileName, state: 'error' })) throw error } } function* startMigration(api: PodloveApiClient): Generator { yield put(plusFileMigration.setTotalState({ totalState: 'in_progress' })) const episodesWithFiles: plusFileMigration.EpisodeWithFiles[] = yield select( selectors.plusFileMigration.episodesWithFiles ) const totalFiles = episodesWithFiles.reduce((acc, episode) => acc + episode.files.length, 0) let migratedFiles = 0 let hasErrors = false const allMigrationTasks = episodesWithFiles.flatMap((episode, episodeIndex) => episode.files.map((file, fileIndex) => ({ episodeIndex, fileIndex })) ) for (const task of allMigrationTasks) { try { yield call(migrateFile, api, task.episodeIndex, task.fileIndex) migratedFiles++ } catch (error) { hasErrors = true console.error('Error migrating file:', error) } finally { const progress = Math.round((migratedFiles / totalFiles) * 100) yield put(plusFileMigration.setProgress({ progress })) } } yield put( plusFileMigration.setTotalState({ totalState: determineMigrationStatus(hasErrors), }) ) if (!hasErrors) { yield api.post('plus/set_migration_complete', {}) } } export default function () { return function* () { yield takeFirst(plusFileMigration.INIT, plusFileMigrationSaga) } } ================================================ FILE: client/src/sagas/podcast.sagas.ts ================================================ import { PodloveApiClient } from '@lib/api' import { fork, put, takeEvery } from 'redux-saga/effects' import { takeFirst } from './helper' import * as lifecycle from '../store/lifecycle.store' import * as podcast from '../store/podcast.store' import { createApi } from './api' import { Action } from 'redux' import { get, isEmpty } from 'lodash' interface PodcastData { title: string | null subtitle: string | null summary: string | null mnemonic: string | null itunes_type: string | null author_name: string | null poster: string | null link: string | null license_name: string | null license_url: string | null } let PODCAST_UPDATE: { [key: string]: any } = {} function* podcastSaga() { const apiClient: PodloveApiClient = yield createApi() yield fork(initialize, apiClient) yield takeEvery(podcast.UPDATE, collectPodcastUpdate) } function* initialize(api: PodloveApiClient) { const { result }: { result: PodcastData } = yield api.get(`podcast`) if (result) { yield put(podcast.set(result)) } } function collectPodcastUpdate(action: Action) { const prop = get(action, ['payload', 'prop']) const value = get(action, ['payload', 'value'], null) if (!prop) { return } PODCAST_UPDATE[prop] = value } function* save(api: PodloveApiClient, action: Action) { if (isEmpty(PODCAST_UPDATE)) { return } yield api.put(`podcast/`, PODCAST_UPDATE) yield put(podcast.saved(PODCAST_UPDATE)) PODCAST_UPDATE = {} } export default function () { return function* () { yield takeFirst(lifecycle.INIT, podcastSaga) } } ================================================ FILE: client/src/sagas/relatedEpisodes.sagas.ts ================================================ import { createApi } from './api' import { PodloveApiClient } from '@lib/api' import { fork, takeEvery } from '@redux-saga/core/effects' import { select, put } from 'redux-saga/effects' import { get } from 'lodash' import { selectors } from '@store' import { PodloveEpisodeList } from '../types/relatedEpisodes.types' import { takeFirst } from './helper' import * as relatedEpisodesStore from '@store/relatedEpisodes.store' type EpisodeApiListItem = { id: number title: string } function* relatedEpisodesSaga(): any { const apiClient: PodloveApiClient = yield createApi() yield fork(initialize, apiClient) yield takeEvery(relatedEpisodesStore.SET_SELECTED_EPISODES, save, apiClient) } function* initialize(api: PodloveApiClient) { const episodeId: string = yield select(selectors.episode.id) const [relatedEpisodes, episodeList ]: [ { result: Number[] }, { result: EpisodeApiListItem[] } ] = yield Promise.all([ api.get(`episodes/${episodeId}/related?status=all`), api.get('episodes?status=all&sort_by=post_id&order_by=asc') ]) const related = get(relatedEpisodes, ['result', 'relatedEpisodes'], []) const episodes = get(episodeList, ['result', 'results'], []).map((episode: EpisodeApiListItem) => ({ episode_id: episode.id, episode_title: episode.title, })) const arr = related.map( (r : any) => (r.related_episode_id)) yield put(relatedEpisodesStore.setSelectedEpisodes(arr)) yield put(relatedEpisodesStore.setEpisodeList(episodes)) } function* save( api: PodloveApiClient, action: {type: string} ) { const episodeId: string = yield select(selectors.episode.id) const selectEpisodes: Number[] = yield select(selectors.relatedEpisodes.selectEpisode) yield api.post(`episodes/${episodeId}/related`, {related: selectEpisodes}) } export default function () { return function* () { yield takeFirst(relatedEpisodesStore.INIT, relatedEpisodesSaga) } } ================================================ FILE: client/src/sagas/shows.sagas.ts ================================================ import { PodloveApiClient } from '@lib/api' import { fork, put, select, takeEvery } from 'redux-saga/effects' import { takeFirst } from './helper' import { __ } from '../plugins/translations' import { createApi } from './api' import { selectors } from '@store' import * as shows from '@store/shows.store' import * as episode from '@store/episode.store' import * as auphonic from '@store/auphonic.store' import { PodloveShow } from '../types/shows.types' import { get } from 'lodash' function* showsSaga(): any { const apiClient: PodloveApiClient = yield createApi() yield fork(initialize, apiClient) } function* initialize(api: PodloveApiClient) { const modules: string[] = yield select(selectors.settings.modules) const { result: showsList }: { result: PodloveShow[] } = yield api.get(`shows`) if (shows) { yield put(shows.set(showsList)) yield takeEvery(episode.UPDATE, maybeUpdateEpisodeNumber) if (modules.includes('automatic_numbering')) { yield takeEvery(shows.SELECT, updateEpisodeNumber, api) } if (modules.includes('auphonic')) { yield takeEvery(shows.SELECT, setAuphonicPreset, showsList) } } } function* setAuphonicPreset(shows: PodloveShow[], action: { type: string; payload: string }) { const show = shows.find((show) => show.slug === action.payload) if (show && show.auphonic_preset) { yield put(auphonic.setPreset(show.auphonic_preset)) } } function* maybeUpdateEpisodeNumber(action: { type: string payload: { prop: string; value: any } }) { const prop = get(action, ['payload', 'prop']) const value = get(action, ['payload', 'value'], null) if (prop === 'show') { yield put(shows.select(value)) } } function* updateEpisodeNumber(api: PodloveApiClient, action: { type: string; payload: string }) { const { result: number }: { result: number } = yield api.get(`shows/next_episode_number`, { query: { show: action.payload }, }) yield put(episode.update({ prop: 'number', value: number.toString() })) } export default function () { return function* () { yield takeFirst(shows.INIT, showsSaga) } } ================================================ FILE: client/src/sagas/transcripts.sagas.ts ================================================ import { fork } from '@redux-saga/core/effects' import { takeEvery, select, put } from 'redux-saga/effects' import { get } from 'lodash' import { selectors } from '@store' import { PodloveTranscript, PodloveTranscriptVoice } from '../types/transcripts.types' import * as transcriptsStore from '@store/transcripts.store' import { createApi } from './api' import { PodloveApiClient } from '@lib/api' import { takeFirst } from './helper' function* transcriptsSaga(): any { const apiClient: PodloveApiClient = yield createApi() yield fork(initialize, apiClient) yield takeEvery(transcriptsStore.IMPORT_TRANSCRIPTS, importTranscripts, apiClient) yield takeEvery(transcriptsStore.UPDATE_VOICE, updateVoice, apiClient) yield takeEvery(transcriptsStore.DELETE_TRANSCRIPTS, deleteTranscripts, apiClient) yield takeEvery(transcriptsStore.IMPORT_ASSET_TRANSCRIPTS, importTranscriptFromAsset, apiClient) } function* initialize(api: PodloveApiClient) { const episodeId: string = yield select(selectors.episode.id) const [transcripts, voices]: [ { result: PodloveTranscript[] }, { result: PodloveTranscriptVoice[] } ] = yield Promise.all([ api.get(`transcripts/${episodeId}`), api.get(`transcripts/voices/${episodeId}`), ]) yield put(transcriptsStore.setTranscripts(get(transcripts, ['result', 'transcript'], []))) yield put(transcriptsStore.setVoices(get(voices, ['result', 'voices'], []))) } function* importTranscripts( api: PodloveApiClient, action: { type: string, payload: string } ) { const episodeId: string = yield select(selectors.episode.id) const { result } = yield api.put(`transcripts/${episodeId}`, { content: action.payload }) if (result) { yield fork(initialize, api) } } function* importTranscriptFromAsset( api: PodloveApiClient, action: { type: string } ) { const episodeId: string = yield select(selectors.episode.id) const { result } = yield api.put(`transcripts/${episodeId}`, { asset: 1}) if (result) { yield fork(initialize, api) } } function* updateVoice(api: PodloveApiClient, action: { type: string, payload: { voice: string; contributor: string } }) { const episodeId: string = yield select(selectors.episode.id) yield api.post(`transcripts/voices/${episodeId}`, { voice: action.payload.voice, contributor_id: action.payload.contributor, }) } function* deleteTranscripts(api: PodloveApiClient) { const episodeId: string = yield select(selectors.episode.id) const { result } = yield api.delete(`transcripts/${episodeId}`) if (result) { yield fork(initialize, api) } } export default function () { return function* () { yield takeFirst(transcriptsStore.INIT, transcriptsSaga) } } ================================================ FILE: client/src/sagas/wordpress.sagas.ts ================================================ import { call, put, select, takeEvery } from 'redux-saga/effects' import * as lifecycleStore from '@store/lifecycle.store' import * as wordpressStore from '@store/wordpress.store' import * as episodeStore from '@store/episode.store' import selectors from '@store/selectors' import { takeFirst, channel } from './helper' import * as wordpress from '../lib/wordpress' import { get } from 'lodash' import { Action } from 'redux' function* wordpressSaga(): any { const generateTitle: boolean = yield select(selectors.settings.autoGenerateEpisodeTitle) if (typeof wordpress.store?.subscribe !== 'undefined') { yield takeEvery(yield call(channel, wordpress.store?.subscribe), wordpressGutenbergUpdate) } if (wordpress.postTitleInput) { yield takeEvery(yield call(channel, wordpress.postTitleListener), postTitleUpdate) } if (generateTitle) { yield takeEvery(episodeStore.SET, updatePostTitle) yield takeEvery(episodeStore.UPDATE, updatePostTitle) } if (wordpress.media) { yield takeEvery(wordpressStore.SELECT_MEDIA_FROM_LIBRARY as any, selectMediaFromLibrary) } } function getFeaturedImageIdFromEditor() { if (!wordpress.store?.select) { return null } const editor = wordpress.store.select('core/editor') if (!editor?.getEditedPostAttribute) { return null } return editor.getEditedPostAttribute('featured_media') } function getTitleFromEditor() { if (!wordpress.store?.select) { return '' } const editor = wordpress.store.select('core/editor') if (!editor?.getEditedPostAttribute) { return '' } return editor.getEditedPostAttribute('title') } function* wordpressGutenbergUpdate() { const title: string = getTitleFromEditor() const imgId: number = getFeaturedImageIdFromEditor() const media = imgId ? wordpress.store.select('core').getMedia(imgId) : null const oldTitle: string | null = yield select(selectors.post.title) const oldMedia: object | null = yield select(selectors.post.featuredMedia) if (oldTitle != title) { yield put( wordpressStore.update({ prop: 'title', value: title, }) ) } if (get(oldMedia, ['id']) != get(media, ['id'])) { yield put( wordpressStore.update({ prop: 'featured_media', value: media, }) ) } } function* postTitleUpdate(title: String) { yield put( wordpressStore.update({ prop: 'title', value: title, }) ) } function* updatePostTitle() { if (!wordpress.postTitleInput) { return } const template: string = yield select(selectors.settings.blogTitleTemplate) if (!template) { return } const title: string = yield select(selectors.episode.title) const episodeNumber: string = yield select(selectors.episode.number) const mnemonic: string = yield select(selectors.podcast.mnemonic) // TODO: get from episode? const seasonNumber: string = '' const padding: number = yield select(selectors.settings.episodeNumberPadding) const newTitle = template .replace('%mnemonic%', mnemonic || '') .replace('%episode_number%', (episodeNumber || '').padStart(padding || 0, '0')) .replace('%season_number%', seasonNumber || '') .replace('%episode_title%', title || '') if (wordpress.postTitleInput.value != newTitle) { wordpress.postTitleInput.value = newTitle yield postTitleUpdate(newTitle) } } function* selectMediaFromLibrary(action: { payload: { onSuccess: Action } }) { const successAction = get(action, ['payload', 'onSuccess']) if (!successAction) { console.warn('Missing successAction') return } const mediaLibrary = wordpress.media({ title: 'Select or Upload Media Of Your Chosen Persuasion', button: { text: 'Use this media', }, multiple: false, // Set to true to allow multiple files to be selected }) const mediaSelectionDialogue: Promise = new Promise((resolve) => { mediaLibrary.on('select', () => { const { url } = mediaLibrary.state().get('selection').first().toJSON() resolve(url) }) }) mediaLibrary.open() try { const url: string = yield mediaSelectionDialogue yield put({ ...successAction, payload: url, }) } finally { } } export default function () { return function* () { yield takeFirst(lifecycleStore.INIT, wordpressSaga) } } ================================================ FILE: client/src/store/admin.store.ts ================================================ import { get } from 'lodash' import { handleActions, createAction } from 'redux-actions' export const INIT = 'podlove/publisher/admin/INIT' export const SET = 'podlove/publisher/admin/SET' export const UPDATE_TYPE = 'podlove/publisher/admin/UPDATE_TYPE' export type State = { bannerHide: boolean | null, type: string | null, feedUrl: string | null } export const initialState: State = { bannerHide: null, type: null, feedUrl: null } export const init = createAction(INIT) export const set = createAction>(SET) export const update_type = createAction(UPDATE_TYPE) export const reducer = handleActions( { [SET]: (state: State, action: { payload: Partial }): State => ({ bannerHide: get(action, ['payload', 'banner_hide'], state.bannerHide), type: get(action, ['payload', 'type'], state.type), feedUrl: get(action, ['payload', 'feedUrl'], state.feedUrl), }), [UPDATE_TYPE]: (state: State, action: { payload: string }): State => ({ ...state, type: action.payload }), }, initialState ) export const selectors = { bannerHide: (state: State) => state.bannerHide, type: (state: State) => state.type, feedUrl: (state: State) => state.feedUrl, } ================================================ FILE: client/src/store/auphonic.store.ts ================================================ import { createAction, handleActions } from 'redux-actions' export type Service = { uuid: string display_name: string email: string incoming: boolean outgoing: boolean type: string } export type Metadata = { album: string append_chapters: boolean artist: string genre: string license: string license_url: string publisher: string subtitle: string summary: string tags: string[] title: string track: string url: string year: string } export type AuphonicChapter = { start: string start_sec: number start_output: string start_output_sec: number title: string image?: string url?: string } export type Production = { uuid: string status: number status_string: string error_message: string error_status: any | null warning_message: string warning_status: any | null edit_page: string status_page: string waveform_image: string image: string | null metadata: Metadata creation_time: string change_time?: string | null is_multitrack: boolean multi_input_files: AuphonicInputFile[] input_file: string chapters: AuphonicChapter[] output_basename: string output_files?: AuphonicOutputFile[] outgoing_services: object[] algorithms: object speech_recognition: object service: string | null } export type PlusTransferFile = { success: boolean | null // null indicates pending/processing state status: 'pending' | 'processing' | 'completed' | 'failed' // explicit status for UI filename: string download_url: string message: string } export type AuphonicInputFile = { id: string input_file: string input_filetype: string input_length: number service: string | null type: 'multitrack' | string offset: number input_channels: number input_bitrate: number input_samplerate: number algorithms: AuphonicTrackAlgorithms } export type AuphonicOutputFile = { format: string bitrate: string suffix: string ending: string filename: string mono_mixdown: boolean split_on_chapters: boolean outgoing_services: string[] } export type AuphonicTrackAlgorithms = { backforeground: string denoise: boolean denoiseamount: number filtering: boolean } export type Preset = Production & { preset_name: string } export type AudioTrack = { identifier: string identifier_new: string fileSelection: any input_file_name: string filtering: boolean noise_and_hum_reduction: boolean fore_background: string track_gain: string save_state: 'new' | 'unchanged' | 'edited' | 'deleted' } export type FileSelection = { urlValue: string | null fileValue: string | null currentServiceSelection: string | null fileSelection: string | null } export type PlusTransferStatus = { production_uuid: string status: 'waiting_for_webhook' | 'in_progress' | 'completed' | 'completed_with_errors' | 'failed' files?: PlusTransferFile[] errors?: string } export type State = { token: string | null production: Production | null productions: Production[] | null presets: Preset[] | null preset: string | null services: Service[] service_files: object tracks: AudioTrack[] file_selections: object current_file_selection: string | null is_saving: boolean is_initializing: boolean publish_when_done: boolean plus_transfer_status: PlusTransferStatus | null } export const initialState: State = { token: null, production: null, productions: [], presets: [], preset: null, services: [], service_files: {}, tracks: [], file_selections: {}, current_file_selection: null, is_saving: false, is_initializing: true, publish_when_done: false, plus_transfer_status: null, } export const INIT = 'podlove/publisher/auphonic/INIT' export const INIT_DONE = 'podlove/publisher/auphonic/INIT_DONE' export const SET_TOKEN = 'podlove/publisher/auphonic/SET_TOKEN' export const SET_PRODUCTION = 'podlove/publisher/auphonic/SET_PRODUCTION' export const SET_PRODUCTIONS = 'podlove/publisher/auphonic/SET_PRODUCTIONS' export const SET_SERVICES = 'podlove/publisher/auphonic/SET_SERVICES' export const CREATE_PRODUCTION = 'podlove/publisher/auphonic/CREATE_PRODUCTION' export const CREATE_MULTITRACK_PRODUCTION = 'podlove/publisher/auphonic/CREATE_MULTITRACK_PRODUCTION' export const SAVE_PRODUCTION = 'podlove/publisher/auphonic/SAVE_PRODUCTION' export const START_PRODUCTION = 'podlove/publisher/auphonic/START_PRODUCTION' export const DESELECT_PRODUCTION = 'podlove/publisher/auphonic/DESELECT_PRODUCTION' export const SELECT_SERVICE = 'podlove/publisher/auphonic/SELECT_SERVICE' export const SET_SERVICE_FILES = 'podlove/publisher/auphonic/SET_SERVICE_FILES' export const SELECT_TRACKS = 'podlove/publisher/auphonic/SELECT_TRACKS' export const ADD_TRACK = 'podlove/publisher/auphonic/ADD_TRACK' export const REMOVE_TRACK = 'podlove/publisher/auphonic/REMOVE_TRACK' export const UPDATE_TRACK = 'podlove/publisher/auphonic/UPDATE_TRACK' export const SET_PRESETS = 'podlove/publisher/auphonic/SET_PRESETS' export const SET_PRESET = 'podlove/publisher/auphonic/SET_PRESET' export const UPDATE_FILE_SELECTION = 'podlove/publisher/auphonic/UPDATE_FILE_SELECTION' export const START_POLLING = 'podlove/publisher/auphonic/START_POLLING' export const STOP_POLLING = 'podlove/publisher/auphonic/STOP_POLLING' export const START_SAVING = 'podlove/publisher/auphonic/START_SAVING' export const STOP_SAVING = 'podlove/publisher/auphonic/STOP_SAVING' export const UPDATE_WEBHOOK = 'podlove/publisher/auphonic/UPDATE_WEBHOOK' export const SET_PLUS_TRANSFER_STATUS = 'podlove/publisher/auphonic/SET_PLUS_TRANSFER_STATUS' export const TRIGGER_PLUS_TRANSFER = 'podlove/publisher/auphonic/TRIGGER_PLUS_TRANSFER' export const LOAD_PLUS_TRANSFER_STATUS = 'podlove/publisher/auphonic/LOAD_PLUS_TRANSFER_STATUS' export const init = createAction(INIT) export const initDone = createAction(INIT_DONE) export const setToken = createAction(SET_TOKEN) // Productions export const setProduction = createAction(SET_PRODUCTION) export const deselectProduction = createAction(DESELECT_PRODUCTION) export const setProductions = createAction(SET_PRODUCTIONS) export const createProduction = createAction(CREATE_PRODUCTION) export const createMultitrackProduction = createAction(CREATE_MULTITRACK_PRODUCTION) export const saveProduction = createAction>(SAVE_PRODUCTION) export const startProduction = createAction>(START_PRODUCTION) // Presets export const setPresets = createAction(SET_PRESETS) export const setPreset = createAction(SET_PRESET) // Files & File Services export const setServices = createAction(SET_SERVICES) export const setServiceFiles = createAction<{ uuid: string; files: string[] | null }>(SET_SERVICE_FILES) export const selectService = createAction(SELECT_SERVICE) export const updateFileSelection = createAction<{ key: string; prop: string; value: string | File | null }>(UPDATE_FILE_SELECTION) // Tracks export const selectTracks = createAction(SELECT_TRACKS) export const addTrack = createAction(ADD_TRACK) export const removeTrack = createAction(REMOVE_TRACK) export const updateTrack = createAction<{ track: Partial; index: number }>(UPDATE_TRACK) // Polling export const startPolling = createAction(START_POLLING) export const stopPolling = createAction(STOP_POLLING) // Saving State export const startSaving = createAction(START_SAVING) export const stopSaving = createAction(STOP_SAVING) // Webhook export const updateWebhook = createAction(UPDATE_WEBHOOK) export const setPlusTransferStatus = createAction(SET_PLUS_TRANSFER_STATUS) export const triggerPlusTransfer = createAction<{ production_uuid: string }>(TRIGGER_PLUS_TRANSFER) export const loadPlusTransferStatus = createAction<{ production_uuid: string }>(LOAD_PLUS_TRANSFER_STATUS) export const reducer = handleActions( { [INIT_DONE]: (state: State): State => ({ ...state, is_initializing: false, }), [UPDATE_FILE_SELECTION]: ( state: State, action: { type: string; payload: { key: string; prop: string; value: string | null } } ): State => { // FIXME: mark track as modified when selection changes return { ...state, current_file_selection: action.payload.key, file_selections: { ...state.file_selections, [action.payload.key]: { //@ts-ignore ...state.file_selections[action.payload.key], [action.payload.prop]: action.payload.value, }, }, } }, [ADD_TRACK]: (state: State, action: any): State => { const id = `Track ${state.tracks.length + 1}` return { ...state, tracks: [ ...state.tracks, { identifier: id, identifier_new: id, fileSelection: null, input_file_name: '', filtering: true, noise_and_hum_reduction: false, fore_background: 'auto', track_gain: '0', save_state: 'new', }, ], } }, [REMOVE_TRACK]: (state: State, action: { type: string; payload: string }): State => { return { ...state, tracks: state.tracks.filter((track, index) => track.identifier != action.payload), } }, [UPDATE_TRACK]: ( state: State, action: { type: string; payload: { track: Partial; index: number } } ): State => { // save_state: 'new' | 'unchanged' | 'edited' | 'deleted' const track_save_state = ( track: Partial, track_payload: Partial ): Partial => { const old_state = track.save_state if (old_state == 'new') { return { save_state: 'new' } } return { save_state: 'edited' } } const tracks = state.tracks.reduce( (result: AudioTrack[], track, trackIndex) => [ ...result, trackIndex === action.payload.index ? { ...track, ...action.payload.track, ...track_save_state(track, action.payload.track), } : track, ], [] ) return { ...state, tracks } }, [SET_SERVICE_FILES]: ( state: State, action: { payload: { uuid: string; files: string[] | null } } ): State => { const { uuid, files } = action.payload return { ...state, service_files: { ...state.service_files, [uuid]: files }, } }, [SET_SERVICES]: (state: State, action: { payload: Service[] }): State => ({ ...state, services: action.payload, }), [SET_PRESETS]: (state: State, action: { payload: Preset[] | null }): State => ({ ...state, presets: action.payload, }), [SET_PRODUCTIONS]: (state: State, action: { payload: Production[] | null }): State => ({ ...state, productions: action.payload, }), [SET_PRODUCTION]: (state: State, action: { payload: Production | null }): State => { const production = action.payload const file_selections = () => { if (production?.is_multitrack) { return ( production?.multi_input_files?.reduce((acc, file, index) => { let service = file.service if (!service) { if (file.input_file.substring(0, 4) == 'http') { service = 'url' } else { service = 'file' } } return { ...acc, [`${production?.uuid}_t${index}`]: { currentServiceSelection: service, fileSelection: file.service ? file.input_file : null, urlValue: service == 'url' ? file.input_file : null, fileValue: null, } as FileSelection, } }, {}) || {} ) } else { // single track let service = production?.service const input_file = production?.input_file if (!service) { if (input_file?.substring(0, 4) == 'http') { service = 'url' } else { service = 'file' } } return { [`${production?.uuid}`]: { currentServiceSelection: service, fileSelection: service ? input_file : null, urlValue: service == 'url' ? input_file : null, fileValue: null, } as FileSelection, } } } return { ...state, production: production, file_selections: file_selections(), tracks: action.payload?.multi_input_files?.reduce((acc, file) => { return [ ...acc, { identifier: file.id, identifier_new: file.id, filtering: file.algorithms?.filtering, noise_and_hum_reduction: file.algorithms?.denoise, fore_background: file.algorithms?.backforeground, input_file_name: file.input_file, save_state: 'unchanged', } as AudioTrack, ] }, [] as AudioTrack[]) || [], } }, [DESELECT_PRODUCTION]: (state: State): State => ({ ...state, production: null, tracks: [], file_selections: [], current_file_selection: null, }), [SET_PRESET]: (state: State, action: { payload: string | null }): State => ({ ...state, preset: action.payload, }), [SET_TOKEN]: (state: State, action: { payload: string | null }): State => ({ ...state, token: action.payload, }), [START_SAVING]: (state: State, action: { payload: null }): State => ({ ...state, is_saving: true, }), [STOP_SAVING]: (state: State, action: { payload: null }): State => ({ ...state, is_saving: false, }), [UPDATE_WEBHOOK]: (state: State, action: { payload: boolean }): State => ({ ...state, publish_when_done: action.payload, }), [SET_PLUS_TRANSFER_STATUS]: (state: State, action: { payload: PlusTransferStatus }): State => { return { ...state, plus_transfer_status: action.payload, } }, }, initialState ) const chaptersPayload = (chapters: AuphonicChapter[] | undefined) => { if (!chapters) { return [] } return chapters.map((chapter) => { let payload: { start: string title: string image?: string url?: string } = { start: chapter.start, title: chapter.title, } if (chapter.image) { payload.image = chapter.image } if (chapter.url) { payload.url = chapter.url } return payload }) } const outputFilesPayload = (output_files: AuphonicOutputFile[] | undefined) => { if (!output_files) { return [] } return output_files.map((file) => { return { format: file.format, bitrate: file.bitrate, suffix: file.suffix, ending: file.ending, filename: file.filename, mono_mixdown: file.mono_mixdown, split_on_chapters: file.split_on_chapters, outgoing_services: file.outgoing_services, } }) } const productionPayload = (state: State) => { const production = state.production return { uuid: production?.uuid, metadata: production?.metadata, input_file: production?.input_file, chapters: chaptersPayload(production?.chapters), output_files: outputFilesPayload(production?.output_files), output_basename: production?.output_basename, outgoing_services: production?.outgoing_services, algorithms: production?.algorithms, speech_recognition: production?.speech_recognition, } } export const selectors = { token: (state: State) => state.token, production: (state: State) => state.production, productionId: (state: State) => state.production?.uuid, productions: (state: State) => state.productions, presets: (state: State) => state.presets, preset: (state: State) => state.preset, productionPayload, services: (state: State) => state.services, incomingServices: (state: State) => state.services.filter((s: Service) => s.incoming), outgoingServices: (state: State) => state.services.filter((s: Service) => s.outgoing), serviceFiles: (state: State) => state.service_files, tracks: (state: State) => state.tracks, fileSelections: (state: State) => state.file_selections, currentFileSelection: (state: State) => state.current_file_selection, isSaving: (state: State) => state.is_saving, isInitializing: (state: State) => state.is_initializing, publishWhenDone: (state: State) => state.publish_when_done, plusTransferStatus: (state: State) => state.plus_transfer_status?.status, plusTransferFiles: (state: State) => state.plus_transfer_status?.files, plusTransferErrors: (state: State) => state.plus_transfer_status?.errors, } ================================================ FILE: client/src/store/chapters.store.ts ================================================ import { get } from 'lodash' import { handleActions, createAction } from 'redux-actions' import { PodloveChapter } from '../types/chapters.types' export type State = { chapters: PodloveChapter[] selected: number | null } export const initialState: State = { chapters: [], selected: null, } export const INIT = 'podlove/publisher/chapter/INIT' export const UPDATE = 'podlove/publisher/chapter/UPDATE' export const SELECT = 'podlove/publisher/chapter/SELECT' export const REMOVE = 'podlove/publisher/chapter/REMOVE' export const ADD = 'podlove/publisher/chapter/ADD' export const PARSE = 'podlove/publisher/chapter/PARSE' export const PARSED = 'podlove/publisher/chapter/PARSED' export const SET = 'podlove/publisher/chapter/SET' export const DOWNLOAD = 'podlove/publisher/chapter/DOWNLOAD' export const SELECT_IMAGE = 'podlove/publisher/chapter/SELECT_IMAGE' export const SET_IMAGE = 'podlove/publisher/chapter/SET_IMAGE' export const init = createAction(INIT) export const update = createAction<{ chapter: Partial; index: number }>(UPDATE) export const select = createAction(SELECT) export const remove = createAction(REMOVE) export const add = createAction(ADD) export const parse = createAction(PARSE) export const parsed = createAction(PARSED) export const set = createAction(SET) export const download = createAction<'psc' | 'mp4'>(DOWNLOAD) export const selectImage = createAction(SELECT_IMAGE) export const setImage = createAction(SET_IMAGE) export const reducer = handleActions( { [PARSED]: (state: State, action: typeof parsed): State => ({ ...state, selected: null, chapters: get(action, ['payload'], []) as PodloveChapter[], }), [SET]: (state: State, action: typeof set): State => ({ ...state, selected: null, chapters: get(action, ['payload'], []) as PodloveChapter[], }), [UPDATE]: ( state: State, action: { type: string; payload: { chapter: Partial; index: number } } ): State => { let selectedChapterIndex = selectedIndex(state) const selectedChapter = selected(state) // update chapter const chapters = state.chapters.reduce( (result: PodloveChapter[], chapter, chapterIndex) => [ ...result, chapterIndex === action.payload.index ? { ...chapter, ...action.payload.chapter } : chapter, ], [] ) // sort chapters by time const sortedChapters = chapters.sort((a, b) => a.start - b.start) // update selected index const newSelectedIndex = sortedChapters.findIndex( (chapter) => selectedChapter && chapter.title == selectedChapter.title ) return { ...state, chapters: sortedChapters, selected: newSelectedIndex >= 0 ? newSelectedIndex : selectedChapterIndex, } }, [SELECT]: (state: State, action: { type: string; payload: number }): State => ({ ...state, selected: action.payload, }), [ADD]: (state: State): State => ({ ...state, chapters: [ ...state.chapters, { start: get(state, ['chapters', state.chapters.length - 1, 'start'], 0), title: '', href: '', image: '', }, ], }), [REMOVE]: (state: State, action: { type: string; payload: number }): State => ({ ...state, selected: null, chapters: state.chapters.filter((chapter, index) => index !== action.payload), }), [SET_IMAGE]: (state: State, action: { type: string; payload: string }): State => ({ ...state, chapters: state.chapters.map((chapter, index) => { if (index !== state.selected) { return chapter } return { ...chapter, image: action.payload } }) }) }, initialState ) const chapters = (state: State) => state.chapters const selectedIndex = (state: State) => state.selected const selected = (state: State) => state.selected !== null ? get(state, ['chapters', state.selected], null) : null export const selectors = { chapters, selectedIndex, selected, } ================================================ FILE: client/src/store/contributors.store.ts ================================================ import { handleActions, createAction } from 'redux-actions' import { PodloveContributor, PodloveGroup, PodloveRole } from '../types/contributors.types'; export type State = { contributors: PodloveContributor[], roles: PodloveRole[], groups: PodloveGroup[] } export const initialState: State = { contributors: [], roles: [], groups: [], }; export const INIT = 'podlove/publisher/contributors/INIT' export const SET_CONTRIBUTORS = 'podlove/publisher/contributors/SET_CONTRIBUTORS' export const SET_ROLES = 'podlove/publisher/contributors/SET_ROLES' export const SET_GROUPS = 'podlove/publisher/contributors/SET_GROUPS' export const ADD_CONTRIBUTOR = 'podlove/publisher/contributors/ADD' export const init = createAction(INIT); export const setContributors = createAction(SET_CONTRIBUTORS); export const setRoles = createAction(SET_ROLES); export const setGroups = createAction(SET_GROUPS); export const addContributor = createAction>(ADD_CONTRIBUTOR); export const reducer = handleActions({ [SET_CONTRIBUTORS]: (state: State, action: { payload: PodloveContributor[] }): State => ({ ...state, contributors: action.payload }), [SET_ROLES]: (state: State, action: { payload: PodloveRole[] }): State => ({ ...state, roles: action.payload }), [SET_GROUPS]: (state: State, action: { payload: PodloveGroup[] }): State => ({ ...state, groups: action.payload }), [ADD_CONTRIBUTOR]: (state: State, action: { payload: PodloveContributor }): State => ({ ...state, contributors: [ ...state.contributors, action.payload ] }) }, initialState); export const selectors = { contributors: (state: State) => state.contributors, roles: (state: State) => state.roles, groups: (state: State) => state.groups, } ================================================ FILE: client/src/store/episode.store.ts ================================================ import { get, pick } from 'lodash' import { handleActions } from 'redux-actions' import { createAction } from 'redux-actions' import Timestamp from '@lib/timestamp' import * as lifecycle from './lifecycle.store' import { PodloveEpisodeContribution } from '../types/episode.types' import { PodloveContributor } from '../types/contributors.types' import { arrayMove } from '@lib/array' export const INIT = 'podlove/publisher/episode/INIT' export const UPDATE = 'podlove/publisher/episode/UPDATE' export const QUICKSAVE = 'podlove/publisher/episode/QUICKSAVE' export const SAVED = 'podlove/publisher/episode/SAVED' export const SLUG_CHANGED = 'podlove/publisher/episode/SLUG_CHANGED' export const SET = 'podlove/publisher/episode/SET' export const SET_POSTER = 'podlove/publisher/episode/SET_POSTER' export const SELECT_POSTER = 'podlove/publisher/episode/SELECT_POSTER' export const MOVE_CONTRIBUTION_UP = 'podlove/publisher/episode/MOVE_CONTRIBUTION_UP' export const MOVE_CONTRIBUTION_DOWN = 'podlove/publisher/episode/MOVE_CONTRIBUTION_DOWN' export const DELETE_CONTRIBUTION = 'podlove/publisher/episode/DELETE_CONTRIBUTION' export const UPDATE_CONTRIBUTION = 'podlove/publisher/episode/UPDATE_CONTRIBUTION' export const ADD_CONTRIBUTION = 'podlove/publisher/episode/ADD_CONTRIBUTION' export const CREATE_CONTRIBUTION = 'podlove/publisher/episode/CREATE_CONTRIBUTION' export type State = { id: string | null slug: string | null slug_frozen: boolean duration: number | null number: string | null title: string | null subtitle: string | null summary: string | null type: 'full' | 'trailer' | 'bonus' | null episode_poster: string | null poster: string | null mnemonic: string | null explicit: boolean | null auphonic_production_id: 'string' | null is_auphonic_production_running: boolean auphonic_webhook_config: object | null auphonic_plus_transfer_change_time: string | null soundbite_start: number | null soundbite_duration: number | null soundbite_title: string | null license_name: string | null license_url: string | null contributions: PodloveEpisodeContribution[] show: string | null } export const initialState: State = { id: null, slug: null, slug_frozen: false, duration: null, number: null, subtitle: null, title: null, summary: null, type: null, episode_poster: null, poster: null, mnemonic: null, explicit: null, auphonic_production_id: null, is_auphonic_production_running: false, auphonic_webhook_config: null, auphonic_plus_transfer_change_time: null, soundbite_start: null, soundbite_duration: null, soundbite_title: null, contributions: [], license_name: null, license_url: null, show: null, } export const update = createAction<{ prop: string; value: any }>(UPDATE) export const quicksave = createAction(QUICKSAVE) export const init = createAction(INIT) export const selectPoster = createAction(SELECT_POSTER) export const set = createAction<{ slug?: string slug_frozen?: boolean number?: string duration?: string title?: string subtitle?: string summary?: string episode_poster?: string poster?: string mnemonic?: string explicit?: boolean contributions?: object auphonic_production_id?: string is_auphonic_production_running?: boolean auphonic_webhook_config?: object auphonic_plus_transfer_change_time?: string soundbite_start?: string soundbite_duration?: string soundbite_title?: string license_name?: string license_url?: string show?: string }>(SET) export const moveContributionUp = createAction(MOVE_CONTRIBUTION_UP) export const moveContributionDown = createAction(MOVE_CONTRIBUTION_DOWN) export const deleteContribution = createAction(DELETE_CONTRIBUTION) export const updateContribution = createAction(UPDATE_CONTRIBUTION) export const addContribution = createAction>(ADD_CONTRIBUTION) export const createContribution = createAction(CREATE_CONTRIBUTION) export const saved = createAction(SAVED) export const slugChanged = createAction(SLUG_CHANGED) export const reducer = handleActions( { [lifecycle.INIT]: (state: State, action: typeof lifecycle.init): State => ({ ...state, id: get(action, ['payload', 'episode', 'id'], null), duration: Timestamp.fromString(get(action, ['payload', 'episode', 'duration'], null)).totalMs, }), [UPDATE]: (state: State, action: typeof update): State => { const prop = get(action, ['payload', 'prop']) const value = get(action, ['payload', 'value'], null) // FIXME: finish implementation once episode saga supports it const simple = [ 'title', 'subtitle', 'summary', 'duration', 'slug', 'slug_frozen', 'auphonic_webhook_config', 'is_auphonic_production_running', 'auphonic_plus_transfer_change_time', 'soundbite_start', 'soundbite_duration', 'soundbite_title', 'license_name', 'license_url', 'show', 'number', 'episode_poster', 'poster', 'explicit', ] if (simple.includes(prop)) { return { ...state, [prop]: value } } else { console.debug('todo', prop, value) return { ...state } } }, [SET]: (state: State, action: typeof update): State => ({ ...state, slug: get(action, ['payload', 'slug'], state.slug), slug_frozen: get(action, ['payload', 'slug_frozen'], state.slug_frozen), number: get(action, ['payload', 'number'], state.number), duration: get(action, ['payload', 'duration'], state.duration), title: get(action, ['payload', 'title_clean'], state.title), subtitle: get(action, ['payload', 'subtitle'], state.subtitle), summary: get(action, ['payload', 'summary'], state.summary), type: get(action, ['payload', 'type'], state.type), episode_poster: get(action, ['payload', 'episode_poster'], state.episode_poster), poster: get(action, ['payload', 'poster'], state.poster), mnemonic: get(action, ['payload', 'mnemonic'], state.mnemonic), explicit: get(action, ['payload', 'explicit'], state.explicit), auphonic_production_id: get( action, ['payload', 'auphonic_production_id'], state.auphonic_production_id ), is_auphonic_production_running: get( action, ['payload', 'is_auphonic_production_running'], state.is_auphonic_production_running ), auphonic_plus_transfer_change_time: get( action, ['payload', 'auphonic_plus_transfer_change_time'], state.auphonic_plus_transfer_change_time ), auphonic_webhook_config: get( action, ['payload', 'auphonic_webhook_config'], state.auphonic_webhook_config ), soundbite_start: get(action, ['payload', 'soundbite_start'], state.soundbite_start), soundbite_duration: get(action, ['payload', 'soundbite_duration'], state.soundbite_duration), soundbite_title: get(action, ['payload', 'soundbite_title'], state.soundbite_title), license_name: get(action, ['payload', 'license_name'], state.license_name), license_url: get(action, ['payload', 'license_url'], state.license_url), contributions: get(action, ['payload', 'contributions'], state.contributions), show: get(action, ['payload', 'show'], state.show), }), [MOVE_CONTRIBUTION_UP]: (state: State, action: typeof moveContributionUp): State => { const index = state.contributions.findIndex( (contribution) => contribution.position === get(action, ['payload', 'position']) ) if (index < 1) { return state } return { ...state, contributions: arrayMove(state.contributions, index, index - 1).map( (contribution, position) => ({ ...contribution, position }) ), } }, [MOVE_CONTRIBUTION_DOWN]: (state: State, action: typeof moveContributionDown): State => { const index = state.contributions.findIndex( (contribution) => contribution.position === get(action, ['payload', 'position']) ) if (index > state.contributions.length) { return state } return { ...state, contributions: arrayMove(state.contributions, index, index + 1).map( (contribution, position) => ({ ...contribution, position }) ), } }, [DELETE_CONTRIBUTION]: (state: State, action: typeof deleteContribution): State => ({ ...state, contributions: state.contributions .filter(({ position }) => get(action, ['payload', 'position']) !== position) .map((contribution, position) => ({ ...contribution, position })), }), [UPDATE_CONTRIBUTION]: (state: State, action: typeof updateContribution): State => ({ ...state, contributions: state.contributions.map((contribution) => { if (contribution.contributor_id !== get(action, ['payload', 'contributor_id'])) { return contribution } return pick(get(action, ['payload'], {}), [ 'id', 'contributor_id', 'role_id', 'group_id', 'position', 'comment', ]) }), }), [ADD_CONTRIBUTION]: (state: State, action: typeof addContribution) => ({ ...state, contributions: [ ...state.contributions, { id: null, contributor_id: get(action, ['payload', 'id'], null), role_id: null, group_id: null, position: state.contributions.length, comment: null, }, ], }), }, initialState ) export const selectors = { id: (state: State) => state.id, slug: (state: State) => state.slug, slugFrozen: (state: State) => state.slug_frozen, duration: (state: State) => state.duration, number: (state: State) => state.number, title: (state: State) => state.title, subtitle: (state: State) => state.subtitle, summary: (state: State) => state.summary, type: (state: State) => state.type, poster: (state: State) => state.poster, episodePoster: (state: State) => state.episode_poster, mnemonic: (state: State) => state.mnemonic, explicit: (state: State) => state.explicit, auphonicProductionId: (state: State) => state.auphonic_production_id, isAuphonicProductionRunning: (state: State) => state.is_auphonic_production_running, auphonicWebhookConfig: (state: State) => state.auphonic_webhook_config, auphonicPlusTransferChangeTime: (state: State) => state.auphonic_plus_transfer_change_time, soundbite_start: (state: State) => state.soundbite_start, soundbite_duration: (state: State) => state.soundbite_duration, soundbite_title: (state: State) => state.soundbite_title, license_name: (state: State) => state.license_name, license_url: (state: State) => state.license_url, contributions: (state: State) => state.contributions, currentShow: (state: State) => state.show, } ================================================ FILE: client/src/store/index.ts ================================================ declare global { interface Window { __REDUX_DEVTOOLS_EXTENSION_COMPOSE__: Function } } import { createStore, applyMiddleware, compose, Store } from 'redux' import createSagaMiddleware from 'redux-saga' import selectors from './selectors' import reducers from './reducers' import { State as LifecycleState } from './lifecycle.store' import { State as ChaptersState } from './chapters.store' import { State as episodeState } from './episode.store' import { State as runtimeState } from './runtime.store' import { State as postState } from './post.store' import { State as transcriptsState } from './transcripts.store' import { State as contributorsState } from './contributors.store' import { State as settingsState } from './settings.store' import { State as podcastState } from './podcast.store' import { State as auphonicState } from './auphonic.store' import { State as progressState } from './progress.store' import { State as mediafilesState } from './mediafiles.store' import { State as plusFileMigrationState } from './plusFileMigration.store' import { State as relatedEpisodesState } from './relatedEpisodes.store' import { State as showsState } from './shows.store' import { State as adminState } from './admin.store' import { State as plusState } from './plus.store' import lifecycleSaga from '../sagas/lifecycle.sagas' import podcastSaga from '../sagas/podcast.sagas' import notificationSaga from '../sagas/notification.saga' import chaptersSaga from '../sagas/chapters.sagas' import transcriptsSaga from '../sagas/transcripts.sagas' import contributorsSaga from '../sagas/contributors.sagas' import wordpressSaga from '../sagas/wordpress.sagas' import episodeSaga from '../sagas/episode.sagas' import auphonicSaga from '../sagas/auphonic.sagas' import mediafilesSaga from '../sagas/mediafiles.sagas' import relatedEpisodesSaga from '../sagas/relatedEpisodes.sagas' import showsSaga from '../sagas/shows.sagas' import adminSaga from '../sagas/admin.sagas' import plusFileMigrationSaga from '../sagas/plusFileMigration.sagas' import plusSaga from '../sagas/plus.sagas' export interface State { lifecycle: LifecycleState chapters: ChaptersState episode: episodeState runtime: runtimeState post: postState transcripts: transcriptsState contributors: contributorsState settings: settingsState podcast: podcastState auphonic: auphonicState progress: progressState mediafiles: mediafilesState relatedEpisodes: relatedEpisodesState shows: showsState admin: adminState plusFileMigration: plusFileMigrationState plus: plusState } const sagas = createSagaMiddleware() const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose export const store: Store = createStore(reducers, composeEnhancers(applyMiddleware(sagas))) sagas.run(lifecycleSaga()) sagas.run(notificationSaga()) sagas.run(chaptersSaga()) sagas.run(transcriptsSaga()) sagas.run(contributorsSaga()) sagas.run(wordpressSaga()) sagas.run(episodeSaga()) sagas.run(podcastSaga()) sagas.run(auphonicSaga()) sagas.run(mediafilesSaga()) sagas.run(relatedEpisodesSaga()) sagas.run(showsSaga()) sagas.run(adminSaga()) sagas.run(plusFileMigrationSaga()) sagas.run(plusSaga()) export { selectors, sagas } ================================================ FILE: client/src/store/lifecycle.store.ts ================================================ import { handleActions, createAction } from 'redux-actions' export type State = { saved: boolean; changes: boolean; bootstrapped: boolean; } export const INIT = 'podlove/publisher/INIT' export const READY = 'podlove/publisher/READY' export const SAVE = 'podlove/publisher/SAVE' export const ERROR = 'podlove/publisher/ERROR' export const init = createAction<{ api?: { base: string; nonce: string; }, post?: { id: string; }, episode?: { id: string; duration?: string; } }>(INIT); export const save = createAction(SAVE) export const error = createAction(ERROR) export const ready = createAction(READY) export const initialState: State = { saved: false, changes: false, bootstrapped: false }; export const reducer = handleActions({ [INIT]: (state: State): State => ({ ...state, bootstrapped: true }) }, initialState); export const selectors = { bootstrapped: (state: State) => state.bootstrapped } ================================================ FILE: client/src/store/mediafiles.store.ts ================================================ import { createAction, handleActions } from 'redux-actions' export type MediaFile = { asset_id: number asset: string url: string size: number enable: boolean is_verifying: boolean } export type FileInfo = { file: File originalName: string newName: string fileExists?: boolean } export type State = { is_initializing: boolean slug_autogeneration_enabled: boolean files: MediaFile[] selectedFiles: FileInfo[] } export const initialState: State = { is_initializing: true, slug_autogeneration_enabled: false, files: [], selectedFiles: [], } export const INIT = 'podlove/publisher/mediafiles/INIT' export const INIT_DONE = 'podlove/publisher/mediafiles/INIT_DONE' export const SET = 'podlove/publisher/mediafiles/SET' export const UPDATE = 'podlove/publisher/mediafiles/UPDATE' export const ENABLE = 'podlove/publisher/mediafiles/ENABLE' export const DISABLE = 'podlove/publisher/mediafiles/DISABLE' export const VERIFY = 'podlove/publisher/mediafiles/VERIFY' export const VERIFY_ALL = 'podlove/publisher/mediafiles/VERIFY_ALL' export const UPLOAD_INTENT = 'podlove/publisher/mediafiles/UPLOAD_INTENT' export const PLUS_UPLOAD_INTENT = 'podlove/publisher/mediafiles/PLUS_UPLOAD_INTENT' export const SET_UPLOAD_URL = 'podlove/publisher/mediafiles/SET_UPLOAD_URL' export const ENABLE_SLUG_AUTOGEN = 'podlove/publisher/mediafiles/ENABLE_SLUG_AUTOGEN' export const DISABLE_SLUG_AUTOGEN = 'podlove/publisher/mediafiles/DISABLE_SLUG_AUTOGEN' export const FILE_SELECTED = 'podlove/publisher/mediafiles/FILE_SELECTED' export const SET_FILE_INFO = 'podlove/publisher/mediafiles/SET_FILE_INFO' export const ADD_SELECTED_FILES = 'podlove/publisher/mediafiles/ADD_SELECTED_FILES' export const REMOVE_SELECTED_FILE = 'podlove/publisher/mediafiles/REMOVE_SELECTED_FILE' export const UNFREEZE_SLUG = 'podlove/publisher/mediafiles/UNFREEZE_SLUG' export const init = createAction(INIT) export const initDone = createAction(INIT_DONE) export const set = createAction(SET) export const update = createAction>(UPDATE) export const enable = createAction(ENABLE) export const disable = createAction(DISABLE) export const verify = createAction(VERIFY) export const verifyAll = createAction(VERIFY_ALL) export const uploadIntent = createAction(UPLOAD_INTENT) export const plusUploadIntent = createAction(PLUS_UPLOAD_INTENT) export const setUploadUrl = createAction(SET_UPLOAD_URL) export const enableSlugAutogen = createAction(ENABLE_SLUG_AUTOGEN) export const disableSlugAutogen = createAction(DISABLE_SLUG_AUTOGEN) export const fileSelected = (files: File[], episodeSlug: string | null) => ({ type: FILE_SELECTED, payload: { files, episodeSlug }, }) export const addSelectedFiles = createAction(ADD_SELECTED_FILES) export const removeSelectedFile = createAction(REMOVE_SELECTED_FILE) export const unfreezeSlug = createAction(UNFREEZE_SLUG) // TODO: enable revalidates I think? export const reducer = handleActions( { [INIT_DONE]: (state: State): State => ({ ...state, is_initializing: false, }), [SET]: (state: State, action: { type: string; payload: MediaFile[] }): State => ({ ...state, files: action.payload, }), [UPDATE]: (state: State, action: { type: string; payload: Partial }): State => ({ ...state, files: state.files.reduce( (result: MediaFile[], file) => [ ...result, file.asset_id == action.payload.asset_id ? { ...file, ...action.payload } : file, ], [] ), }), [ENABLE]: (state: State, action: { type: string; payload: number }): State => ({ ...state, files: state.files.reduce( (result: MediaFile[], file) => [ ...result, file.asset_id == action.payload ? { ...file, enable: true } : file, ], [] ), }), [DISABLE]: (state: State, action: { type: string; payload: number }): State => ({ ...state, files: state.files.reduce( (result: MediaFile[], file) => [ ...result, file.asset_id == action.payload ? { ...file, enable: false } : file, ], [] ), }), [ENABLE_SLUG_AUTOGEN]: (state: State): State => ({ ...state, slug_autogeneration_enabled: true, }), [DISABLE_SLUG_AUTOGEN]: (state: State): State => ({ ...state, slug_autogeneration_enabled: false, }), [SET_FILE_INFO]: ( state: State, action: { type: string payload: FileInfo[] } ): State => ({ ...state, selectedFiles: action.payload, }), [ADD_SELECTED_FILES]: ( state: State, action: { type: string payload: FileInfo[] } ): State => ({ ...state, selectedFiles: [...state.selectedFiles, ...action.payload], }), [REMOVE_SELECTED_FILE]: ( state: State, action: { type: string payload: string } ): State => ({ ...state, selectedFiles: state.selectedFiles.filter(f => f.newName !== action.payload), }), }, initialState ) export const selectors = { isInitializing: (state: State) => state.is_initializing, slugAutogenerationEnabled: (state: State) => state.slug_autogeneration_enabled, files: (state: State) => state.files, selectedFiles: (state: State) => state.selectedFiles, } export const actions = { fileSelected: (files: File[], episodeSlug: string | null) => ({ type: FILE_SELECTED, payload: { files, episodeSlug }, }), setSelectedFiles: (selectedFiles: FileInfo[]) => ({ type: SET_FILE_INFO, payload: selectedFiles, }), addSelectedFiles: (selectedFiles: FileInfo[]) => ({ type: ADD_SELECTED_FILES, payload: selectedFiles, }), removeSelectedFile: (fileName: string) => ({ type: REMOVE_SELECTED_FILE, payload: fileName, }), } ================================================ FILE: client/src/store/notification.store.ts ================================================ import { handleActions, createAction } from 'redux-actions' export const NOTIFY = 'podlove/publisher/NOTIFY' export const notify = createAction<{ type: 'success' | 'warning' | 'error', message: string; }>(NOTIFY) export type State = { } export const initialState: State = { }; export const reducer = handleActions({ }, initialState); export const selectors = { } ================================================ FILE: client/src/store/plus.store.ts ================================================ import { handleActions, createAction } from 'redux-actions' import * as lifecycle from './lifecycle.store' export type PlusFeatures = { fileStorage: boolean feedProxy: boolean } export type State = { features: PlusFeatures token: string user: { email: string } | null isLoading: boolean isSaving: boolean } export const initialState: State = { features: { fileStorage: false, feedProxy: false, }, token: '', user: null, isLoading: true, isSaving: false, } export const INIT = 'podlove/publisher/plus/INIT' export const SET_FEATURE = 'podlove/publisher/plus/SET_FEATURE' export const GET_TOKEN = 'podlove/publisher/plus/GET_TOKEN' export const SET_TOKEN = 'podlove/publisher/plus/SET_TOKEN' export const SET_USER = 'podlove/publisher/plus/SET_USER' export const SAVE_TOKEN = 'podlove/publisher/plus/SAVE_TOKEN' export const SET_LOADING = 'podlove/publisher/plus/SET_LOADING' export const SET_SAVING = 'podlove/publisher/plus/SET_SAVING' export const init = createAction(INIT) export const setFeature = createAction<{ feature: string; value: boolean }>(SET_FEATURE) export const getToken = createAction(GET_TOKEN) export const setToken = createAction(SET_TOKEN) export const setUser = createAction<{ email: string } | null>(SET_USER) export const saveToken = createAction(SAVE_TOKEN) export const setLoading = createAction(SET_LOADING) export const setSaving = createAction(SET_SAVING) export const reducer = handleActions( { [lifecycle.INIT]: (state: State, action: typeof lifecycle.init): State => ({ ...state, }), [SET_FEATURE]: (state: State, action: ReturnType): State => ({ ...state, features: { ...state.features, [action.payload.feature]: action.payload.value, }, }), [SET_TOKEN]: (state: State, action: ReturnType): State => ({ ...state, token: action.payload, }), [SET_USER]: (state: State, action: ReturnType): State => ({ ...state, user: action.payload, }), [SET_LOADING]: (state: State, action: ReturnType): State => ({ ...state, isLoading: action.payload, }), [SET_SAVING]: (state: State, action: ReturnType): State => ({ ...state, isSaving: action.payload, }), }, initialState ) export const selectors = { features: (state: State) => state.features, token: (state: State) => state.token, user: (state: State) => state.user, isLoading: (state: State) => state.isLoading, isSaving: (state: State) => state.isSaving, } ================================================ FILE: client/src/store/plusFileMigration.store.ts ================================================ import { handleActions, createAction } from 'redux-actions' import * as lifecycle from './lifecycle.store' type UploadState = 'init' | 'ready' | 'in_progress' | 'finished' | 'error' type UploadFile = { name: string localUrl: string remoteUrl: string state: UploadState } export type EpisodeWithFiles = { episodeName: string files: UploadFile[] } export type State = { totalState: UploadState progress: number currentEpisodeName: string currentFileName: string episodesWithFiles: EpisodeWithFiles[] isMigrationComplete: boolean showMigrationToolManually: boolean } export const initialState: State = { totalState: 'init', progress: 0, currentEpisodeName: '', currentFileName: '', episodesWithFiles: [], isMigrationComplete: false, showMigrationToolManually: false, } export const INIT = 'podlove/publisher/plusFileMigration/INIT' export const SET_EPISODES_WITH_FILES = 'podlove/publisher/plusFileMigration/SET_EPISODES_WITH_FILES' export const SET_TOTAL_STATE = 'podlove/publisher/plusFileMigration/SET_TOTAL_STATE' export const START_MIGRATION = 'podlove/publisher/plusFileMigration/START_MIGRATION' export const SET_CURRENT_METADATA = 'podlove/publisher/plusFileMigration/SET_CURRENT_METADATA' export const SET_FILE_STATE = 'podlove/publisher/plusFileMigration/SET_FILE_STATE' export const SET_PROGRESS = 'podlove/publisher/plusFileMigration/SET_PROGRESS' export const SET_MIGRATION_COMPLETE = 'podlove/publisher/plusFileMigration/SET_MIGRATION_COMPLETE' export const TOGGLE_MIGRATION_TOOL_MANUALLY = 'podlove/publisher/plusFileMigration/TOGGLE_MIGRATION_TOOL_MANUALLY' export const init = createAction(INIT) export const setEpisodesWithFiles = createAction<{ episodesWithFiles: EpisodeWithFiles[] }>(SET_EPISODES_WITH_FILES) export const setTotalState = createAction<{ totalState: UploadState }>(SET_TOTAL_STATE) export const startMigration = createAction(START_MIGRATION) export const setCurrentMetadata = createAction<{ currentEpisodeName: string; currentFileName: string }>(SET_CURRENT_METADATA) export const setFileState = createAction<{ filename: string; state: UploadState }>(SET_FILE_STATE) export const setProgress = createAction<{ progress: number }>(SET_PROGRESS) export const setMigrationComplete = createAction<{ isMigrationComplete: boolean }>(SET_MIGRATION_COMPLETE) export const toggleMigrationToolManually = createAction(TOGGLE_MIGRATION_TOOL_MANUALLY) export const reducer = handleActions( { [lifecycle.INIT]: (state: State, action: typeof lifecycle.init): State => ({ ...state, }), [SET_EPISODES_WITH_FILES]: ( state: State, action: ReturnType ): State => ({ ...state, episodesWithFiles: action.payload.episodesWithFiles, }), [SET_TOTAL_STATE]: (state: State, action: ReturnType): State => ({ ...state, totalState: action.payload.totalState, }), [SET_CURRENT_METADATA]: ( state: State, action: ReturnType ): State => ({ ...state, currentEpisodeName: action.payload.currentEpisodeName, currentFileName: action.payload.currentFileName, }), [SET_FILE_STATE]: (state: State, action: ReturnType): State => ({ ...state, episodesWithFiles: state.episodesWithFiles.map((episode) => ({ ...episode, files: episode.files.map((file) => ({ ...file, state: file.name === action.payload.filename ? action.payload.state : file.state, })), })), }), [SET_PROGRESS]: (state: State, action: ReturnType): State => ({ ...state, progress: action.payload.progress, }), [SET_MIGRATION_COMPLETE]: ( state: State, action: ReturnType ): State => ({ ...state, isMigrationComplete: action.payload.isMigrationComplete, }), [TOGGLE_MIGRATION_TOOL_MANUALLY]: (state: State): State => ({ ...state, showMigrationToolManually: !state.showMigrationToolManually, }), }, initialState ) export const selectors = { totalState: (state: State) => state.totalState, progress: (state: State) => state.progress, currentEpisodeName: (state: State) => state.currentEpisodeName, currentFileName: (state: State) => state.currentFileName, episodesWithFiles: (state: State) => state.episodesWithFiles, isMigrationComplete: (state: State) => state.isMigrationComplete, showMigrationToolManually: (state: State) => state.showMigrationToolManually, } ================================================ FILE: client/src/store/podcast.store.ts ================================================ import { get } from 'lodash' import { handleActions } from 'redux-actions' import { createAction } from 'redux-actions' export const INIT = 'podlove/publisher/podcast/INIT' export const SET = 'podlove/publisher/podcast/SET' export const SAVED = 'podlove/publisher/podcasr/SAVED' export const UPDATE = 'podlove/publisher/podcast/UPDATE' export type State = { title: string | null subtitle: string | null summary: string | null mnemonic: string | null itunes_type: string | null author_name: string | null poster: string | null link: string | null license_name: string | null license_url: string | null } export const initialState: State = { title: null, subtitle: null, summary: null, mnemonic: null, itunes_type: null, author_name: null, poster: null, link: null, license_name: null, license_url: null } export const init = createAction(INIT) export const set = createAction>(SET) export const saved = createAction(SAVED) export const update = createAction<{ prop: string; value: any }>(UPDATE) export const reducer = handleActions( { [SET]: (state: State, action: { payload: Partial }): State => ({ title: get(action , ['payload', 'title'], state.title), subtitle: get(action , ['payload', 'subtitle'], state.subtitle), summary: get(action , ['payload', 'summary'], state.summary), mnemonic: get(action , ['payload', 'mnemonic'], state.mnemonic), itunes_type: get(action , ['payload', 'itunes_type'], state.itunes_type), author_name: get(action , ['payload', 'author_name'], state.author_name), poster: get(action , ['payload', 'poster'], state.poster), link: get(action , ['payload', 'link'], state.link), license_name: get(action , ['payload', 'license_name'], state.license_name), license_url: get(action , ['payload', 'license_url'], state.license_url), }), [UPDATE]: (state: State, action: typeof update): State => { const prop = get(action, ['payload', 'prop']) const value = get(action, ['payload', 'value'], null) const simple = [ 'title', 'subtitle', 'summary', 'author_name', 'podcast_email', 'funding_url', 'funding_label', 'license_name', 'license_url', ] if (simple.includes(prop)) { return { ...state, [prop]: value } } else { console.debug('todo', prop, value) return { ...state } } }, }, initialState ) export const selectors = { title: (state: State) => state.title, subtitle: (state: State) => state.subtitle, summary: (state: State) => state.summary, mnemonic: (state: State) => state.mnemonic, itunesType: (state: State) => state.itunes_type, author: (state: State) => state.author_name, poster: (state: State) => state.poster, link: (state: State) => state.link, license_name: (state: State) => state.license_name, license_url: (state: State) => state.license_url, } ================================================ FILE: client/src/store/post.store.ts ================================================ import { get } from 'lodash' import { handleActions } from 'redux-actions' import { INIT, init } from './lifecycle.store' import * as wordpressStore from './wordpress.store' export type State = { id: string | null title: string | null featured_media: object | null } export const initialState: State = { id: null, title: null, featured_media: null, } export const reducer = handleActions( { [INIT]: (state: State, action: typeof init): State => ({ ...state, id: get(action, ['payload', 'post', 'id'], null), title: get(action, ['payload', 'post', 'title'], null), featured_media: get(action, ['payload', 'post', 'featured_media'], null), }), [wordpressStore.UPDATE]: ( state: State, action: { type: string; payload: { prop: string; value: any } } ): State => { const prop = get(action, ['payload', 'prop']) const value = get(action, ['payload', 'value'], null) const allowed_props = ['title', 'featured_media'] if (allowed_props.includes(prop)) { return { ...state, [prop]: value } } else { return { ...state } } }, }, initialState ) export const selectors = { id: (state: State) => state.id, title: (state: State) => state.title, featured_media: (state: State) => state.featured_media, } ================================================ FILE: client/src/store/progress.store.ts ================================================ import { createAction, handleActions } from 'redux-actions' export type ProgressStatus = 'init' | 'in_progress' | 'finished' | 'error' export type ProgressItem = { progress: number status: ProgressStatus message?: string } export type State = { [key: string]: ProgressItem } export type SetProgressPayload = { key: string progress: number status?: ProgressStatus message?: string } export type SetProgressStatusPayload = { key: string status: ProgressStatus message?: string } const initialState: State = {} export const SET_PROGRESS = 'podlove/publisher/progress/SET_PROGRESS' export const SET_PROGRESS_STATUS = 'podlove/publisher/progress/SET_PROGRESS_STATUS' export const RESET_PROGRESS = 'podlove/publisher/progress/RESET_PROGRESS' export const setProgress = createAction(SET_PROGRESS) export const setProgressStatus = createAction(SET_PROGRESS_STATUS) export const resetProgress = createAction(RESET_PROGRESS) export const reducer = handleActions( { [SET_PROGRESS]: (state: State, action: { type: string; payload: SetProgressPayload }) => { const { key, progress, status, message } = action.payload const currentItem = state[key] || { progress: 0, status: 'init' } return { ...state, [key]: { ...currentItem, progress, ...(status && { status }), ...(message !== undefined && { message }), }, } }, [SET_PROGRESS_STATUS]: ( state: State, action: { type: string; payload: SetProgressStatusPayload } ) => { const { key, status, message } = action.payload const currentItem = state[key] || { progress: 0, status: 'init' } return { ...state, [key]: { ...currentItem, status, ...(message !== undefined && { message }), }, } }, [RESET_PROGRESS]: (state: State, action: { type: string; payload: string }) => { const newState = { ...state } delete newState[action.payload] return newState }, }, initialState ) const progress = (state: State, key: string): number => state[key]?.progress ?? 0 const status = (state: State, key: string): ProgressStatus => state[key]?.status ?? 'init' const message = (state: State, key: string): string | undefined => state[key]?.message export const selectors = { progress, status, message, } ================================================ FILE: client/src/store/reducers.ts ================================================ import { combineReducers } from 'redux' import * as lifecycleStore from './lifecycle.store' import * as chaptersStore from './chapters.store' import * as episodeStore from './episode.store' import * as runtimeStore from './runtime.store' import * as postStore from './post.store' import * as transcriptsStore from './transcripts.store' import * as contributorsStore from './contributors.store' import * as settingsStore from './settings.store' import * as podcastStore from './podcast.store' import * as auphonicStore from './auphonic.store' import * as progressStore from './progress.store' import * as mediafilesStore from './mediafiles.store' import * as relatedEpisodesStore from './relatedEpisodes.store' import * as showsStore from './shows.store' import * as adminStore from './admin.store' import * as plusFileMigrationStore from './plusFileMigration.store' import * as plusStore from './plus.store' export default combineReducers({ lifecycle: lifecycleStore.reducer, chapters: chaptersStore.reducer, episode: episodeStore.reducer, runtime: runtimeStore.reducer, post: postStore.reducer, transcripts: transcriptsStore.reducer, contributors: contributorsStore.reducer, settings: settingsStore.reducer, podcast: podcastStore.reducer, auphonic: auphonicStore.reducer, progress: progressStore.reducer, mediafiles: mediafilesStore.reducer, relatedEpisodes: relatedEpisodesStore.reducer, shows: showsStore.reducer, admin: adminStore.reducer, plusFileMigration: plusFileMigrationStore.reducer, plus: plusStore.reducer, }) ================================================ FILE: client/src/store/relatedEpisodes.store.ts ================================================ import { handleActions, createAction } from "redux-actions"; import { PodloveEpisodeList } from "../types/relatedEpisodes.types"; import * as lifecycle from './lifecycle.store' export type State = { episodeList: PodloveEpisodeList[] selectEpisodes: Number[] } export const initialState: State = { episodeList: [], selectEpisodes: [] } export const INIT = 'podlove/publisher/relatedEpisodes/INIT' export const SET_EPISODE_LIST = 'podlove/publisher/relatedEpisodes/SET_EPISODE_LIST' export const SET_SELECTED_EPISODES = 'podlove/publisher/relatedEpisodes/SET_SELECTED_EPISODES' export const UPDATE_RELATED_EPISODES = 'podlove/publisher/relatedEpisodes/UPDATE_RELATED_EPISODES' export const init = createAction(INIT); export const setEpisodeList = createAction(SET_EPISODE_LIST); export const setSelectedEpisodes = createAction(SET_SELECTED_EPISODES); export const updateRelatedEpisodes = createAction(UPDATE_RELATED_EPISODES); export const reducer = handleActions( { [lifecycle.INIT]: (state: State, action: typeof lifecycle.init): State => ({ ...state, }), [SET_EPISODE_LIST]: (state: State, action: { payload: PodloveEpisodeList[] }): State => ({ ...state, episodeList: action.payload, }), [SET_SELECTED_EPISODES]: (state: State, action: { payload: Number[]}): State => ({ ...state, selectEpisodes: action.payload, }), }, initialState ) export const selectors = { episodeList: (state: State) => state.episodeList, selectEpisodes: (state: State) => state.selectEpisodes, } ================================================ FILE: client/src/store/runtime.store.ts ================================================ import { get } from 'lodash'; import { handleActions, } from 'redux-actions' import { INIT, init } from './lifecycle.store'; export type State = { baseUrl: string | null; api: { nonce: string | null; base: string | null; auth: string | null; bearer: string | null; } } export const initialState: State = { baseUrl: null, api: { nonce: null, base: null, auth: null, bearer: null } }; export const reducer = handleActions({ [INIT]: (state: State, action: typeof init): State => ({ ...state, baseUrl: get(action, ['payload', 'baseUrl'], null), api: { base: get(action, ['payload', 'api', 'base'], null), nonce: get(action, ['payload', 'api', 'nonce'], null), auth: get(action, ['payload', 'api', 'auth'], null), bearer: get(action, ['payload', 'api', 'bearer'], null), } }) }, initialState); export const selectors = { baseUrl: (state: State) => state.baseUrl, nonce: (state: State) => state.api.nonce, base: (state: State) => state.api.base, auth: (state: State) => state.api.auth, bearer: (state: State) => state.api.bearer, } ================================================ FILE: client/src/store/selectors.ts ================================================ import { createSelector } from 'reselect' import { State } from './index' import * as lifecycleStore from './lifecycle.store' import * as chaptersStore from './chapters.store' import * as episodeStore from './episode.store' import * as runtimeStore from './runtime.store' import * as postStore from './post.store' import * as transcriptsStore from './transcripts.store' import * as contributorsStore from './contributors.store' import * as settingsStore from './settings.store' import * as podcastStore from './podcast.store' import * as plusFileMigrationStore from './plusFileMigration.store' import * as plusStore from './plus.store' import * as auphonicStore from './auphonic.store' import * as progressStore from './progress.store' import * as mediafilesStore from './mediafiles.store' import * as relatedEpisodesStore from './relatedEpisodes.store' import * as showsStore from './shows.store' import * as adminStore from './admin.store' const root = { lifecycle: (state: State) => state.lifecycle, chapters: (state: State) => state.chapters, podcast: (state: State) => state.podcast, episode: (state: State) => state.episode, runtime: (state: State) => state.runtime, post: (state: State) => state.post, transcripts: (state: State) => state.transcripts, contributors: (state: State) => state.contributors, auphonic: (state: State) => state.auphonic, progress: (state: State) => state.progress, mediafiles: (state: State) => state.mediafiles, settings: (state: State) => state.settings, relatedEpisodes: (state: State) => state.relatedEpisodes, shows: (state: State) => state.shows, admin: (state: State) => state.admin, plusFileMigration: (state: State) => state.plusFileMigration, plus: (state: State) => state.plus, } const lifecycle = { bootstrapped: createSelector(root.lifecycle, lifecycleStore.selectors.bootstrapped), } const auphonic = { token: createSelector(root.auphonic, auphonicStore.selectors.token), productionId: createSelector(root.auphonic, auphonicStore.selectors.productionId), productions: createSelector(root.auphonic, auphonicStore.selectors.productions), production: createSelector(root.auphonic, auphonicStore.selectors.production), presets: createSelector(root.auphonic, auphonicStore.selectors.presets), preset: createSelector(root.auphonic, auphonicStore.selectors.preset), productionPayload: createSelector(root.auphonic, auphonicStore.selectors.productionPayload), incomingServices: createSelector(root.auphonic, auphonicStore.selectors.incomingServices), outgoingServices: createSelector(root.auphonic, auphonicStore.selectors.outgoingServices), serviceFiles: createSelector(root.auphonic, auphonicStore.selectors.serviceFiles), tracks: createSelector(root.auphonic, auphonicStore.selectors.tracks), fileSelections: createSelector(root.auphonic, auphonicStore.selectors.fileSelections), currentFileSelection: createSelector(root.auphonic, auphonicStore.selectors.currentFileSelection), isSaving: createSelector(root.auphonic, auphonicStore.selectors.isSaving), isInitializing: createSelector(root.auphonic, auphonicStore.selectors.isInitializing), publishWhenDone: createSelector(root.auphonic, auphonicStore.selectors.publishWhenDone), plusTransferStatus: createSelector(root.auphonic, auphonicStore.selectors.plusTransferStatus), plusTransferFiles: createSelector(root.auphonic, auphonicStore.selectors.plusTransferFiles), plusTransferErrors: createSelector(root.auphonic, auphonicStore.selectors.plusTransferErrors), } const progress = { progress: createSelector( [root.progress, (_state: any, key: string) => key], progressStore.selectors.progress ), status: createSelector( [root.progress, (_state: any, key: string) => key], progressStore.selectors.status ), message: createSelector( [root.progress, (_state: any, key: string) => key], progressStore.selectors.message ), } const podcast = { title: createSelector(root.podcast, podcastStore.selectors.title), subtitle: createSelector(root.podcast, podcastStore.selectors.subtitle), summary: createSelector(root.podcast, podcastStore.selectors.summary), mnemonic: createSelector(root.podcast, podcastStore.selectors.mnemonic), itunesType: createSelector(root.podcast, podcastStore.selectors.itunesType), author: createSelector(root.podcast, podcastStore.selectors.author), poster: createSelector(root.podcast, podcastStore.selectors.poster), link: createSelector(root.podcast, podcastStore.selectors.link), license_name: createSelector(root.podcast, podcastStore.selectors.license_name), license_url: createSelector(root.podcast, podcastStore.selectors.license_url), } const chapters = { list: createSelector(root.chapters, chaptersStore.selectors.chapters), selected: createSelector(root.chapters, chaptersStore.selectors.selected), selectedIndex: createSelector(root.chapters, chaptersStore.selectors.selectedIndex), } const contributors = { contributors: createSelector(root.contributors, contributorsStore.selectors.contributors), roles: createSelector(root.contributors, contributorsStore.selectors.roles), groups: createSelector(root.contributors, contributorsStore.selectors.groups), } const episode = { id: createSelector(root.episode, episodeStore.selectors.id), slug: createSelector(root.episode, episodeStore.selectors.slug), slugFrozen: createSelector(root.episode, episodeStore.selectors.slugFrozen), duration: createSelector(root.episode, episodeStore.selectors.duration), number: createSelector(root.episode, episodeStore.selectors.number), title: createSelector(root.episode, episodeStore.selectors.title), subtitle: createSelector(root.episode, episodeStore.selectors.subtitle), summary: createSelector(root.episode, episodeStore.selectors.summary), type: createSelector(root.episode, episodeStore.selectors.type), poster: createSelector(root.episode, episodeStore.selectors.poster), episodePoster: createSelector(root.episode, episodeStore.selectors.episodePoster), effectivePoster: createSelector( createSelector(root.episode, episodeStore.selectors.episodePoster), createSelector(root.episode, episodeStore.selectors.poster), podcast.poster, (episodePoster, poster, podcastPoster) => episodePoster || poster || podcastPoster ), mnemonic: createSelector(root.episode, episodeStore.selectors.mnemonic), explicit: createSelector(root.episode, episodeStore.selectors.explicit), soundbite_start: createSelector(root.episode, episodeStore.selectors.soundbite_start), soundbite_duration: createSelector(root.episode, episodeStore.selectors.soundbite_duration), soundbite_title: createSelector(root.episode, episodeStore.selectors.soundbite_title), auphonicProductionId: createSelector(root.episode, episodeStore.selectors.auphonicProductionId), isAuphonicProductionRunning: createSelector( root.episode, episodeStore.selectors.isAuphonicProductionRunning ), auphonicWebhookConfig: createSelector(root.episode, episodeStore.selectors.auphonicWebhookConfig), auphonicPlusTransferChangeTime: createSelector( root.episode, episodeStore.selectors.auphonicPlusTransferChangeTime ), license_name: createSelector(root.episode, episodeStore.selectors.license_name), license_url: createSelector(root.episode, episodeStore.selectors.license_url), contributions: createSelector( createSelector(root.episode, episodeStore.selectors.contributions), contributors.contributors, (contributions, list) => { const result = contributions.map((contribution) => ({ ...contribution, ...(contribution.contributor_id ? list.find(({ id }) => id.toString() === contribution.contributor_id.toString()) : {}), })) return result } ), currentShow: createSelector(root.episode, episodeStore.selectors.currentShow), } const mediafiles = { isInitializing: createSelector(root.mediafiles, mediafilesStore.selectors.isInitializing), files: createSelector(root.mediafiles, mediafilesStore.selectors.files), selectedFiles: createSelector(root.mediafiles, mediafilesStore.selectors.selectedFiles), slugAutogenerationEnabled: createSelector( root.mediafiles, mediafilesStore.selectors.slugAutogenerationEnabled ), } const runtime = { baseUrl: createSelector(root.runtime, runtimeStore.selectors.baseUrl), nonce: createSelector(root.runtime, runtimeStore.selectors.nonce), base: createSelector(root.runtime, runtimeStore.selectors.base), auth: createSelector(root.runtime, runtimeStore.selectors.auth), bearer: createSelector(root.runtime, runtimeStore.selectors.bearer), } const post = { id: createSelector(root.post, postStore.selectors.id), title: createSelector(root.post, postStore.selectors.title), featuredMedia: createSelector(root.post, postStore.selectors.featured_media), } const transcripts = { list: createSelector(root.transcripts, transcriptsStore.selectors.transcripts), voices: createSelector(root.transcripts, transcriptsStore.selectors.voices), } const settings = { autoGenerateEpisodeTitle: createSelector( root.settings, settingsStore.selectors.autoGenerateEpisodeTitle ), blogTitleTemplate: createSelector(root.settings, settingsStore.selectors.blogTitleTemplate), episodeNumberPadding: createSelector(root.settings, settingsStore.selectors.episodeNumberPadding), mediaFileBaseUri: createSelector(root.settings, settingsStore.selectors.mediaFileBaseUri), imageAsset: createSelector(root.settings, settingsStore.selectors.imageAsset), enableEpisodeExplicit: createSelector( root.settings, settingsStore.selectors.enableEpisodeExplicit ), enablePlusStorage: createSelector(root.settings, settingsStore.selectors.enablePlusStorage), modules: createSelector(root.settings, settingsStore.selectors.modules), } const relatedEpisodes = { episodeList: createSelector(root.relatedEpisodes, relatedEpisodesStore.selectors.episodeList), selectEpisode: createSelector( root.relatedEpisodes, relatedEpisodesStore.selectors.selectEpisodes ), } const shows = { list: createSelector(root.shows, showsStore.selectors.shows), } const admin = { bannerHide: createSelector(root.admin, adminStore.selectors.bannerHide), type: createSelector(root.admin, adminStore.selectors.type), feedUrl: createSelector(root.admin, adminStore.selectors.feedUrl), } const plusFileMigration = { totalState: createSelector(root.plusFileMigration, plusFileMigrationStore.selectors.totalState), progress: createSelector(root.plusFileMigration, plusFileMigrationStore.selectors.progress), currentEpisodeName: createSelector( root.plusFileMigration, plusFileMigrationStore.selectors.currentEpisodeName ), currentFileName: createSelector( root.plusFileMigration, plusFileMigrationStore.selectors.currentFileName ), episodesWithFiles: createSelector( root.plusFileMigration, plusFileMigrationStore.selectors.episodesWithFiles ), isMigrationComplete: createSelector( root.plusFileMigration, plusFileMigrationStore.selectors.isMigrationComplete ), showMigrationToolManually: createSelector( root.plusFileMigration, plusFileMigrationStore.selectors.showMigrationToolManually ), } const plus = { features: createSelector(root.plus, plusStore.selectors.features), token: createSelector(root.plus, plusStore.selectors.token), user: createSelector(root.plus, plusStore.selectors.user), isLoading: createSelector(root.plus, plusStore.selectors.isLoading), isSaving: createSelector(root.plus, plusStore.selectors.isSaving), } export default { lifecycle, podcast, chapters, episode, runtime, post, transcripts, contributors, settings, auphonic, progress, mediafiles, relatedEpisodes, shows, admin, plusFileMigration, plus, } ================================================ FILE: client/src/store/settings.store.ts ================================================ import { get } from 'lodash' import { handleActions } from 'redux-actions' import { init, INIT } from './lifecycle.store' type TrackingMode = 'ptm_analytics' type TrackingWindow = 'daily' export interface State { plus: { storage_enabled: boolean | null } metadata: { enable_episode_explicit: boolean | null enable_episode_license: boolean | null enable_episode_recording_date: boolean | null } tracking: { mode: TrackingMode | null window: TrackingWindow | null } website: { blog_title_template: string | null custom_episode_slug: string | null enable_generated_blog_post_title: boolean | null episode_archive: boolean | null episode_archive_slug: string | null episode_number_padding: string | null feeds_skip_redirect: boolean | null hide_wp_feed_discovery: boolean | null landing_page: string | null merge_episodes: boolean | null ssl_verify_peer: boolean | null url_template: string | null use_post_permastruct: boolean | null } assets: { image: null | 'podcast-cover' | 'post-thumbnail' | 'manual' chapter: null | 'none' | 'manual' transcript: null | 'manual' } media: { base_uri: null | string } modules: string[] } export const initialState: State = { plus: { storage_enabled: null, }, metadata: { enable_episode_explicit: null, enable_episode_license: null, enable_episode_recording_date: null, }, tracking: { mode: null, window: null, }, website: { blog_title_template: null, custom_episode_slug: null, enable_generated_blog_post_title: null, episode_archive: null, episode_archive_slug: null, episode_number_padding: null, feeds_skip_redirect: null, hide_wp_feed_discovery: null, landing_page: null, merge_episodes: null, ssl_verify_peer: null, url_template: null, use_post_permastruct: null, }, assets: { image: null, chapter: null, transcript: null, }, media: { base_uri: null, }, modules: [], } const normalizeAssignmentImage = ( input: string ): null | 'podcast-cover' | 'post-thumbnail' | 'manual' => { switch (input) { case '0': return 'podcast-cover' case 'post-thumbnail': return 'post-thumbnail' case 'manual': return 'manual' default: return null } } const normalizeAssignmentChapter = (input: string): null | 'none' | 'manual' => { switch (input) { case '0': return 'none' case 'manual': return 'manual' default: return null } } export const reducer = handleActions( { [INIT]: (state: State, action: typeof init): State => ({ ...state, plus: { storage_enabled: get(action, ['payload', 'plus', 'storage_enabled'], null) === true, }, metadata: { enable_episode_explicit: get( action, ['payload', 'expert_settings', 'metadata', 'enable_episode_explicit'], null ) === '1', enable_episode_license: get( action, ['payload', 'expert_settings', 'metadata', 'enable_episode_license'], null ) === '1', enable_episode_recording_date: get( action, ['payload', 'expert_settings', 'metadata', 'enable_episode_recording_date'], null ) === '1', }, tracking: { mode: get(action, ['payload', 'expert_settings', 'tracking', 'mode'], null), window: get(action, ['payload', 'expert_settings', 'tracking', 'mode'], null), }, media: { base_uri: get(action, ['payload', 'media', 'base_uri'], null), }, website: { blog_title_template: get( action, ['payload', 'expert_settings', 'website', 'blog_title_template'], null ), custom_episode_slug: get( action, ['payload', 'expert_settings', 'website', 'custom_episode_slug'], null ), enable_generated_blog_post_title: get( action, ['payload', 'expert_settings', 'website', 'enable_generated_blog_post_title'], null ) === 'on', episode_archive: get(action, ['payload', 'expert_settings', 'website', 'episode_archive'], null) === 'on', episode_archive_slug: get( action, ['payload', 'expert_settings', 'website', 'episode_archive_slug'], null ), episode_number_padding: get( action, ['payload', 'expert_settings', 'website', 'episode_number_padding'], null ), feeds_skip_redirect: get(action, ['payload', 'expert_settings', 'website', 'feeds_skip_redirect'], null) === 'on', hide_wp_feed_discovery: get(action, ['payload', 'expert_settings', 'website', 'hide_wp_feed_discovery'], null) === 'on', landing_page: get(action, ['payload', 'expert_settings', 'website', 'landing_page'], null), merge_episodes: get(action, ['payload', 'expert_settings', 'website', 'merge_episodes'], null) === 'on', ssl_verify_peer: get(action, ['payload', 'expert_settings', 'website', 'ssl_verify_peer'], null) === 'on', url_template: get(action, ['payload', 'expert_settings', 'website', 'url_template'], null), use_post_permastruct: get(action, ['payload', 'expert_settings', 'website', 'use_post_permastruct'], null) === 'on', }, assets: { image: normalizeAssignmentImage(get(action, ['payload', 'assignments', 'image'], null)), chapter: normalizeAssignmentChapter( get(action, ['payload', 'assignments', 'chapter'], null) ), transcript: get(action, ['payload', 'assignments', 'transcript'], null), }, modules: get(action, ['payload', 'modules']), }), }, initialState ) export const selectors = { autoGenerateEpisodeTitle: (state: State) => state.website.enable_generated_blog_post_title, blogTitleTemplate: (state: State) => state.website.blog_title_template, episodeNumberPadding: (state: State) => state.website.episode_number_padding, imageAsset: (state: State) => state.assets.image, enableEpisodeExplicit: (state: State) => state.metadata.enable_episode_explicit, enablePlusStorage: (state: State) => state.plus.storage_enabled, mediaFileBaseUri: (state: State) => state.media.base_uri, modules: (state: State) => state.modules, } ================================================ FILE: client/src/store/shows.store.ts ================================================ import { PodloveShow } from '../types/shows.types' import { get } from 'lodash' import { handleActions, createAction } from 'redux-actions' export const INIT = 'podlove/publisher/shows/INIT' export const SET = 'podlove/publisher/shows/SET' export const SELECT = 'podlove/publisher/shows/SELECT' export const init = createAction(INIT) export const set = createAction(SET) export const select = createAction(SELECT) export type State = { shows: PodloveShow[] } export const initialState: State = { shows: [], } export const reducer = handleActions( { [SET]: (state: State, action: typeof set): State => ({ ...state, shows: get(action, ['payload'], []) as PodloveShow[], }), }, initialState ) export const selectors = { shows: (state: State) => state.shows, } ================================================ FILE: client/src/store/transcripts.store.ts ================================================ import { handleActions, createAction } from 'redux-actions' import { PodloveTranscript, PodloveTranscriptVoice } from '../types/transcripts.types' export const INIT = 'podlove/publisher/transcript/INIT' export const SET_TRANSCRIPTS = 'podlove/publisher/transcript/SET_TRANSCRIPTS' export const SET_VOICES = 'podlove/publisher/transcript/SET_VOICES' export const UPDATE_VOICE = 'podlove/publisher/transcript/UPDATE_VOICE' export const IMPORT_TRANSCRIPTS = 'podlove/publisher/transcript/IMPORT_TRANSCRIPTS' export const IMPORT_ASSET_TRANSCRIPTS = 'podlove/publisher/transcript/IMPORT_ASSET_TRANSCRIPTS' export const DELETE_TRANSCRIPTS = 'podlove/publisher/transcript/DELETE_TRANSCRIPTS' export const init = createAction(INIT) export const setTranscripts = createAction(SET_TRANSCRIPTS) export const setVoices = createAction(SET_VOICES) export const updateVoice = createAction<{ voice: string; contributor: string }>(UPDATE_VOICE) export const importTranscripts = createAction(IMPORT_TRANSCRIPTS) export const importTranscriptFromAsset = createAction(IMPORT_ASSET_TRANSCRIPTS) export const deleteTranscripts = createAction(DELETE_TRANSCRIPTS) export type State = { transcripts: PodloveTranscript[] voices: { voice: string, contributor: string }[] } export const initialState: State = { transcripts: [], voices: [], } export const reducer = handleActions( { [SET_TRANSCRIPTS]: (state: State, action: { payload: PodloveTranscript[] }): State => ({ ...state, transcripts: action.payload, }), [SET_VOICES]: (state: State, action: { payload: PodloveTranscriptVoice[] }): State => ({ ...state, voices: action.payload.map((elem: { voice: string; contributor_id: string }) => ({ voice: elem.voice, contributor: elem.contributor_id, })), }), [UPDATE_VOICE]: (state: State, action: { payload: { voice: string; contributor: string } }): State => ({ ...state, voices: state.voices.map((voice) => { if (voice.voice === action.payload.voice) { return { ...voice, contributor: action.payload.contributor, } } return voice }), }), }, initialState ) export const selectors = { transcripts: (state: State) => state.transcripts, voices: (state: State) => state.voices, } ================================================ FILE: client/src/store/vue.ts ================================================ import type { Dispatch, Store, UnknownAction } from 'redux' import { injectStore, mapState } from 'redux-vuex' import type { State } from './index' type AppSelector = (state: State) => T type AppSelectorMap = Record> type AppSelection = { [K in keyof T]: ReturnType } export type AppStore = Store export type AppDispatch = Dispatch export const injectAppStore = (): AppStore => injectStore() as AppStore export const injectAppDispatch = (): AppDispatch => injectAppStore().dispatch as AppDispatch export const mapAppState = (selectors: T): AppSelection => mapState(selectors as Record unknown>) as AppSelection ================================================ FILE: client/src/store/wordpress.store.ts ================================================ import { Action } from 'redux' import { createAction } from 'redux-actions' export const UPDATE = 'podlove/publisher/wordpress/UPDATE' export const SELECT_MEDIA_FROM_LIBRARY = 'podlove/publisher/wordpress/SELECT_MEDIA_FROM_LIBRARY' export const update = createAction<{ prop: string; value: any }>(UPDATE) export const selectMediaFromLibrary = createAction<{ onSuccess: Action }>(SELECT_MEDIA_FROM_LIBRARY) ================================================ FILE: client/src/style.css ================================================ @tailwind base; @tailwind components; @tailwind utilities; /* fix overwrites */ input[type=checkbox]:checked::before { content: ''; } ================================================ FILE: client/src/types/chapters.types.ts ================================================ export interface PodloveChapter { start: number; title: string; href?: string; image?: string; } ================================================ FILE: client/src/types/contributors.types.ts ================================================ export interface PodloveContributor { id: string avatar: string avatar_url: string count: string department: string gender: string jobtitle: string mail: string publicname: string realname: string nickname: string organisation: string slug: string } export interface PodloveRole { id: number slug: string title: string } export interface PodloveGroup { id: number slug: string title: string } ================================================ FILE: client/src/types/episode.types.ts ================================================ export interface PodloveEpisode { slug: string slug_frozen: boolean number: string title: string subtitle: string summary: string poster: string } export interface PodloveEpisodeContribution { id: number contributor_id: number role_id: number group_id: number position: number comment: string } ================================================ FILE: client/src/types/license.types.ts ================================================ export enum PodloveLicenseVersion { cc0 = "Public Domain License", pdmark = "Public Domain Mark License", cc3 = "Creative Commons 3.0 and earlier", cc4 = "Creative Commons 4.0" } export enum PodloveLicenseOptionCommercial { yes = "Yes", no = "No" } export enum PodloveLicenseOptionModification { yes = "Yes", yesbutshare = "Yes, as long as others share alike", no = "No" } export enum PodloveLicenseScope { Episode = "Episode", Podcast = "Podcast" } export type PodloveJurisdicationObject = { symbol: string, name: string, version: string } export const PodloveLicenseOptionJurisdication: Array = [ { symbol: "international", name: "International" , version: "3.0" }, { symbol: "ar", name: "Argentina" , version: "2.5" }, { symbol: "au", name: "Australia" , version: "3.0" }, { symbol: "at", name: "Austria" , version: "3.0" }, { symbol: "be", name: "Belgium" , version: "2.0" }, { symbol: "br", name: "Brazil" , version: "3.0" }, { symbol: "bg", name: "Bulgaria" , version: "2.5" }, { symbol: "ca", name: "Canada" , version: "2.5" }, { symbol: "cl", name: "Chile" , version: "3.0" }, { symbol: "cn", name: "China Mainland" , version: "3.0" }, { symbol: "co", name: "Colombia" , version: "2.5" }, { symbol: "cr", name: "Costa Rica" , version: "3.0" }, { symbol: "hr", name: "Croatia" , version: "3.0" }, { symbol: "cz", name: "Czech Republic" , version: "3.0" }, { symbol: "dk", name: "Denmark" , version: "2.5" }, { symbol: "ec", name: "Ecuador" , version: "3.0" }, { symbol: "eg", name: "Egypt" , version: "3.0" }, { symbol: "ee", name: "Estonia" , version: "3.0" }, { symbol: "fi", name: "Finland" , version: "1.0" }, { symbol: "fr", name: "France" , version: "3.0" }, { symbol: "de", name: "Germany" , version: "3.0" }, { symbol: "gr", name: "Greece" , version: "3.0" }, { symbol: "gt", name: "Guatemala" , version: "3.0" }, { symbol: "hk", name: "Hong Kong" , version: "3.0" }, { symbol: "hu", name: "Hungary" , version: "2.5" }, { symbol: "igo", name: "IGO" , version: "3.0" }, { symbol: "in", name: "India" , version: "2.5" }, { symbol: "ie", name: "Ireland" , version: "3.0" }, { symbol: "il", name: "Israel" , version: "2.5" }, { symbol: "it", name: "Italy" , version: "3.0" }, { symbol: "jp", name: "Japan" , version: "2.1" }, { symbol: "lu", name: "Luxembourg" , version: "3.0" }, { symbol: "mk", name: "Macedonia" , version: "2.5" }, { symbol: "my", name: "Malaysia" , version: "2.5" }, { symbol: "mt", name: "Malta" , version: "2.5" }, { symbol: "mx", name: "Mexico" , version: "2.5" }, { symbol: "nl", name: "Netherlands" , version: "3.0" }, { symbol: "nz", name: "New Zealand" , version: "3.0" }, { symbol: "no", name: "Norway" , version: "3.0" }, { symbol: "pe", name: "Peru" , version: "2.5" }, { symbol: "ph", name: "Philippines" , version: "3.0" }, { symbol: "pl", name: "Poland" , version: "3.0" }, { symbol: "pt", name: "Portugal" , version: "3.0" }, { symbol: "pr", name: "Puerto Rico" , version: "3.0" }, { symbol: "ro", name: "Romania" , version: "3.0" }, { symbol: "rs", name: "Serbia" , version: "3.0" }, { symbol: "sg", name: "Singapore" , version: "3.0" }, { symbol: "si", name: "Slovenia" , version: "2.5" }, { symbol: "za", name: "South Africa" , version: "2.5" }, { symbol: "kp", name: "South Korea" , version: "2.0" }, { symbol: "es", name: "Spain" , version: "3.0" }, { symbol: "se", name: "Sweden" , version: "2.5" }, { symbol: "ch", name: "Switzerland" , version: "3.0" }, { symbol: "tw", name: "Taiwan" , version: "3.0" }, { symbol: "th", name: "Thailand" , version: "3.0" }, { symbol: "gb", name: "UK: England & Wales", version: "2.0" }, { symbol: "gb_sc", name: "UK: Scotland" , version: "2.5" }, { symbol: "ug", name: "Uganda" , version: "3.0" }, { symbol: "us", name: "United States" , version: "3.0" }, { symbol: "vn", name: "Vietnam" , version: "3.0" }, ] export interface PodloveLicense { type: string | null, version: PodloveLicenseVersion | null, optionCommercial: PodloveLicenseOptionCommercial | null, optionModification: PodloveLicenseOptionModification | null, optionJurisdication: PodloveJurisdicationObject | null, } ================================================ FILE: client/src/types/relatedEpisodes.types.ts ================================================ export interface PodloveEpisodeList { episode_id: number, episode_title: string, } ================================================ FILE: client/src/types/shows.types.ts ================================================ export interface PodloveShow { id: number title: string slug: string subtitle: string summary: string image: string language: string category: string auphonic_preset: string } ================================================ FILE: client/src/types/transcripts.types.ts ================================================ export interface PodloveTranscript { voice: string; start: string, start_ms: number; end: string; end_ms: number; text: string; } export interface PodloveTranscriptVoice { voice: string, contributor_id: string } ================================================ FILE: client/src/vue-shims.d.ts ================================================ declare module '*.vue' { import type { DefineComponent } from 'vue' const component: DefineComponent<{}, {}, any> export default component } ================================================ FILE: client/tailwind.config.js ================================================ // tailwind.config.js const defaultTheme = require('tailwindcss/defaultTheme') module.exports = { content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'], theme: { extend: { fontFamily: { sans: ['Inter var', ...defaultTheme.fontFamily.sans], }, }, }, plugins: [require('@tailwindcss/forms')], } ================================================ FILE: client/tsconfig.json ================================================ { "compilerOptions": { "baseUrl": ".", "target": "esnext", "useDefineForClassFields": true, "module": "esnext", "moduleResolution": "node", "strict": true, "jsx": "preserve", "sourceMap": true, "resolveJsonModule": true, "esModuleInterop": true, "types": [], "lib": ["esnext", "dom"], "paths": { "@components/*": ["src/components/*"], "@store": ["src/store/index.ts"], "@store/*": ["src/store/*"], "@types/*": ["src/types/*"], "@sagas/*": ["src/sagas/*"], "@lib/*": ["src/lib/*"], } }, "exclude": ["node_modules/**/*"], "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"] } ================================================ FILE: client/typings/podlove.d.ts ================================================ interface Chapter { start: number; title: string; url?: string; image?: string; } declare module '@podlove/utils/keyboard' { export module utils { export function keydown(callback: Function): void; export function keyup(callback: Function): void; } } declare module 'podcast-chapter-parser-mp4chaps' { export function parse(text: string): Chapter[]; } declare module 'podcast-chapter-parser-audacity' { export function parse(text: string): Chapter[]; } declare module 'podcast-chapter-parser-hindenburg' { export function parser(parser: any): { parse(text: string): Chapter[]; } } declare module 'podcast-chapter-parser-psc' { export function parser(parser: any): { parse(text: string): Chapter[]; } } ================================================ FILE: client/typings/redux-actions.d.ts ================================================ declare module 'redux-actions' { export const handleActions: (bindings: any, state: any) => any; export const createAction: (type: string) => (payload: T) => { type: string, payload: T }; } ================================================ FILE: client/vite.config.js ================================================ import { defineConfig } from 'vite' import * as path from 'path' import vue from '@vitejs/plugin-vue' const root = path.resolve(__dirname) // https://vitejs.dev/config/ export default defineConfig({ plugins: [vue()], server: { proxy: { '/wp-json': { target: process.env.WORDPRESS_URL || 'http://podlove.local', changeOrigin: true, secure: false, } } }, root: path.resolve(__dirname), resolve: { alias: { vue: 'vue/dist/vue.esm-bundler.js', '@store': path.resolve(root, 'src', 'store'), '@components': path.resolve(root, 'src', 'components'), '@types': path.resolve(root, 'src', 'types'), '@sagas': path.resolve(root, 'src', 'sagas'), '@lib': path.resolve(root, 'src', 'lib'), } }, build: { outDir: path.resolve(root, 'dist'), cssCodeSplit: false, rollupOptions: { output: { entryFileNames: `client.js`, chunkFileNames: `chunk-[name].js`, assetFileNames: `[name].[ext]` } } } }) ================================================ FILE: composer.json ================================================ { "name": "podlove/podcast-publisher", "description": "Podcast Publishing Plugin for WordPress", "license": "MIT", "require": { "php": "^8.0", "podlove/podlove-timeline": "2.*", "monolog/monolog": "2.9.*", "symfony/yaml": "6.0.*", "symfony/polyfill-mbstring": "1.27.*", "twig/twig": "3.14.*", "geoip2/geoip2": "~2.0", "matomo/device-detector": "6.1.*", "podlove/webvtt-parser": "^1.1.6", "geertw/ip-anonymizer": "^1.1", "dariuszp/cli-progress-bar": "^1.0", "league/csv": "9.8.0", "gajus/dindent": "^2.0", "ramsey/uuid": "^4.7", "symfony/deprecation-contracts": "^3.0" }, "require-dev": { "pear/pear_exception": "1.0.*@dev", "bamarni/composer-bin-plugin": "1.4.1", "phpunit/phpunit": "^9.6", "yoast/phpunit-polyfills": "^4.0", "php-stubs/wordpress-stubs": "^6.9", "php-stubs/wordpress-tests-stubs": "^6.9" }, "autoload": { "classmap": [ "lib/", "includes/", "vendor-prefixed/" ], "exclude-from-classmap": [ "/vendor/twig" ] }, "config": { "platform": { "php": "8.0.29" }, "allow-plugins": { "bamarni/composer-bin-plugin": true } }, "scripts": { "prefix-dependencies": [ "composer prefix-twig", "composer prefix-matomo", "composer prefix-monolog", "composer prefix-psr" ], "prefix-twig": [ "@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" ], "prefix-matomo": [ "@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" ], "prefix-monolog": [ "@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" ], "prefix-psr": [ "@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" ] } } ================================================ FILE: config/php-scoper/matomo.inc.php ================================================ [ Finder::create()->files()->in('vendor/matomo/*')->name(['*.php', '*.yml', 'LICENSE', 'composer.json']), Finder::create()->files()->in('vendor/mustangostang/*')->name(['*.php', 'LICENSE', 'composer.json']), ], 'patchers' => [ function (string $filePath, string $prefix, string $content): string { $content = str_replace( 'class_exists(\'DeviceDetector', 'class_exists(\''.$prefix.'\\\DeviceDetector', $content ); $content = str_replace( '$className = \'DeviceDetector', '$className = \''.$prefix.'\\\DeviceDetector', $content ); // hack: remove faulty escaping in regex; not sure why php-scoper even touch this line if (stristr($filePath, 'AbstractParser.php') || stristr($filePath, 'DeviceDetector.php') || stristr($filePath, 'ShellTv.php') || stristr($filePath, 'HbbTV.php') || stristr($filePath, 'Version.php')) { $content = str_replace('\\\\', '\\', $content); } return $content.''; } ] ]; ================================================ FILE: config/php-scoper/monolog.inc.php ================================================ [ Finder::create()->files()->in('vendor/monolog/*')->name(['*.php', 'LICENSE', 'composer.json']), ], 'patchers' => [ ] ]; ================================================ FILE: config/php-scoper/piwik.inc.php ================================================ [ Finder::create()->files()->in('vendor/piwik/*')->name(['*.php', '*.yml', 'LICENSE', 'composer.json']), Finder::create()->files()->in('vendor/mustangostang/*')->name(['*.php', 'LICENSE', 'composer.json']), ], 'patchers' => [ function (string $filePath, string $prefix, string $content): string { $content = str_replace( 'class_exists(\'DeviceDetector', 'class_exists(\''.$prefix.'\\\DeviceDetector', $content ); $content = str_replace( '$className = \'DeviceDetector', '$className = \''.$prefix.'\\\DeviceDetector', $content ); return $content.''; } ] ]; ================================================ FILE: config/php-scoper/psr.inc.php ================================================ [ Finder::create()->files()->in('vendor/psr/*')->name(['*.php', 'LICENSE', 'composer.json']), ], 'patchers' => [ ] ]; ================================================ FILE: config/php-scoper/twig.inc.php ================================================ [ Finder::create()->files()->in('vendor/twig/*')->name(['*.php', 'LICENSE', 'composer.json']), ], 'patchers' => [ function (string $filePath, string $prefix, string $content): string { // suppress warnings for class_alias $content = preg_replace('/(\\\class_alias)/', '@${1}', $content); if (stristr($filePath, 'CoreExtension.php') || stristr($filePath, 'EscaperExtension.php') || stristr($filePath, 'DebugExtension.php')) { $pattern = '/TwigFilter\((\'[^\']+\'),\s+\'(_?twig[^\']+)\'/'; $content = preg_replace_callback( $pattern, function ($matches) use ($prefix) { return 'TwigFilter('.$matches[1].', \''.$prefix.'\\'.$matches[2].'\''; }, $content ); $pattern = '/TwigFunction\((\'[^\']+\'),\s+\'(twig[^\']+)\'/'; $content = preg_replace_callback( $pattern, function ($matches) use ($prefix) { return 'TwigFunction('.$matches[1].', \''.$prefix.'\\'.$matches[2].'\''; }, $content ); $pattern = '/TwigTest\((\'[^\']+\'),\s+\'(twig[^\']+)\'/'; $content = preg_replace_callback( $pattern, function ($matches) use ($prefix) { return 'TwigTest('.$matches[1].', \''.$prefix.'\\'.$matches[2].'\''; }, $content ); } if (stristr($filePath, 'ForNode.php')) { $content = str_replace( ' = twig_ensure_traversable', ' = '.$prefix.'\\\twig_ensure_traversable', $content ); } if (stristr($filePath, 'CaptureNode.php')) { $content = str_replace( '\\\Twig', $prefix.'\\\Twig', $content ); } if (stristr($filePath, 'IncludeNode.php') || stristr($filePath, 'WithNode.php')) { $content = str_replace( 'twig_array_merge(', $prefix.'\\\twig_array_merge(', $content ); $content = str_replace( 'twig_to_array(', $prefix.'\\\twig_to_array(', $content ); $content = str_replace( 'twig_test_iterable(', $prefix.'\\\twig_test_iterable(', $content ); } if (stristr($filePath, 'InBinary.php')) { $content = str_replace( 'twig_in_filter(', $prefix.'\\\twig_in_filter(', $content ); } if (stristr($filePath, 'MethodCallExpression.php')) { $content = str_replace( 'twig_call_macro(', $prefix.'\\\twig_call_macro(', $content ); } if (stristr($filePath, 'ModuleNode.php')) { $content = str_replace( 'use Twig\\', 'use '.$prefix.'\\\Twig\\', $content ); } if (stristr($filePath, 'GetAttrExpression.php')) { $content = str_replace( 'twig_get_attribute', $prefix.'\\\twig_get_attribute', $content ); } return $content; }, ], ]; ================================================ FILE: css/about.css ================================================ /*------------------------------------------------------------------------------ 22.0 - About Pages 1.0 Global: About, Credits, Freedoms 1.1 Typography 1.2 Structure 1.3 Point Releases 2.0 About Page 2.1 Typography 2.2 Structure ------------------------------------------------------------------------------*/ /*------------------------------------------------------------------------------ 1.0 - Global: About, Credits, Freedoms ------------------------------------------------------------------------------*/ .podlove-about-wrap { position: relative; margin: 25px 40px 0 20px; max-width: 1050px; /* readability */ font-size: 15px; } .podlove-about-wrap div.updated, .podlove-about-wrap div.error { display: none !important; } .podlove-about-wrap hr { border: 0; height: 0; margin: 0; border-top: 1px solid rgba(0, 0, 0, 0.1); } .podlove-about-wrap img { margin: 0; max-width: 100%; height: auto; vertical-align: middle; } /* WordPress Version Badge */ .podlove-badge { background: #777 url("data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4NCjwhLS0gR2VuZXJhdG9yOiBBZG9iZSBJbGx1c3RyYXRvciAxNi4wLjQsIFNWRyBFeHBvcnQgUGx1Zy1JbiAuIFNWRyBWZXJzaW9uOiA2LjAwIEJ1aWxkIDApICAtLT4NCjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCIgWw0KCTwhRU5USVRZIG5zX2V4dGVuZCAiaHR0cDovL25zLmFkb2JlLmNvbS9FeHRlbnNpYmlsaXR5LzEuMC8iPg0KCTwhRU5USVRZIG5zX2FpICJodHRwOi8vbnMuYWRvYmUuY29tL0Fkb2JlSWxsdXN0cmF0b3IvMTAuMC8iPg0KCTwhRU5USVRZIG5zX2dyYXBocyAiaHR0cDovL25zLmFkb2JlLmNvbS9HcmFwaHMvMS4wLyI+DQoJPCFFTlRJVFkgbnNfdmFycyAiaHR0cDovL25zLmFkb2JlLmNvbS9WYXJpYWJsZXMvMS4wLyI+DQoJPCFFTlRJVFkgbnNfaW1yZXAgImh0dHA6Ly9ucy5hZG9iZS5jb20vSW1hZ2VSZXBsYWNlbWVudC8xLjAvIj4NCgk8IUVOVElUWSBuc19zZncgImh0dHA6Ly9ucy5hZG9iZS5jb20vU2F2ZUZvcldlYi8xLjAvIj4NCgk8IUVOVElUWSBuc19jdXN0b20gImh0dHA6Ly9ucy5hZG9iZS5jb20vR2VuZXJpY0N1c3RvbU5hbWVzcGFjZS8xLjAvIj4NCgk8IUVOVElUWSBuc19hZG9iZV94cGF0aCAiaHR0cDovL25zLmFkb2JlLmNvbS9YUGF0aC8xLjAvIj4NCl0+DQo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkViZW5lXzEiIHhtbG5zOng9IiZuc19leHRlbmQ7IiB4bWxuczppPSImbnNfYWk7IiB4bWxuczpncmFwaD0iJm5zX2dyYXBoczsiDQoJIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4IiB3aWR0aD0iMTI4cHgiIGhlaWdodD0iMTI4cHgiDQoJIHZpZXdCb3g9IjAgMCAxMjggMTI4IiBlbmFibGUtYmFja2dyb3VuZD0ibmV3IDAgMCAxMjggMTI4IiB4bWw6c3BhY2U9InByZXNlcnZlIj4NCjxtZXRhZGF0YT4NCgk8c2Z3ICB4bWxucz0iJm5zX3NmdzsiPg0KCQk8c2xpY2VzPjwvc2xpY2VzPg0KCQk8c2xpY2VTb3VyY2VCb3VuZHMgIGhlaWdodD0iMTI3Ljk4MyIgd2lkdGg9IjcyLjQyNCIgYm90dG9tTGVmdE9yaWdpbj0idHJ1ZSIgeD0iMjcuMzk2IiB5PSIwLjUwNSI+PC9zbGljZVNvdXJjZUJvdW5kcz4NCgk8L3Nmdz4NCjwvbWV0YWRhdGE+DQo8cGF0aCBmaWxsPSIjZmZmIiBkPSJNOTIuMjczLDEyNy45OTVIMzUuOTQzYy00LjQ0NCwwLTguMDQ3LTMuNTgxLTguMDQ3LTcuOTk5VjguMDExYzAtNC40MTcsMy42MDMtNy45OTksOC4wNDctNy45OTloNTYuMzMxDQoJYzQuNDQzLDAsOC4wNDcsMy41ODIsOC4wNDcsNy45OTl2MTExLjk4NUMxMDAuMzIsMTI0LjQxNCw5Ni43MTgsMTI3Ljk5NSw5Mi4yNzMsMTI3Ljk5NXogTTYzLjYwNSwxMTEuOTk2DQoJYzEzLjMzMywwLDI0LjE0MS0xMC43NDMsMjQuMTQxLTIzLjk5N2MwLTEzLjI1MS0xMC44MDktMjMuOTk1LTI0LjE0MS0yMy45OTVjLTEzLjMzMywwLTI0LjE0MSwxMC43NDQtMjQuMTQxLDIzLjk5NQ0KCUMzOS40NjQsMTAxLjI1Myw1MC4yNzMsMTExLjk5Niw2My42MDUsMTExLjk5NnogTTkyLjI3Myw4LjAxMUgzNS45NDN2NDcuOTkzaDU2LjMzMVY4LjAxMUw5Mi4yNzMsOC4wMTF6IE02My42MDUsNzkuMjQ2DQoJYzQuODY0LDAsOC44MDYsMy45Miw4LjgwNiw4Ljc1M2MwLDQuODM2LTMuOTQsOC43NTUtOC44MDYsOC43NTVjLTQuODY0LDAtOC44MDctMy45MTktOC44MDctOC43NTUNCglDNTQuNzk5LDgzLjE2Niw1OC43NDIsNzkuMjQ2LDYzLjYwNSw3OS4yNDZ6Ii8+DQo8cGF0aCBmaWxsPSIjZmZmIiBkPSJNNjMuOTkyLDIyLjk3MmM1LjAzMy0xMS4yNSwyMC4yOTktOS4wOTgsMjAuMzk4LDQuNTM0YzAuMDU3LDcuODA5LTIwLjM2OSwyMS44NzEtMjAuMzY5LDIxLjg3MQ0KCXMtMjAuNDctMTMuOTI5LTIwLjQxMy0yMS43ODlDNDMuNzA4LDEzLjk4OCw1OC43MTIsMTEuMjUzLDYzLjk5MiwyMi45NzJ6Ii8+DQo8L3N2Zz4NCg=="); background-position: center 24px; background-repeat: no-repeat; -webkit-background-size: 85px 85px; background-size: 85px 85px; font-size: 14px; text-align: center; font-weight: 600; margin: 5px 0 0; padding-top: 110px; height: 30px; display: inline-block; width: 140px; } .podlove-about-wrap .podlove-badge { position: absolute; top: 0; right: 0; } /* Tabs */ .podlove-about-wrap h2.nav-tab-wrapper { padding-left: 6px; } .podlove-about-wrap h2 .nav-tab { padding: 4px 15px 6px; margin: 0 3px -1px 0; font-size: 18px; vertical-align: top; border-width: 1px; white-space: nowrap; } /* 1.1 - Typography */ .podlove-about-wrap p { line-height: 1.6em; font-size: 14px; } .podlove-about-wrap h1 { margin: 0.2em 200px 0 0; color: #333; line-height: 1.2em; font-size: 2.8em; font-weight: 400; } .podlove-about-wrap h3 { margin: 2em 0 .6em; font-size: 1.25em; line-height: 1.5em; } .podlove-about-wrap h4 { color: #222; } .podlove-about-wrap code, .podlove-about-wrap ol li p { font-size: 14px; } .podlove-about-wrap .about-description, .podlove-about-wrap .about-text { margin-top: 1.4em; font-weight: normal; line-height: 1.6em; font-size: 19px; } .podlove-about-wrap .about-text { margin: 1em 200px 1em 0; min-height: 60px; color: #777; } /* 1.2 - Structure */ .podlove-about-wrap .two-col > div { position: relative; width: 47.6%; margin-right: 4.799999999%; float: left; } .podlove-about-wrap .three-col > div { position: relative; width: 29.95%; margin-right: 4.999999999%; float: left; } .podlove-about-wrap .col .last-feature { margin-right: 0; } /* 1.3 - Point Releases */ .podlove-about-wrap .point-releases { margin-top: 5px; border-bottom: 1px solid #dfdfdf; } .podlove-about-wrap .changelog.point-releases h3 { padding-top: 35px; } .podlove-about-wrap .changelog.point-releases h3:first-child { padding-top: 7px; } /*------------------------------------------------------------------------------ 2.0 - About Page ------------------------------------------------------------------------------*/ /* 2.1 - Typography */ .podlove-about-wrap .headline-feature h2 { margin: 1.1em 0 0.2em; font-size: 2.4em; font-weight: 300; line-height: 1.3; text-align: center; } .podlove-about-wrap .feature-list h2 { margin: 30px 0 15px; text-align: center; } .podlove-about-wrap .feature-section h4 { margin: 1.4em 0 0.6em 0; font-size: 1.2em; } .podlove-about-wrap .feature-section p { margin-top: 0.6em; } /* 2.2 - Structure */ .podlove-about-wrap .featured-image { text-align: center; } .podlove-about-wrap .feature-section { overflow: hidden; padding-bottom: 20px; } .podlove-about-wrap .headline-feature .feature-section { margin: 0 auto; max-width: 82%; } .podlove-about-wrap .headline-feature .feature-section .col:first-child { float: left; margin: 15px 5% 0 0; width: 55%; } .podlove-about-wrap .headline-feature .feature-section .col:last-child { float: right; margin: 15px 0 40px; width: 40%; } .podlove-about-wrap .feature-list .feature-section { margin-top: 0; } .top-feature h3 { text-align: center; } .top-feature p, .top-feature ul { max-width: 68%; margin: 0 auto 20px; } /* Return to Dashboard Home link */ .podlove-about-wrap .return-to-dashboard { margin: 30px 0 0 -5px; font-size: 14px; font-weight: bold; } .podlove-about-wrap .return-to-dashboard a { text-decoration: none; padding: 0 5px; } /* SVGs */ .podlove-about-wrap .feature-list svg { float: left; clear: left; margin: 15px 15px 0 0 ; height: 90px; width: 90px; background-color: #cccccc; -webkit-border-radius: 50%; border-radius: 50%; fill: #999; border: 1px solid #c1c1c1; } .podlove-about-wrap .feature-list.finer-points h4, .podlove-about-wrap .feature-list.finer-points p { margin-left: 115px; } /*------------------------------------------------------------------------------ 4.0 - Media Queries ------------------------------------------------------------------------------*/ @media screen and ( max-width: 782px ) { .podlove-about-wrap .one-col > div, .podlove-about-wrap .two-col > div, .podlove-about-wrap .three-col > div { width: 100%; margin: 0 0 40px; padding: 0 0 40px; border-bottom: 1px solid rgba(0, 0, 0, 0.1); } .podlove-about-wrap .feature-list div, .podlove-about-wrap .col > div.last-feature { margin: 0; padding: 0; border-bottom: none; } .podlove-about-wrap .headline-feature .feature-section { max-width: 100%; } .podlove-about-wrap .feature-list .feature-section { padding: 0 0 40px; } } @media only screen and (max-width: 500px) { .podlove-about-wrap { margin-right: 20px; margin-left: 10px; } .podlove-about-wrap h1, .podlove-about-wrap .about-text { margin-right: 0; } .podlove-about-wrap .about-text { margin-bottom: 0.25em; } .podlove-about-wrap .podlove-badge { position: relative; margin-bottom: 1.5em; width: 100%; } .podlove-about-wrap h2.nav-tab-wrapper { padding-left: 0; border-bottom: 0; } .podlove-about-wrap h2 .nav-tab { margin-top: 10px; margin-right: 10px; border-bottom: 1px solid #ccc; } .podlove-about-wrap .three-col div, .podlove-about-wrap .headline-feature .feature-section div { width: 100% !important; float: none !important; } } @media only screen and (max-width: 400px) { .podlove-about-wrap .feature-list svg { margin-top: 15px; height: 65px; width: 65px; } .podlove-about-wrap .feature-list.finer-points h4, .podlove-about-wrap .feature-list.finer-points p { margin-left: 80px; } } ================================================ FILE: css/admin-font.css ================================================ @font-face { font-family: 'Podlove'; src:url('../fonts/Podlove.eot'); src:url('../fonts/Podlove.eot?#iefix') format('embedded-opentype'), url('../fonts/Podlove.woff') format('woff'), url('../fonts/Podlove.ttf') format('truetype'), url('../fonts/Podlove.svg#Podlove') format('svg'); font-weight: normal; font-style: normal; } /* Use the following CSS code if you want to use data attributes for inserting your icons */ [data-icon]:before { font-family: 'Podlove'; content: attr(data-icon); speak: none; font-weight: normal; font-variant: normal; text-transform: none; line-height: 1; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } /* Use the following CSS code if you want to have a class per icon */ /* Instead of a list of all class selectors, you can use the generic selector below, but it's slower: [class*="podlove-icon-"] { */ .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 { font-family: 'Podlove'; speak: none; font-style: normal; font-weight: normal; font-variant: normal; text-transform: none; line-height: 1; -webkit-font-smoothing: antialiased; } .podlove-icon-reorder:before { content: "\f0c9"; } .podlove-icon-ok:before { content: "\f00c"; } .podlove-icon-remove:before { content: "\f00d"; } .podlove-icon-minus:before { content: "\f068"; } .podlove-icon-spinner:before { content: "\f110"; } .podlove-icon-reply:before { content: "\f112"; } .podlove-icon-share:before { content: "\f045"; } .podlove-icon-time:before { content: "\f017"; } .podlove-icon-repeat:before { content: "\f01e"; } .podlove-icon-plus:before { content: "\f067"; } .podlove-icon-cloud-download:before { content: "\f0ed"; } .podlove-icon-external-link:before { content: "\f08e"; } .podlove-icon-circle:before { content: "\f111"; } .podlove-icon-cogs:before { content: "\f085"; } .podlove-icon-ban-circle:before { content: "\f05e"; } .podlove-icon-heart:before { content: "\f004"; } .podlove-icon-appdotnet:before { content: "\e000"; } .podlove-icon-github:before { content: "\e001"; } .podlove-icon-twitter:before { content: "\e002"; } .podlove-icon-facebook:before { content: "\e003"; } .podlove-icon-googleplus:before { content: "\e004"; } .podlove-icon-pinterest:before { content: "\e005"; } .podlove-icon-flattr:before { content: "\e006"; } .podlove-icon-paypal:before { content: "P"; } .podlove-icon-house:before { content: "\e007"; } .podlove-icon-mail:before { content: "\e008"; } .podlove-icon-cart:before { content: "\e009"; } .podlove-icon-edit:before { content: "e"; } .podlove-icon-calendar:before { content: "\e953"; } ================================================ FILE: css/admin.css ================================================ /* generic */ input[type='color'] { padding: 1px 2px; } .reorder-handle { font-size: 20px; cursor: move; color: gray; float: right; } .reorder-handle:hover { color: rgb(51, 51, 51); } i.rotate { display: inline-block; -webkit-animation: Rotate 500ms infinite linear; -moz-animation: Rotate 500ms infinite linear; -ms-animation: Rotate 500ms infinite linear; -o-animation: Rotate 500ms infinite linear; animation: Rotate 500ms infinite linear; } @-o-keyframes Rotate { from { -o-transform: rotate(0deg); } to { -o-transform: rotate(360deg); } } @-moz-keyframes Rotate { from { -moz-transform: rotate(0deg); } to { -moz-transform: rotate(360deg); } } @-ms-keyframes Rotate { from { -ms-transform: rotate(0deg); } to { -ms-transform: rotate(360deg); } } @-webkit-keyframes Rotate { from { -webkit-transform: rotate(0deg); } to { -webkit-transform: rotate(360deg); } } @-keyframes Rotate { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } ul.podlove-disc-list { list-style-type: disc; margin-left: 40px; } .force-issues { border-left: 4px solid #ccc; padding-left: 15px; } .clickable { cursor: pointer; } .podlove-icon-ok { color: green; } .podlove-icon-remove { color: red; } .podlove-icon-minus { color: #999; } .podlove-contributor-edit, .podlove-contributor-create { text-decoration: none; margin-left: 6px; vertical-align: middle; } .podlove-contributor-edit { display: none; } a[data-podlove-help] { text-decoration: none; } /* Dashboard */ div.podlove-dashboard-statistics-wrapper { width: 49%; display: inline-block; } td.podlove-dashboard-number-column { font-size: 1.2em; text-align: right; padding-right: 10px; } td.podlove-dashboard-number-column a, td.podlove-dashboard-number-column a:visited { text-decoration: none; } td.podlove-dashboard-total-number { border-top: 1px solid #bcbcbc; } table.podlove-dashboard-statistics td { padding: 3px; } .nav-tab-wrapper .nav-tab-title { float: left; line-height: 24px; font-size: 14px; padding: 5px 10px; } /* Dashboard in Sidebar */ .inner-sidebar div.podlove-dashboard-statistics-wrapper { width: 100%; display: block; } /* Settings: Assets */ .episode_assets.wp-list-table .column-move { width: 50px; } .episode_assets.wp-list-table .column-downloadable { width: 100px; } .feeds.wp-list-table .column-move { width: 50px; } .feeds.wp-list-table .column-discoverable { width: 100px; } table#dashboard_feed_info { width: 100%; } table#dashboard_feed_info td.center { text-align: center; } /* Support */ .podlove_system_report { font-family: monospace; resize: none; } /* Media files */ table.podlove_alternating th { font-weight: bold; padding: 1px; } table.podlove_alternating { width: 100%; border-bottom: 1px solid #999; } table.podlove_alternating th { text-align: left; border-bottom: 1px solid #999; } table.podlove_alternating td { padding: 5px; height: 24px; } table.podlove_alternating tr:nth-child(even) { background: #eaeaea; } .base_url { color: #777; font-size: 0.9em; } .media_file_row .enable { text-align: center; } .subtitle_warning { float: left; font-weight: bold; padding-right: 10px; } .subtitle_warning .close { cursor: pointer; } .media_file_row .enable { width: 45px; } .media_file_row .size { width: 130px; } .media_file_row .update { width: 90px; } .row__podlove_meta_episode_assets label[for='_podlove_meta_episode_assets'] { padding-bottom: 0; } /* Episode */ .row__podlove_meta_cover_art .podlove-media-upload-wrap > span { display: -webkit-flex; display: -ms-flexbox; display: flex; } .row__podlove_meta_cover_art .podlove-media-upload-wrap > span input { -webkit-flex: initial; -ms-flex: initial; flex: initial; width: 100%; min-width: 100px; } .row__podlove_meta_cover_art .podlove-media-upload-wrap > span a { margin-left: 10px; } .row__podlove_meta_duration > div > div { display: -webkit-flex; display: -ms-flexbox; display: flex; } .row__podlove_meta_duration > div > div input { -webkit-flex: initial; -ms-flex: initial; flex: initial; width: 100%; min-width: 100px; } .row__podlove_meta_duration > div > div a.button { margin-left: 10px; } .row__podlove_meta_duration div i { line-height: 26px; margin-left: 10px; } /* Contributors */ #add_new_contributor_selector { width: 250px; } #add_new_contributor_wrapper, #add_new_episode_relation_wrapper { width: 285px; margin: 5px 0px 0px 0px; } #contributors_table_body select { width: 180px; } #contributors_table_body td { height: 40px !important; } span.contributor_remove, span.episode_relation_remove { font-size: 1.6em; } tr.row_podlove_feed_protection_password, tr.row_podlove_feed_protection_user, tr.row_podlove_feed_protection_type { display: none; } img.podlove-avatar { width: 50px; vertical-align: bottom; margin: 2px; } input.podlove-contributor-field { width: 350px; } /* Podlove Podcast License */ #podlove_podcast_license_name, #podlove_podcast_license_url { width: 400px; } div.podlove_cc_license, div.podlove_license, .podlove_podcast_license_image { display: block; width: 300px; text-align: center; } ._podlove_episode_list_triangle, ._podlove_episode_list_triangle_expanded { width: 20px; font-size: 0.8em; } ._podlove_episode_list_triangle_expanded { display: none; } span#podlove_cc_license_selector_toggle { font-weight: bold; cursor: pointer; } .row_podlove_cc_license_selector { display: none; } .row_podlove_cc_license_selector td div { padding: 0px 5px 5px 30px; } .row_podlove_cc_license_selector td div select { width: 240px; } label.podlove_cc_license_selector_label { font-style: italic; width: 240px; display: inline-block; clear: both; } .row_podlove_contributor_services_form_table td, .row_podlove_contributor_services_form_table td { padding: 0; } /* Podlove input field validation */ .podlove-invalid-input { border: 2px solid #c72a00 !important; } .podlove-input-isinvalid { display: inline-block; background-color: #c72a00; color: #fff; height: 29px; line-height: 30px; padding: 0 5px 0 5px; margin-left: -1px; } .podlove-hide { display: none; } /* podcast post meta box */ .media_file_table { width: 100%; border-bottom: 1px solid #999; } .media_file_table th { text-align: left; border-bottom: 1px solid #999; } .media_file_table th.verify_all { padding: 0 0 5px 5px; } .media_file_table th.verify_all a { font-weight: normal; margin: 0; } .media_file_table td { padding: 5px; height: 24px; } .media_file_table tr:nth-child(even) { background: #eaeaea; } .podlove-div-wrapper-form > div > span > label { display: inline-block; padding: 15px 0 6px 0; font-size: 1.2em; } .podlove-div-wrapper-form textarea, .podlove-div-wrapper-form input[type='text'], .podlove-div-wrapper-form select { margin: 0px; width: 100%; } .podlove-div-wrapper-form .character_counter { text-align: right; float: right; } #template-editor { background: white; border: 1px solid rgb(229, 229, 229); margin-top: 15px; padding: 0; box-sizing: border-box; } #template-editor a { text-decoration: none; } #template-editor a:focus, #template-editor a:active { outline: 0; } #template-editor .navigation { background: #f9f9f9; width: 20%; float: left; position: relative; min-height: 445px; box-sizing: border-box; font-weight: bold; } #template-editor .navigation span { vertical-align: middle; } #template-editor .navigation .unsaved { font-size: 150%; } #template-editor .navigation .add { position: absolute; bottom: 8px; left: 10px; } #template-editor .navigation ul { margin: 0; height: 418px; overflow-y: scroll; } #template-editor .navigation ul li { padding: 6px 10px; margin: 0; } #template-editor .navigation ul a { display: block; } #template-editor .navigation ul li.active { background: #d7d7d7; } #template-editor .editor { float: left; width: 80%; box-sizing: border-box; } #template-editor .toolbar { border-bottom: 1px solid rgb(229, 229, 229); padding: 8px 10px; font-size: 14px; width: 100%; box-sizing: border-box; } #template-editor .toolbar input[type='text'] { width: 100%; } #template-editor .toolbar .title { float: left; line-height: 26px; width: 50%; } #template-editor footer { border-top: 1px solid #f9f9f9; padding: 8px 10px; } #template-editor footer a.delete { color: red; line-height: 26px; padding-right: 5px; } #template-editor footer .actions { display: flex; flex-direction: row; justify-content: space-between; align-content: center; } #template-editor .editor .main { /*padding: 8px 10px;*/ height: 400px; } .fullscreen-button.fullscreen-off { background-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiA/PjwhRE9DVFlQRSBzdmcgIFBVQkxJQyAnLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4nICAnaHR0cDovL3d3dy53My5vcmcvR3JhcGhpY3MvU1ZHLzEuMS9EVEQvc3ZnMTEuZHRkJz48c3ZnIGhlaWdodD0iMzJweCIgc3R5bGU9ImVuYWJsZS1iYWNrZ3JvdW5kOm5ldyAwIDAgMzIgMzI7IiB2ZXJzaW9uPSIxLjEiIHZpZXdCb3g9IjAgMCAzMiAzMiIgd2lkdGg9IjMycHgiIHhtbDpzcGFjZT0icHJlc2VydmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiPjxnIGlkPSJMYXllcl8xIi8+PGcgaWQ9ImZ1bGxzY3JlZW5feDVGX2V4aXQiPjxnPjxwb2x5Z29uIHBvaW50cz0iMjQuNTg2LDI3LjQxNCAyOS4xNzIsMzIgMzIsMjkuMTcyIDI3LjQxNCwyNC41ODYgMzIsMjAgMjAsMjAgMjAsMzIgICAiIHN0eWxlPSJmaWxsOiM0RTRFNTA7Ii8+PHBvbHlnb24gcG9pbnRzPSIwLDEyIDEyLDEyIDEyLDAgNy40MTQsNC41ODYgMi44NzUsMC4wNDMgMC4wNDcsMi44NzEgNC41ODYsNy40MTQgICAiIHN0eWxlPSJmaWxsOiM0RTRFNTA7Ii8+PHBvbHlnb24gcG9pbnRzPSIwLDI5LjE3MiAyLjgyOCwzMiA3LjQxNCwyNy40MTQgMTIsMzIgMTIsMjAgMCwyMCA0LjU4NiwyNC41ODYgICAiIHN0eWxlPSJmaWxsOiM0RTRFNTA7Ii8+PHBvbHlnb24gcG9pbnRzPSIyMCwxMiAzMiwxMiAyNy40MTQsNy40MTQgMzEuOTYxLDIuODcxIDI5LjEzMywwLjA0MyAyNC41ODYsNC41ODYgMjAsMCAgICIgc3R5bGU9ImZpbGw6IzRFNEU1MDsiLz48L2c+PC9nPjwvc3ZnPg=='); } .fullscreen-button:hover { opacity: 1; } .fullscreen-button { display: block; position: absolute; z-index: 100001; padding: 0; margin: 0; height: 32px; width: 32px; background-repeat: no-repeat; cursor: pointer; background-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiA/PjwhRE9DVFlQRSBzdmcgIFBVQkxJQyAnLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4nICAnaHR0cDovL3d3dy53My5vcmcvR3JhcGhpY3MvU1ZHLzEuMS9EVEQvc3ZnMTEuZHRkJz48c3ZnIGhlaWdodD0iMzJweCIgc3R5bGU9ImVuYWJsZS1iYWNrZ3JvdW5kOm5ldyAwIDAgMzIgMzI7IiB2ZXJzaW9uPSIxLjEiIHZpZXdCb3g9IjAgMCAzMiAzMiIgd2lkdGg9IjMycHgiIHhtbDpzcGFjZT0icHJlc2VydmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiPjxnIGlkPSJMYXllcl8xIi8+PGcgaWQ9ImZ1bGxzY3JlZW4iPjxnPjxwb2x5Z29uIHBvaW50cz0iMjcuNDE0LDI0LjU4NiAyMi44MjgsMjAgMjAsMjIuODI4IDI0LjU4NiwyNy40MTQgMjAsMzIgMzIsMzIgMzIsMjAgICAiIHN0eWxlPSJmaWxsOiM0RTRFNTA7Ii8+PHBvbHlnb24gcG9pbnRzPSIxMiwwIDAsMCAwLDEyIDQuNTg2LDcuNDE0IDkuMTI5LDExLjk1MyAxMS45NTcsOS4xMjUgNy40MTQsNC41ODYgICAiIHN0eWxlPSJmaWxsOiM0RTRFNTA7Ii8+PHBvbHlnb24gcG9pbnRzPSIxMiwyMi44MjggOS4xNzIsMjAgNC41ODYsMjQuNTg2IDAsMjAgMCwzMiAxMiwzMiA3LjQxNCwyNy40MTQgICAiIHN0eWxlPSJmaWxsOiM0RTRFNTA7Ii8+PHBvbHlnb24gcG9pbnRzPSIzMiwwIDIwLDAgMjQuNTg2LDQuNTg2IDIwLjA0Myw5LjEyNSAyMi44NzEsMTEuOTUzIDI3LjQxNCw3LjQxNCAzMiwxMiAgICIgc3R5bGU9ImZpbGw6IzRFNEU1MDsiLz48L2c+PC9nPjwvc3ZnPg=='); opacity: 0.3; top: 5px; right: 5px; } .fullScreen .fullScreen-editor { height: auto !important; width: auto !important; border: 0; margin: 0; position: fixed !important; top: 0; bottom: 0; left: 0; right: 0; z-index: 100000; } .fullScreen { overflow: hidden; } .editor-wrapper { position: relative; } .fullScreen #wpwrap, .fullScreen #wpbody, .fullScreen .editor-wrapper { position: static; } .podlove_gender_widget_column { min-width: 300px; display: inline-block; } .podlove_gender_widget_column table { width: 95%; } .podlove_gender_widget_column thead th { border-bottom: 1px solid #cdcdcd; } .podlove_gender_widget_column td, .podlove_gender_widget_column th { text-align: right; padding-right: 2%; } /* podlove media upload */ .podlove_preview_pic { margin: 1px; position: relative; min-height: 40px; display: none; } .podlove_preview_pic img { border: 1px solid #ddd; } .podlove_preview_pic .podlove_reset_image { position: absolute; bottom: 5px; color: #a00; } .analytics-metric-container { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); } @media (min-width: 640px) { .analytics-metric-container { grid-template-columns: repeat(4, minmax(0, 1fr)); } } .analytics-metric-box { text-align: center; margin: 10px 20px; min-width: 120px; } .analytics-metric-box > span, #analytics-global-downloads > div { font-size: 23px; line-height: 23px; display: block; } .analytics-metric-box .analytics-value, #analytics-global-downloads .analytics-value { font-weight: bold; line-height: 40px; } .analytics-metric-box .analytics-description, .analytics-metric-box .analytics-subtext, #analytics-global-downloads .analytics-description { font-size: 14px; line-height: 16px; color: #666; } .downloads.striped > tbody > :nth-child(odd) { background: inherit; } .downloads.striped > tbody > :nth-child(4n + 1), .downloads.striped > tbody > :nth-child(4n + 2) { background: #f9f9f9; } .downloads .dashicons { font-size: 13px; line-height: 1.5em; } #the-list .downloads-description { display: table-cell; } .wp-list-table .column-episode_number { width: 30px; } section.chart-wrapper { float: left; height: 320px; } section.chart-wrapper h1 { font-size: 14px; margin-left: 10px; } section.chart-wrapper div { width: 285px; height: 285px; } .chart-wrapper h1, .chart-wrapper h1 small { line-height: 19px; height: 19px; } .chart-wrapper h1 a { text-decoration: none; } .chart-menubar:first-child { float: right; } .chart-menubar:last-child { float: left; } .chart-menubar span { line-height: 26px; } #episode-performance-chart { float: none; height: 250px; } #episode-range-chart { float: none; height: 80px; margin-top: -15px; } #episode-source-chart g.row text, #episode-context-chart g.row text, #episode-client-chart g.row text, #episode-system-chart g.row text, #episode-geo-chart g.row text, #episode-asset-chart g.row text, #analytics-chart-global-clients g.row text, #analytics-chart-global-systems g.row text, #analytics-chart-global-sources g.row text, #analytics-global-top-episodes g.row text, #analytics-chart-global-assets g.row text { fill: black; } section.chart-wrapper div.chart-loading { font-size: 2rem; text-align: center; margin-top: 100px; height: 200px; } .chart-failed, .chart-nodata { font-size: 1rem; font-weight: bold; text-align: center; width: 100% !important; margin-top: 50px; } .chart-failed { color: rgb(212, 61, 4); } .chart-nodata { color: #666; } .chart-nodata:visible ~ svg { display: none; } /** slacknotes / shownotes **/ .p-card { background: white; border: 1px solid #ddd; max-width: 1024px; } .p-card-body { padding: 12px; } .p-card-header, .p-card-footer { background: #e9e9e9; padding: 12px; } .podlove-form-card { background: white; display: block; margin-bottom: 2em; padding: 1.25em; box-shadow: 1px 1px 2px rgba(0, 0, 0, 0.1), 0px 0px 4px rgba(0, 0, 0, 0.05); } .podlove-form-card tr:first-child th, .podlove-form-card tr:first-child h3 { margin-top: 0; padding-top: 0; } .podlove-form-card .submit { padding-bottom: 0; } /** Plus Banner */ .plus-banner { background: linear-gradient(135deg, #2562eb, #00bfff); border-radius: 12px; padding: 24px 30px; color: white; box-shadow: 0 5px 10px rgba(0, 0, 0, 0.1); display: flex; flex-direction: column; max-width: 900px; margin: 20px 0; position: relative; } .plus-banner-content, .plus-banner-content p { font-size: 14px; } .plus-banner h3 { margin-top: 0; margin-bottom: 16px; font-size: 22px; font-weight: 600; color: white; } .plus-banner-content { } .plus-banner p { margin: 0 0 16px; line-height: 1.5; } .plus-banner .btn { background-color: white; color: #2562eb; text-decoration: none; padding: 10px 20px; border-radius: 6px; font-weight: 600; display: inline-block; transition: transform 0.2s; align-self: flex-start; } .plus-banner .btn:hover { transform: translateY(-2px); box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); } .plus-banner .logo-text { font-weight: 300; font-size: 14px; display: flex; align-items: center; } .plus-banner-footer { display: flex; justify-content: space-between; align-items: center; } .corner-logo { display: flex; align-items: center; opacity: 0.8; } .plus-banner .corner-logo .logo-text { margin-left: 6px; } .banner-feature-list { margin: 16px 0; padding: 0; list-style: none; } .banner-feature-list li { margin-bottom: 8px; display: flex; align-items: baseline; } .banner-feature-list li::before { content: '★'; margin-right: 8px; color: #ffd700; } @media (max-width: 768px) { .plus-banner { padding: 20px; } .plus-banner h3 { line-height: 1.2; } .plus-banner-content { flex-direction: column; } .plus-banner-footer { flex-direction: column; align-items: flex-start; } .plus-banner .corner-logo { margin-top: 15px; } } ================================================ FILE: css/dc.css ================================================ div.dc-chart { float: left; } .dc-chart rect.bar { stroke: none; cursor: pointer; } .dc-chart rect.bar:hover { fill-opacity: .5; } .dc-chart rect.stack1 { stroke: none; fill: red; } .dc-chart rect.stack2 { stroke: none; fill: green; } .dc-chart rect.deselected { stroke: none; fill: #ccc; } .dc-chart .empty-chart .pie-slice path { fill: #FFEEEE; cursor: default; } .dc-chart .empty-chart .pie-slice { cursor: default; } .dc-chart .pie-slice { fill: white; font-size: 12px; cursor: pointer; } .dc-chart .pie-slice.external{ fill: black; } .dc-chart .pie-slice :hover { fill-opacity: .8; } .dc-chart .pie-slice.highlight { fill-opacity: .8; } .dc-chart .selected path { stroke-width: 3; stroke: #ccc; fill-opacity: 1; } .dc-chart .deselected path { stroke: none; fill-opacity: .5; fill: #ccc; } .dc-chart .axis path, .axis line { fill: none; stroke: #000; shape-rendering: crispEdges; } .dc-chart .axis text { font: 10px sans-serif; } .dc-chart .grid-line { fill: none; stroke: #ccc; opacity: .5; shape-rendering: crispEdges; } .dc-chart .grid-line line { fill: none; stroke: #ccc; opacity: .5; shape-rendering: crispEdges; } .dc-chart .brush rect.background { z-index: -999; } .dc-chart .brush rect.extent { fill: steelblue; fill-opacity: .125; } .dc-chart .brush .resize path { fill: #eee; stroke: #666; } .dc-chart path.line { fill: none; stroke-width: 1.5px; } .dc-chart circle.dot { stroke: none; } .dc-chart g.dc-tooltip path { fill: none; stroke: grey; stroke-opacity: .8; } .dc-chart path.area { fill-opacity: .3; stroke: none; } .dc-chart .node { font-size: 0.7em; cursor: pointer; } .dc-chart .node :hover { fill-opacity: .8; } .dc-chart .selected circle { stroke-width: 3; stroke: #ccc; fill-opacity: 1; } .dc-chart .deselected circle { stroke: none; fill-opacity: .5; fill: #ccc; } .dc-chart .bubble { stroke: none; fill-opacity: 0.6; } .dc-data-count { float: right; margin-top: 15px; margin-right: 15px; } .dc-data-count .filter-count { color: #3182bd; font-weight: bold; } .dc-data-count .total-count { color: #3182bd; font-weight: bold; } .dc-data-table { } .dc-chart g.state { cursor: pointer; } .dc-chart g.state :hover { fill-opacity: .8; } .dc-chart g.state path { stroke: white; } .dc-chart g.selected path { } .dc-chart g.deselected path { fill: grey; } .dc-chart g.selected text { } .dc-chart g.deselected text { display: none; } .dc-chart g.county path { stroke: white; fill: none; } .dc-chart g.debug rect { fill: blue; fill-opacity: .2; } .dc-chart g.row rect { fill-opacity: 0.8; cursor: pointer; } .dc-chart g.row rect:hover { fill-opacity: 0.6; } .dc-chart g.row text { fill: white; font-size: 12px; cursor: pointer; } .dc-legend { font-size: 11px; } .dc-legend-item { cursor: pointer; } .dc-chart g.axis text { /* Makes it so the user can't accidentally click and select text that is meant as a label only */ -webkit-user-select: none; /* Chrome/Safari */ -moz-user-select: none; /* Firefox */ -ms-user-select: none; /* IE10 */ -o-user-select: none; user-select: none; pointer-events: none; } .dc-chart path.highlight { stroke-width: 3; fill-opacity: 1; stroke-opacity: 1; } .dc-chart .highlight { fill-opacity: 1; stroke-opacity: 1; } .dc-chart .fadeout { fill-opacity: 0.2; stroke-opacity: 0.2; } .dc-chart path.dc-symbol, g.dc-legend-item.fadeout { fill-opacity: 0.5; stroke-opacity: 0.5; } .dc-hard .number-display { float: none; } .dc-chart .box text { font: 10px sans-serif; -webkit-user-select: none; /* Chrome/Safari */ -moz-user-select: none; /* Firefox */ -ms-user-select: none; /* IE10 */ -o-user-select: none; user-select: none; pointer-events: none; } .dc-chart .box line, .dc-chart .box circle { fill: #fff; stroke: #000; stroke-width: 1.5px; } .dc-chart .box rect { stroke: #000; stroke-width: 1.5px; } .dc-chart .box .center { stroke-dasharray: 3,3; } .dc-chart .box .outlier { fill: none; stroke: #ccc; } .dc-chart .box.deselected .box { fill: #ccc; } .dc-chart .box.deselected { opacity: .5; } .dc-chart .symbol{ stroke: none; } .dc-chart .heatmap .box-group.deselected rect { stroke: none; fill-opacity: .5; fill: #ccc; } .dc-chart .heatmap g.axis text { pointer-events: all; cursor: pointer; } ================================================ FILE: css/frontend.css ================================================ .episode_download_list ul { list-style: none; } .episode_download_list li { display: inline; font-size: 0.75em; } .episode_download_list .size { font-size: 0.5em; padding-left: 3px; } .episode_downloads select { font-size: 18px; display: inline; } .episode_downloads button { margin-left: 10px; } div.podlove_cc_license { display: block; text-align: center; } ul.podlove-donations-list, ul.podlove-social-list { margin: auto 0px auto 0px; } ul.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 { width: 20px; height: 20px; } ul.podlove-donations-list li a, ul.podlove-social-list li a { display: block; } ul.podlove-donations-list li, ul.podlove-social-list li { display: inline-block; line-height: 20px; list-style-type: none; margin-right: 5px; } /* player */ .podlove-player-wrapper { overflow: auto; -webkit-overflow-scrolling: touch; } .podlove-player-wrapper iframe { width: 1px; min-width: 100%; } .podlovewebplayer_wrapper { margin-bottom: 10px; } ================================================ FILE: data/.gitkeep ================================================ ================================================ FILE: data/opawg.json ================================================ [{ "user_agents": ["^Acast.+[Aa]ndroid"], "app": "Acast", "device": "phone", "os": "android" }, { "user_agents": ["^Acast.+iOS"], "app": "Acast", "device": "phone", "os": "ios" }, { "user_agents": ["AdsBot-Google"], "app": "Google AdsBot", "bot": true, "info_url": "http:\/\/www.google.com\/adsbot.html" }, { "user_agents": ["AhrefsBot\/"], "app": "AhrefsBot", "bot": true, "info_url": "http:\/\/ahrefs.com\/robot\/", "examples": ["Mozilla\/5.0 (compatible; AhrefsBot\/7.0; http:\/\/ahrefs.com\/robot\/)"] }, { "user_agents": ["^Airr\/"], "app": "Airr", "info_url": "https:\/\/www.airr.io\/", "examples": ["Airr\/3787 CFNetwork\/1128.0.1 Darwin\/19.6.0", "Airr\/4070 CFNetwork\/1206 Darwin\/20.1.0"] }, { "user_agents": ["^AirableBot-Podcast"], "app": "Airable", "info_url": "https:\/\/www.airablenow.com\/", "examples": ["AirableBot-Podcast\/1.0 ( https\/\/www.airablenow.com)"] }, { "user_agents": ["^AlexaMediaPlayer\/1\\.", "^AlexaMediaPlayer\/16\\.", "^AlexaMediaPlayer\/2\\.", "^Echo.*APNG"], "app": "Alexa-enabled device", "device": "speaker", "os": "alexa", "svg": "amazon.svg", "examples": ["Echo\/1.0(APNG)"] }, { "user_agents": ["^AmazonNewsContentService"], "app": "Alexa Flash Briefing cache", "description": "A service which downloads, caches and normalises audio for the Flash Briefing service on Alexa-enabled devices", "os": "alexa", "info_url": "https:\/\/developer.amazon.com\/docs\/flashbriefing\/flash-briefing-skill-api-feed-reference.html#performance-requirements", "developer_notes": "Stats are available within the Alexa skills dashboard.", "svg": "amazon.svg", "bot": true }, { "user_agents": ["^AmazonMusic(?!.*iPhone|.*Android|.*iPad)"], "examples": ["AmazonMusic"], "app": "Amazon Music Podcasts", "description": "A music and podcasts streaming app", "svg": "amazon.svg" }, { "user_agents": ["^AmazonMusic.*iPhone"], "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"], "app": "Amazon Music Podcasts", "description": "A music and podcasts streaming app", "os": "ios", "device": "phone", "developer_notes": "Examples are from an Amazon contact", "svg": "amazon.svg" }, { "user_agents": ["^AmazonMusic.*iPad"], "examples": ["AmazonMusic/22.13.3 iPad7,3 CFNetwork/1335.0.3 Darwin/21.6.0"], "app": "Amazon Music Podcasts", "description": "A music and podcasts streaming app", "os": "ipados", "device": "tablet" }, { "user_agents": ["^AmazonMusic.*Android"], "examples": ["AmazonMusic\/16.17.0 Dalvik\/2.1.0 (Linux; U; Android 6.0.1; vivo 1610 Build\/MMB29M)"], "app": "Amazon Music Podcasts", "description": "A music and podcasts streaming app", "os": "android", "developer_notes": "Examples are from an Amazon contact", "svg": "amazon.svg" }, { "user_agents": ["^Amazon Music Podcast"], "app": "Amazon Music Podcasts", "description": "A music and podcasts streaming app", "developer_notes": "Backend ingestion service", "svg": "amazon.svg", "bot": true }, { "user_agents": ["^AlexaMediaPlayer\/5\\."], "app": "Amazon Echo Dot", "device": "speaker", "os": "alexa", "svg": "amazon.svg" }, { "user_agents": ["^com.audible.playersdk.player", "^Audible,"], "app": "Audible", "os": "android" }, { "user_agents": ["^Audible.*Darwin"], "app": "Audible", "os": "ios" }, { "user_agents": ["^Android_AudioNow\/"], "app": "Audio Now", "examples": ["Android_AudioNow"], "info_url": "https:\/\/audionow.de\/", "os": "android" }, { "user_agents": ["^AndroidDownloadManager"], "os": "android" }, { "user_agents": ["^AntennaPod\/", "^de.danoeh.antennapod\/"], "app": "AntennaPod", "examples": ["de.danoeh.antennapod\/1.7.3b (Linux;Android 8.0.0) ExoPlayerLib\/2.9.3"], "info_url": "https:\/\/github.com\/AntennaPod\/AntennaPod", "os": "android", "developer_notes": "The de.danoeh version was used when streaming only, and will been phased out as of v2" }, { "user_agents": ["Apache-HttpClient"], "bot": true }, { "user_agents": ["^Applebot\/"], "bot": true, "info_url": "http:\/\/www.apple.com\/go\/applebot", "description": "Applebot is the web crawler for Apple. Products like Siri and Spotlight Suggestions use Applebot." }, { "user_agents": ["^AppleCoreMedia\/1\\..*iPod"], "device": "mp3_player", "examples": ["AppleCoreMedia\/1.0.0.16G114 (iPod touch; U; CPU OS 12_4_2 like Mac OS X; en_us)"], "os": "ios", "description": "AppleCoreMedia library", "info_url": "https:\/\/podnews.net\/article\/applecoremedia-user-agent", "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" }, { "user_agents": ["^AppleCoreMedia\/1\\..*Macintosh"], "examples": ["AppleCoreMedia\/1.0.0.19A583 (Macintosh; U; Intel Mac OS X 10_15; en_us)"], "device": "pc", "os": "macos", "description": "AppleCoreMedia library", "info_url": "https:\/\/podnews.net\/article\/applecoremedia-user-agent", "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" }, { "user_agents": ["^AppleCoreMedia\/1\\..*iPhone"], "device": "phone", "examples": ["AppleCoreMedia\/1.0.0.15G77 (iPhone; U; CPU OS 11_4_1 like Mac OS X; en_us)"], "os": "ios", "description": "AppleCoreMedia library", "info_url": "https:\/\/podnews.net\/article\/applecoremedia-user-agent", "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" }, { "user_agents": ["^AppleCoreMedia\/1\\..*iPad"], "device": "tablet", "examples": ["AppleCoreMedia\/1.0.0.17A860 (iPad; U; CPU OS 13_1_2 like Mac OS X; en_us)"], "os": "ios", "description": "AppleCoreMedia library", "info_url": "https:\/\/podnews.net\/article\/applecoremedia-user-agent", "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" }, { "user_agents": ["^AppleCoreMedia\/1\\..*HomePod"], "device": "speaker", "examples": ["AppleCoreMedia\/1.0.0.16G78 (HomePod; U; CPU OS 12_4 like Mac OS X; en_us)"], "os": "homepodos", "description": "AppleCoreMedia library", "info_url": "https:\/\/podnews.net\/article\/applecoremedia-user-agent", "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" }, { "user_agents": ["^AppleCoreMedia\/1\\..*Apple TV"], "device": "tv", "examples": ["AppleCoreMedia\/1.0.0.17J586 (Apple TV; U; CPU OS 13_0 like Mac OS X; en_us)"], "os": "tvos", "description": "AppleCoreMedia library", "info_url": "https:\/\/podnews.net\/article\/applecoremedia-user-agent", "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" }, { "user_agents": ["^AppleCoreMedia\/1\\..*Apple Watch"], "device": "watch", "os": "watchos", "description": "AppleCoreMedia library", "info_url": "https:\/\/podnews.net\/article\/applecoremedia-user-agent", "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" }, { "user_agents": ["^Audacious"], "examples": ["Audacious/3.10.1 neon/0.30.2"], "device": "pc", "description": "Audacious is an open source audio player.", "info_url": "https:\/\/audacious-media-player.org\/" }, { "user_agents": ["^atc\/"], "app": "Apple Podcasts", "device": "watch", "os": "watchos", "bot": true, "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.", "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)"] }, { "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\/.*\\(.*\\)"], "examples": ["Podcasts\/1410.53 CFNetwork\/1111 Darwin\/19.0.0 (x86_64)", "Podcaster\/1410.53 CFNetwork\/1111 Darwin\/19.0.0 (x86_64)"], "app": "Apple Podcasts", "description": "The Apple Podcasts app.", "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." }, { "user_agents": ["^Armadillo\/1"], "examples": ["Armadillo/12.19 (Linux;Android 11) ExoPlayerLib/2.17.1"], "os": "android", "developer_notes": "This is a library, and not an app", "info_url": "https:\/\/tech.scribd.com\/blog\/2021\/android-audio-player-tutorial-with-armadillo.html" }, { "user_agents": ["^AudioWave\/1"], "app": "AudioWave", "examples": ["AudioWave\/1.5 (+https:\/\/audiowave.io\/; iPhone 15.4)"], "os": "ios" }, { "user_agents": ["^AudioWaveBot\/1.0"], "app": "AudioWave feed parser", "examples": ["AudioWaveBot\/1.0"], "info_url": "https:\/\/audiowave.io", "bot": true }, { "user_agents": ["^BashPodder"], "app": "BashPodder", "device": "pc", "info_url": "http:\/\/lincgeek.org\/bashpodder\/" }, { "user_agents": ["Barkrowler\/"], "app": "Babbar", "bot": true, "info_url": "https:\/\/beta.babbar.tech\/crawler" }, { "user_agents": ["BBC%20Sounds\/"], "app": "BBC Sounds", "device": "phone", "examples": ["BBC%20Sounds\/1.13.1.7716 CFNetwork\/1107.1 Darwin\/19.0.0"], "info_url": "https:\/\/www.bbc.co.uk\/sounds\/help\/questions\/getting-started-with-bbc-sounds\/sounds-intro" }, { "user_agents": ["BBCiPlayerRadio\/"], "app": "BBC iPlayer Radio", "device": "phone", "examples": ["BBCiPlayerRadio\/2.16.0.8764 CFNetwork\/1107.1 Darwin\/19.0.0"], "info_url": "https:\/\/www.bbc.co.uk\/programmes\/p00zh17p" }, { "user_agents": ["; BeyondPod"], "app": "BeyondPod", "device": "phone", "examples": ["Mozilla\/5.0 (Linux; U; en-us; BeyondPod 4)"], "os": "android" }, { "user_agents": ["^Bitcast\/"], "app": "Bitcast", "os": "ios", "info_url": "https:\/\/bitcast.fm\/", "examples": ["Bitcast\/336 CFNetwork\/1197 Darwin\/20.0.0"] }, { "user_agents": ["^Bose\/"], "app": "Bose SoundTouch", "device": "speaker" }, { "user_agents": ["^Breaker\/Android"], "app": "Breaker", "os": "android" }, { "user_agents": ["^Breaker\/iOS"], "app": "Breaker", "os": "ios" }, { "user_agents": ["Android.+(?:B|b)rave"], "app": "Brave", "os": "android" }, { "user_agents": ["Linux.+(?:B|b)rave"], "app": "Brave", "device": "pc", "os": "linux" }, { "user_agents": ["iPhone.+(?:B|b)rave"], "app": "Brave", "device": "phone", "os": "ios" }, { "user_agents": ["Mac OS X.+(?:B|b)rave"], "app": "Brave", "device": "pc", "os": "macos" }, { "user_agents": ["Windows.+(?:B|b)rave"], "app": "Brave", "device": "pc", "os": "windows" }, { "user_agents": ["BroadwayPodcastNetwork\/iOS"], "app": "Broadway Podcast Network", "description": "The Broadway Podcast Network iOS App", "device": "phone", "examples": ["BroadwayPodcastNetwork\/iOS"], "os": "ios" }, { "user_agents": ["^Castamatic\/.+Darwin"], "app": "Castamatic", "description": "Your new favorite podcast player for iOS devices", "device": "phone", "examples": ["Castamatic\/3847 CFNetwork\/1240.0.4 Darwin\/20.6.0"], "os": "ios", "info_url": "https:\/\/castamatic.com" }, { "user_agents": ["^Cast(?:b|B)ox\/.+Android"], "app": "CastBox", "device": "phone", "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"], "os": "android" }, { "user_agents": ["^Cast(?:b|B)ox\/.+iOS"], "app": "CastBox", "device": "phone", "examples": ["CastBox\/8.5.1 (fm.castbox.audiobook.radio.podcast; build:11; iOS 13.1.2)"], "os": "ios" }, { "user_agents": ["^Cast(?:b|B)ox(?!.*(Android|iOS))"], "app": "CastBox", "developer_notes": "There are CastBox compatible User Agents that come without Android\/iOS platform marker", "examples": ["CastBox\/5.7.5-190508115.r1a805d3"] }, { "user_agents": ["^castget "], "app": "castget", "examples": ["castget 1.2.4 (castget rss enclosure downloader)"], "info_url": "https:\/\/castget.johndal.com\/", "device": "pc" }, { "user_agents": ["Castopod\/1.0"], "app": "Castopod", "examples": ["Castopod\/1.0"], "bot": true }, { "user_agents": ["Castro "], "app": "Castro", "device": "phone", "examples": ["Castro 2019.13\/1167", "Castro 2020.14\/1287"], "os": "ios" }, { "user_agents": ["(Linux).* CrKey\/"], "app": "Chromecast device", "device": "speaker", "os": "linux" }, { "user_agents": ["(Fuchsia).* CrKey\/"], "app": "Google Nest Hub", "device": "speaker", "os": "fuschia" }, { "user_agents": ["^Clementine "], "app": "Clementine Music Player", "device": "pc", "info_url": "https:\/\/www.clementine-player.org\/" }, { "user_agents": ["^clark-crawler2"], "app": "Clark-Crawler, unknown", "bot": true }, { "user_agents": ["^curl"], "bot": true }, { "user_agents": ["^Dalvik\/"], "examples": ["Dalvik\/2.1.0 (Linux; U; Android 9; SM-N950U Build\/PPR1.180610.011)"], "os": "android" }, { "user_agents": ["^datagnionbot"], "bot": true }, { "user_agents": ["^Deezer\/.*Android;", "^DeezerJukebox\/.+Android"], "app": "Deezer", "device": "phone", "os": "android", "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"], "info_url": "https:\/\/play.google.com\/store\/apps\/details?id=deezer.android.app" }, { "user_agents": ["^Deezer\/.*CFNetwork\/"], "app": "Deezer", "os": "ios", "examples": ["Deezer\/8.13.0.4 CFNetwork\/1125.2 Darwin\/19.4.0"], "info_url": "https:\/\/apps.apple.com\/us\/app\/deezer-music-podcast-player\/id292738169" }, { "user_agents": ["^Deezer.*Electron; windows"], "app": "Deezer", "examples": ["Deezer\/4.20.0 (Electron; windows\/10.0.18362; Desktop; fr)"], "device": "pc", "os": "windows" }, { "user_agents": ["^Deezer.*Electron; osx"], "app": "Deezer", "examples": ["Deezer\/4.20.0 (Electron; osx\/10.14.6; Desktop; fr)"], "device": "pc", "os": "macos" }, { "user_agents": ["DoggCatcher"], "app": "DoggCatcher", "device": "phone", "examples": ["Mozilla\/5.0 (Linux; U; Windows NT 6.1; en-us; dream) DoggCatcher"], "os": "android" }, { "user_agents": ["DotBot"], "app": "DotBot", "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)"], "bot": true }, { "user_agents": ["^doubleTwist CloudPlayer"], "examples": ["doubleTwist CloudPlayer"], "app": "doubleTwist CloudPlayer", "device": "phone", "info_url": "https:\/\/www.doubletwist.com\/cloudplayer", "os": "android" }, { "user_agents": ["Downcast\/.*iPhone"], "app": "Downcast", "device": "phone", "examples": ["Downcast\/2.9.42 (iPhone; iOS 12.4.1; Scale\/3.00)"], "os": "ios" }, { "user_agents": ["Downcast\/.*iPad"], "app": "Downcast", "device": "tablet", "examples": ["Downcast\/2.9.57 (iPad; iOS 14.2; Scale\/2.00)"], "os": "ios" }, { "user_agents": ["Downcast\/.*Mac OS X"], "app": "Downcast", "examples": ["Downcast\/2.9.57 (Mac OS X Version 10.15.7 (Build 19H15))"], "os": "macos", "device": "pc" }, { "user_agents": ["downcast feed consumer\/"], "app": "Downcast", "examples": ["downcast feed consumer\/0.0.175; (mode=dev; id=u2NgjBSPM6; downcast.fm)"], "bot": true }, { "user_agents": ["Xbox.+Edg?\/"], "app": "Edge", "device": "games_console", "os": "windows" }, { "user_agents": ["Android.+EdgA\/"], "app": "Microsoft Edge", "os": "android", "info_url": "https:\/\/play.google.com\/store\/apps\/details?id=com.microsoft.emmx&hl=en_AU&gl=US" }, { "user_agents": ["iPhone.+EdgiOS\/"], "app": "Edge", "device": "phone", "os": "ios" }, { "user_agents": ["Macintosh.+MacEdgeClient\/"], "app": "Edge", "device": "pc", "os": "macos" }, { "user_agents": ["Windows Phone.+Edge?\/"], "app": "Edge", "device": "phone", "os": "windows" }, { "user_agents": ["Windows(?!.*(Xbox)).+Edg?\/"], "app": "Edge", "device": "pc", "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"], "os": "windows" }, { "user_agents": ["FacebookBot", "facebookexternalhit\/", "podcastbot"], "bot": true, "app": "Facebook", "info_url": "https:\/\/www.facebook.com\/externalhit_uatext.php", "developer_notes": "The podcastbot UA appears to be part of Facebook Podcasts onboarding", "examples": ["facebookexternalhit\/1.1 ( http:\/\/www.facebook.com\/externalhit_uatext.php)", "podcastbot"] }, { "user_agents": ["iPhone.+\\[FBAN\/FBIO.+\\]"], "app": "Facebook", "device": "phone", "os": "ios", "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]"], "description": "The Facebook app's built-in browser on iPhones." }, { "user_agents": ["iOS\/Facebook"], "app": "Facebook", "device": "phone", "os": "ios", "examples": ["iOS\/Facebook"], "developer_notes": "Spotted using a Facebook-exclusive audio feed. Distinct from the above, since it's the actual podcast player.", "description": "The Facebook app's podcast player on iOS." }, { "user_agents": ["^FB4A\/Facebook"], "app": "Facebook", "device": "phone", "os": "android", "examples": ["FB4A\/Facebook"], "developer_notes": "Spotted using a Facebook-exclusive audio feed", "description": "The Facebook app's podcast player on Android." }, { "user_agents": ["^feedly\/"], "app": "Feedly", "examples": ["feedly\/81.0.1 CFNetwork\/1206 Darwin\/20.1.0"], "description": "An RSS reader" }, { "user_agents": ["Linux.*Firefox\/"], "app": "Firefox", "device": "pc", "os": "linux" }, { "user_agents": ["Mac OS X.*Firefox\/"], "app": "Firefox", "device": "pc", "os": "macos" }, { "user_agents": ["Windows.*Firefox\/"], "app": "Firefox", "device": "pc", "examples": ["Mozilla\/5.0 (Windows NT 10.0; Win64; x64; rv:69.0) Gecko\/20100101 Firefox\/69.0"], "os": "windows" }, { "user_agents": ["Android.*(Focus|Firefox)\/"], "app": "Firefox", "os": "android" }, { "user_agents": ["iPhone.*Focus\/"], "app": "Firefox", "device": "phone", "os": "ios" }, { "user_agents": ["iPad.*Focus\/"], "app": "Firefox", "device": "tablet", "os": "ios" }, { "user_agents": ["^Lavf\/"], "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." }, { "user_agents": ["^MAC "], "app": "Flash", "device": "pc", "os": "macos" }, { "user_agents": ["^WIN "], "app": "Flash", "device": "pc", "os": "windows" }, { "user_agents": ["^foobar2000\/"], "app": "foobar2000", "examples": ["foobar2000\/1.x"], "info_url": "https:\/\/www.foobar2000.org\/" }, { "user_agents": ["^Fountain.+(iOS|ios)"], "app": "Fountain", "examples": ["Fountain\/0.2.6 iOS https:\/\/www.fountain.fm'", "Fountain\/0.3.8 ios https:\/\/www.fountain.fm"], "info_url": "https:\/\/www.fountain.fm", "os": "ios" }, { "user_agents": ["^Fountain.+(Android|android)"], "app": "Fountain", "examples": ["Fountain\/0.2.6 Android https:\/\/www.fountain.fm'", "Fountain\/0.3.13 android https:\/\/www.fountain.fm"], "info_url": "https:\/\/www.fountain.fm", "os": "android" }, { "user_agents": ["^fyyd-poll"], "app": "Fyyd", "bot": true }, { "user_agents": ["^Garmin Forerunner"], "app": "Garmin Forerunner", "developer_notes": "This uses Spotify to transfer audio to compatible watches", "device": "watch", "examples": ["Garmin Forerunner 245 Music\/11.60"] }, { "user_agents": ["^Garmin fenix 5X Plus"], "app": "Garmin fenix 5X", "developer_notes": "This uses Spotify to transfer audio to compatible watches", "device": "watch", "examples": ["Garmin fenix 5X Plus/18.0"] }, { "user_agents": ["^Go-http-client"], "developer_notes": "This has been seen being used by a TuneIn client, and may be within WinAMP code.", "examples": ["Go-http-client\/2.0"] }, { "user_agents": ["Goodpods(.)Android \/"], "examples": ["Goodpods.Android \/ 2.2.2","Goodpods.Android / 3.2.9"], "app": "Goodpods", "os": "android", "description": "The social podcasting app", "info_url": "https:\/\/www.goodpods.com\/", "svg": "goodpods.svg" }, { "user_agents": ["Goodpods.[iI]OS \/ \\d+\\.\\d+\\.\\d+"], "examples": ["Goodpods.iOS \/ 2.2.2"], "app": "Goodpods", "os": "ios", "description": "The social podcasting app", "info_url": "https:\/\/www.goodpods.com\/", "svg": "goodpods.svg" }, { "user_agents": ["Goodpods\/\\d+\\.\\d+"], "examples": ["Goodpods\/2.2"], "app": "Goodpods", "os": "linux", "bot": true, "description": "The social podcasting app", "info_url": "https:\/\/www.goodpods.com\/", "svg": "goodpods.svg", "developer_notes": "RSS scraper \/ podcast verifier. Contact hello at goodpods dot com." }, { "user_agents": ["Goodpods\/1 CFNetwork"], "examples": ["Goodpods\/1 CFNetwork\/1329 Darwin\/21.3.0"], "app": "Goodpods", "os": "ios", "description": "The social podcasting app", "info_url": "https:\/\/www.goodpods.com\/", "svg": "goodpods.svg" }, { "user_agents": ["^Gumball"], "examples": ["Gumball.fm Analytics Prefix Checker"], "app": "Gumball", "bot": true, "description": "An attribution service (known as Gumshoe) from the Gumball network" }, { "user_agents": ["Googlebot\/", "Googlebot-Video\/", "Googlebot-Image\/"], "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"], "description": "Google's search bots", "app": "Googlebot", "info_url": "http:\/\/www.google.com\/bot.html", "bot": true }, { "user_agents": ["Google-Podcast"], "bot": true, "app": "Google Podcasts Manager" }, { "user_agents": ["^Google-Speech-Actions"], "app": "Google Assistant", "device": "speaker", "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.", "os": "android", "info_url": "https:\/\/cloud.google.com\/text-to-speech\/docs\/ssml" }, { "user_agents": ["GoogleChirp"], "app": "Google Podcasts", "device": "speaker", "description": "Google Podcasts on smart speakers", "os": "android" }, { "user_agents": ["^GooglePodcasts\/(iPhone|iPad|iPod touch|!Android).*GSA\/", "^Podcasts$", "^GooglePodcasts\/.*(iPhone|iPad|iPod touch)"], "app": "Google Podcasts", "description": "Google Podcasts on iOS", "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"], "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'.", "os": "ios" }, { "user_agents": ["(Android).*GSA\/","^GSA\/"], "app": "Google Podcasts", "os": "android", "description": "Google Podcasts on Android (the app or player)", "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"], "developer_notes": "*GSA\/ is used in the Google app when searching and playing podcasts, and in the Google Podcasts app" }, { "user_agents": ["Linux; Android.*SM-T350"], "device": "tablet", "os": "android" }, { "user_agents": ["^(?!Podverse).*Android.*Chrome\/(?!.*(Googlebot|CrKey|GSA|Edge|EdgA\/))"], "app": "Google Chrome", "os": "android", "developer_notes": "This won't match Googlebot, a Chromecast device, Google speaker or Google app, or the Podverse app" }, { "user_agents": ["CrOS.*Chrome\/"], "app": "Google Chrome", "device": "pc", "os": "chromeos" }, { "user_agents": ["Linux(?!.*(Android)).*Chrome\/(?!.*(CrKey|GSA\/))"], "app": "Google Chrome", "device": "pc", "os": "linux", "developer_notes": "This won't match an Android device, Chromecast device, Google speaker or Google app" }, { "user_agents": ["Mac OS X.*Chrome\/(?!.*(Spreaker\/|OPR\/))"], "app": "Google Chrome", "device": "pc", "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"], "os": "macos", "developer_notes": "This won't match the Spreaker app" }, { "user_agents": ["Windows.*Chrome\/(?!.*(OPR|Edg|Electron|PodFriend\/))"], "app": "Google Chrome", "device": "pc", "examples": ["Mozilla\/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit\/537.36 (KHTML, like Gecko) Chrome\/77.0.3865.120 Safari\/537.36"], "os": "windows" }, { "user_agents": ["iPad.*CriOS\/"], "app": "Google Chrome", "device": "tablet", "os": "ios" }, { "user_agents": ["iPhone.*CriOS\/"], "app": "Google Chrome", "device": "phone", "os": "ios" }, { "user_agents": ["iPhone.+GSA\/\\d+"], "app": "Google Search App", "device": "phone", "os": "ios", "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"], "description": "The Google Search App on iPhone." }, { "user_agents": ["^gPodder\/.*Windows", "^gpodder\\.net"], "app": "gPodder", "os": "windows", "device": "pc", "examples": ["gPodder\/3.10.8 (+http:\/\/gpodder.org\/) Windows\/10"] }, { "user_agents": ["^GStreamer"], "device": "radio" }, { "user_agents": ["^GaanaAndroid-"], "app": "Gaana", "os": "android", "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)"] }, { "user_agents": ["^Gaana-iOS"], "app": "Gaana", "os": "ios", "examples": ["Gaana-iOS"] }, { "user_agents": ["^Guardian-iOSLive\/"], "app": "Guardian", "os": "ios" }, { "user_agents": ["GuardianAndroidApp\/"], "app": "Guardian", "os": "android" }, { "user_agents": ["^gvfs"], "bot": true }, { "user_agents": ["^Himalaya\/.+iPhone"], "app": "Himalaya", "device": "phone", "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)"], "os": "ios", "description": "Himalaya is a podcast app" }, { "user_agents": ["^HyperCatcher"], "app": "HyperCatcher", "device": "phone", "examples": ["HyperCatcher\/1"], "os": "ios", "description": "Newsletters and podcasts together!" }, { "user_agents": ["^iCatcher"], "app": "iCatcher", "device": "phone", "os": "ios" }, { "user_agents": ["^iHeartRadio\/.*Android"], "app": "iHeartRadio", "os": "android", "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)"], "info_url": "https:\/\/play.google.com\/store\/apps\/details?id=com.clearchannel.iheartradio.controller" }, { "user_agents": ["^iHeartRadio\/.* CFNetwork\/", "^iHeartRadio\/.* iOS"], "app": "iHeartRadio", "os": "ios", "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)"], "info_url": "https:\/\/apps.apple.com\/us\/app\/iheart-radio-music-podcasts\/id290638154" }, { "user_agents": ["MSIE "], "app": "Internet Explorer", "device": "pc", "examples": ["Mozilla\/5.0 (compatible; MSIE 10.0; Windows NT 6.1; Trident\/6.0)"], "os": "windows" }, { "user_agents": ["iVoox"], "app": "iVoox", "info_url": "https:\/\/www.ivoox.com\/" }, { "user_agents": ["iVooxApp.*iPhone"], "app": "iVoox", "os": "ios", "examples": ["iVooxApp/3.85 (iPhone; iOS; iOS 15.6.1; d:16910714; 113F)"], "info_url": "https:\/\/www.ivoox.com\/" }, { "user_agents": ["ivooxApp.*Android", "FileDownloader\/"], "app": "iVoox", "os": "android", "examples": ["ivooxApp\/2.281.428_428 (G8341; Android 9; d:420031; E5A4)"], "info_url": "https:\/\/www.ivoox.com\/" }, { "user_agents": ["iTMS", "itunesstored"], "app": "Apple Podcasts automated checks", "bot": true }, { "user_agents": ["^iTunes\/.+Mac OS", "^iTunes\/.+OS X"], "examples": ["iTunes\/10.6.3 (Macintosh; Intel Mac OS X 10.5.8) AppleWebKit\/534.50.2"], "app": "iTunes", "device": "pc", "info_url": "https:\/\/www.apple.com\/itunes\/", "os": "macos" }, { "user_agents": ["^iTunes\/.+Windows"], "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"], "app": "iTunes", "device": "pc", "os": "windows" }, { "user_agents": ["^iTunes\/4"], "device": "speaker" }, { "user_agents": ["J. River Internet Reader"], "examples": ["Microsoft-Windows-XP\/2002, UPnP\/1.1, J. River Internet Reader\/2.0 (compatible; Windows-Media-Player\/10)"], "app": "JRiver Media Center", "device": "pc", "info_url": "https:\/\/www.jriver.com\/", "os": "windows" }, { "user_agents": [".*KAIOS\/(?!.*(PodKast))"], "app": "KAIOS podcast app", "device": "phone", "os": "kaios", "info_url": "https:\/\/kaiostech.com", "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"], "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." }, { "user_agents": [".*PodLP\/"], "app": "PodLP podcast app for KaiOS", "device": "phone", "os": "kaios", "info_url": "https:\/\/podlp.com", "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"], "description": "PodLP is the first podcast app available for KaiOS smart feature phones on the KaiStore.", "developer_notes": "Introduced in version v1.2.0.0 for limited content (downloads); available for all content after v1.3.0.0" }, { "user_agents": [".*PodKast$"], "app": "PodKast app", "device": "phone", "os": "kaios", "examples": ["Mozilla\/5.0 (Mobile; M571M3; rv:48.0) Gecko\/48.0 Firefox\/48.0 KAIOS\/2.5.1.2 PodKast", "KaiOS Downloader PodKast"], "description": "PodKast is a podcast app available for KaiOS smartphones" }, { "user_agents": ["^Laughable.+iOS"], "app": "Laughable", "device": "phone", "os": "ios" }, { "user_agents": ["^lesindesradios$"], "app": "Les Ind\u00e9s Radios", "os": "ios", "device": "phone", "description": "Les Ind\u00e9s Radios is a radio app, available on multiple devices and OSs", "info_url": "https:\/\/www.lesindesradios.fr\/", "examples": ["lesindesradios"] }, { "user_agents": ["^lesindesradios\/.*\\(Linux;Android"], "app": "Les Ind\u00e9s Radios", "os": "android", "device": "phone", "description": "Les Ind\u00e9s Radios is a radio app, available on multiple devices and OSs", "info_url": "https:\/\/www.lesindesradios.fr\/", "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"] }, { "user_agents": ["^com.jio.media.jiobeats", "^com.saavn.android", "^saavn"], "app": "JioSaavn", "os": "android", "info_url": "https:\/\/www.jiosaavn.com\/", "description": "A music streaming and podcast app from India. Earn Your Happy!", "developer_notes": "The user-agent will start with one of the above strings followed by the app version and player version.", "examples": ["com.jio.media.jiobeats\/7.3.1 (Linux;Android 8.1.0) ExoPlayerLib\/2.11.4"] }, { "user_agents": ["^lamarr-iOS", "^TheEconomist-Lamarr-ios"], "app": "The Economist", "device": "phone", "os": "ios", "examples": ["lamarr-iOS-2.20.3-116", "TheEconomist-Lamarr-ios-2.22.2-12002"] }, { "user_agents": ["^lamarr-android", "^TheEconomist-Lamarr-android"], "app": "The Economist", "device": "phone", "os": "android", "examples": ["lamarr-android-2.18.1-21810", "TheEconomist-Lamarr-android-2.22.2-12002"] }, { "user_agents": ["LG Player"], "device": "phone", "os": "android" }, { "user_agents": ["^libwww-perl"], "bot": true }, { "user_agents": ["iPhone.+\\[LinkedInApp\\]"], "app": "LinkedIn", "device": "phone", "os": "ios", "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]"], "description": "The LinkedIn app's built-in browser on iPhones." }, { "user_agents": ["Listen5"], "app": "Listen5", "device": "phone", "os": "ios" }, { "user_agents": ["Lisny"], "app": "Lisny", "os": "android", "examples": ["Lisny"], "info_url": "https:\/\/www.lisny.com", "description": "Lisny is a fast, beautiful and fun listening experience." }, { "user_agents": ["LivelapBot"], "bot": true }, { "user_agents": ["^Luminary\/.+Android"], "app": "Luminary", "device": "phone", "os": "android" }, { "user_agents": ["^Luminary\/.+iOS"], "app": "Luminary", "device": "phone", "os": "ios" }, { "user_agents": ["^MajelanApp"], "app": "Majelan" }, { "user_agents": ["^Mechanize"], "bot": true }, { "user_agents": ["^MediaMonkey"], "app": "MediaMonkey", "device": "pc", "os": "windows" }, { "user_agents": ["^Miro\/.+Windows"], "app": "Miro", "device": "pc", "examples": ["Miro\/6.0 (http:\/\/www.getmiro.com\/; Windows post2008Server x86)"], "info_url": "http:\/\/www.getmiro.com\/", "os": "windows" }, { "user_agents": [".*MJ12bot"], "app": "MJ12bot", "examples": ["Mozilla\/5.0 (compatible; MJ12bot\/v1.4.8; http:\/\/mj12bot.com\/)"], "bot": true }, { "user_agents": ["^mpv 0\\."], "app": "mpv", "info_url": "https:\/\/mpv.io\/" }, { "user_agents": ["^MusicBee"], "app": "MusicBee", "device": "pc", "examples": ["MusicBee"], "info_url": "https:\/\/getmusicbee.com\/", "os": "windows" }, { "user_agents": [".*Neevabot"], "app": "Neevabot", "bot": true, "info_url": "https:\/\/neeva.com\/neevabot", "examples": ["Mozilla\/5.0 (compatible; Neevabot\/1.0; https:\/\/neeva.com\/neevabot)"] }, { "user_agents": ["^NPR%20One\/"], "app": "NPR One", "examples": ["NPR%20One\/234 CFNetwork\/1197 Darwin\/20.0.0"] }, { "user_agents": ["^NPROneAndroid"], "app": "NPR One", "os": "android", "examples": ["NPROneAndroid"] }, { "user_agents": ["NRC Audio\/.*Android"], "examples": ["NRC Audio/2.0.0 (nl.nrc.audio; build:29; Android 12; Sdk:31; Manufacturer:samsung; Model: SM-G975F) OkHttp/4.9.3"], "description": "NRC Audio", "os": "android" }, { "user_agents": ["^OkDownload\/"] }, { "user_agents": ["okhttp"], "examples": ["okhttp\/3.11.0"] }, { "user_agents": ["Opera\/.*Android;"], "app": "Opera", "os": "android" }, { "user_agents": ["Opera\/.*\\(Linux"], "app": "Opera", "device": "pc", "os": "linux" }, { "user_agents": ["Opera\/.*\\(Macintosh", "Macintosh.*OPR\/"], "app": "Opera", "device": "pc", "os": "macos", "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"] }, { "user_agents": ["Opera\/.*\\(Windows", "Windows.*OPR\/"], "app": "Opera", "device": "pc", "os": "windows" }, { "user_agents": ["^MauiBot"], "app": "MauiBot", "bot": true, "examples": ["MauiBot (crawler.feedback dc@gmail.com)"] }, { "user_agents": ["^Overcast\/.*iOS"], "app": "Overcast", "examples": ["Overcast\/3.0 (+http:\/\/overcast.fm\/; iOS podcast app)"], "os": "ios" }, { "user_agents": ["^Overcast.*Apple Watch"], "app": "Overcast", "examples": ["Overcast ( http:\/\/overcast.fm\/; Apple Watch podcast app)"], "os": "watchos", "device": "watch" }, { "user_agents": ["^Overcast\/1.0 Podcast Sync"], "app": "Overcast feed parser", "examples": ["Overcast\/1.0 Podcast Sync"], "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.", "bot": true }, { "user_agents": ["^PandoraRSSCrawler"], "bot": true, "app": "Pandora RSS crawler" }, { "user_agents": ["^Pandora.+Android"], "app": "Pandora", "os": "android", "examples": ["Pandora\/2009.2 Android\/7.1.1 gteslteatt (ExoPlayerLib1.5.14.1)"] }, { "user_agents": ["iPhone.+Pandora\/"], "app": "Pandora", "device": "phone", "os": "ios", "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"] }, { "user_agents": ["PaperLiBot\/"], "app": "PaperLi", "examples": ["Mozilla\/5.0 (compatible; PaperLiBot\/2.1; https:\/\/support.paper.li\/entries\/20023257-what-is-paper-li)"], "bot": true }, { "user_agents": ["^Player FM","^Player%20FM"], "app": "Player FM", "examples": ["Player%20FM/588 CFNetwork/1121.2.2 Darwin/19.2.0"] }, { "user_agents": ["^Pingdom"], "bot": true }, { "user_agents": ["^Pocket Casts", "^PocketCasts\/"], "app": "Pocket Casts", "examples": ["Pocket Casts"], "info_url": "https:\/\/www.pocketcasts.com\/", "description": "A podcast app and web player", "developer_notes": "'PocketCasts' is a feed parser; 'Pocket Casts' is the app. There is also a web player.", "svg": "pocketcasts.svg" }, { "user_agents": ["^Podcast.*Addict\/"], "app": "PodcastAddict", "device": "phone", "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)"], "os": "android" }, { "user_agents": ["^ThePodcastApp.*iPhone"], "app": "The Podcast App", "os": "ios", "device": "phone", "examples": ["ThePodcastApp/6.23.0 (iPhone; iOS 15.6.1; ) player (build 6272; +https://podcast.app/)"] }, { "user_agents": ["iOS.*The Podcast App\/", "com.evolve.podcast\/", "^ThePodcastApp(?!.*(iPhone))"], "app": "The Podcast App", "os": "ios", "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)"], "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." }, { "user_agents": ["^PodcastGuru"], "app": "Podcast Guru", "os": "android", "info_url": "https:\/\/podcastguru.io\/", "description": "Podcast Guru is the simple and free podcast player" }, { "user_agents": ["^PodcastRepublic.+Android"], "app": "PodcastRepublic", "device": "phone", "examples": ["PodcastRepublic\/18.0 (Linux; U; Android 10;blueline\/QP1A.190711.020.C3)"], "os": "android" }, { "user_agents": ["podCloud"], "app": "PodCloud", "description": "Le podcast, simplement. A French-language web-based podcast player.", "bot": true, "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\/ ", "info_url": "https:\/\/podcloud.fr" }, { "user_agents": ["^Podcoin"], "app": "Podcoin" }, { "user_agents": ["^PodCruncher\/.* CFNetwork\/"], "app": "PodCruncher", "os": "ios", "examples": ["PodCruncher\/3.7.1 CFNetwork\/1125.2 Darwin\/19.4.0", "PodCruncher\/3.7.1 CFNetwork\/978.0.7 Darwin\/18.7.0"], "info_url": "https:\/\/apps.apple.com\/us\/app\/podcruncher-podcast-player\/id421894356" }, { "user_agents": ["^Podbean\/Android App", "^Podbean\/Android generic"], "app": "Podbean", "os": "android", "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"], "info_url": "https:\/\/play.google.com\/store\/apps\/details?id=com.podbean.app.podcast" }, { "user_agents": ["^Podbean\/iOS"], "app": "Podbean", "os": "ios", "examples": ["Podbean\/iOS (http:\/\/podbean.com) 5.2.0 - 19c4ff292bd09cd2ccbad22cc6755a45"], "info_url": "https:\/\/apps.apple.com\/us\/app\/podbean-podcast-app-player\/id973361050" }, { "user_agents": ["podfollowbot\/"], "app": "Podfollow", "examples": ["Mozilla\/5.0 https:\/\/podfollow.com\/crawling podfollowbot\/1.0"], "info_url": "https:\/\/podfollow.com", "description": "Podfollow, a service to help link to your podcast", "bot": true }, { "user_agents": ["PodderBot\/"], "examples": ["PodderBot\/1.0"], "app": "PodderBot", "bot": true, "description": "PodderApp bot", "info_url": "https:\/\/www.podderapp.com\/", "developer_notes": "PodderApp bot for RSS fetching / verification" }, { "user_agents": ["^Podfriend"], "app": "Podfriend", "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"], "info_url": "https:\/\/podfriend.com", "description": "Podfriend Electron app" }, { "user_agents": ["^Podhero", "^Swoot\/"], "app": "Podhero", "examples": ["Podhero%20Alpha\/4373 CFNetwork\/1197 Darwin\/20.0.0"], "info_url": "https:\/\/podhero.com", "description": "Podhero app on iOS and Android." }, { "user_agents": ["^Podkicker"], "app": "Podkicker Pro", "os": "android" }, { "user_agents": ["PodLink"], "app": "PodLink", "info_url": "https:\/\/pod.link\/faq\/crawler" }, { "user_agents": ["^PodMN\/Android"], "description": "Minnesota Podcasts Live Here", "examples": ["PodMN\/Android 1.2.6 (Android 7.1.1; SM-J510FN Build\/NMF26X)"], "info_url": "https:\/\/play.google.com\/store\/apps\/details?id=com.podmn.app", "app": "PodMN", "device": "phone", "os": "android", "svg": "podmn.svg" }, { "user_agents": ["^PodMN\/iOS"], "description": "Minnesota Podcasts Live Here", "examples": ["PodMN\/iOS 1.2.6 (iPhone XR\/13.6.1)"], "info_url": "https:\/\/apps.apple.com\/us\/app\/podmn\/id1464935818", "app": "PodMN", "device": "phone", "os": "ios", "svg": "podmn.svg" }, { "user_agents": ["PodnewsBot"], "app": "Podnews", "bot": true, "description": "Podnews runs a number of bots to read and test RSS and audio files", "info_url": "http:\/\/podnews.net" }, { "user_agents": ["podnods-crawler", "podnods"], "app": "Podnods", "bot": true, "description": "Podnods is a podcast discovery site. This user agent is for crawling podcast data.", "info_url": "https:\/\/podnods.com\/about" }, { "user_agents": ["^Podurama"], "app": "Podurama", "description": "Best free cross-platform podcast app", "examples": ["Podurama/4.0.8.1 CFNetwork/1390 Darwin/22.0.0"], "info_url": "https:\/\/podurama.com" }, { "user_agents": ["podnods-player"], "app": "Podnods", "description": "Podnods is a podcast discovery site. This user agent is for users to sample and play podcasts.", "info_url": "https:\/\/podnods.com\/about" }, { "user_agents": ["^Procast.+iOS"], "app": "Procast", "description": "Procast - The Podcast App", "info_url": "https:\/\/procast.coderocker.de\/", "os": "ios" }, { "user_agents": ["Podwatch-Pro Crawler"], "app": "PodwatchPro", "bot": true, "description": "PodwatchPro is a podcast analytics software. This user agent is for crawling podcast data.", "info_url": "https:\/\/www.agma-mmc.de\/media-analyse\/ma-podcast" }, { "user_agents": ["Podyssey App"], "app": "Podyssey", "description": "Podyssey is a community for people that love podcasts. It's like Goodreads, but for podcasts.", "info_url": "https:\/\/podyssey.fm" }, { "user_agents": ["com.toysinboxes.Echo"], "app": "Podyssey", "description": "Podyssey is a community for people that love podcasts. It's like Goodreads, but for podcasts.", "info_url": "https:\/\/podyssey.fm", "os": "ios" }, { "user_agents": ["^Podopolo"], "app": "Podopolo", "description": "Listen with passion - connect with purpose", "info_url": "https:\/\/podopolo.com\/" }, { "user_agents": ["fm.podyssey.podcasts"], "app": "Podyssey", "description": "Podyssey is a community for people that love podcasts. It's like Goodreads, but for podcasts.", "info_url": "https:\/\/podyssey.fm", "os": "android" }, { "user_agents": ["python-requests"], "bot": true }, { "user_agents": ["^radio.de\/app.+Android"], "app": "radio.de", "os": "android", "description": "Radio.de is a radio and podcast app in Germany", "info_url": "https:\/\/www.radio.de\/" }, { "user_agents": ["^Radioplayer Android app"], "app": "RadioPlayer", "os": "android", "description": "Radioplayer is a radio and podcast app, with country-specific versions available in selected countries.", "info_url": "http:\/\/radioplayer.org" }, { "user_agents": ["^Radioplayer iOS app"], "app": "RadioPlayer", "os": "ios", "description": "Radioplayer is a radio and podcast app, with country-specific versions available in selected countries.", "info_url": "http:\/\/radioplayer.org" }, { "user_agents": ["^RadioPublic\/android-", "^RadioPublic Android"], "app": "RadioPublic", "description": "RadioPublic\u2019s free, easy to use podcast player makes listening to podcasts simple, enjoyable, and fun.", "examples": ["RadioPublic\/android-2.2"], "info_url": "https:\/\/radiopublic.com\/", "svg": "radiopublic.svg", "os": "android" }, { "user_agents": ["RadioPublic iOS", "RadioPublic.+CFNetwork", "^RadioPublic\/iOS"], "app": "RadioPublic", "description": "RadioPublic\u2019s free, easy to use podcast player makes listening to podcasts simple, enjoyable, and fun.", "examples": ["RadioPublic\/iOS-2.0"], "info_url": "https:\/\/radiopublic.com\/", "svg": "radiopublic.svg", "os": "ios" }, { "user_agents": ["^Repod\/.+iOS"], "app": "Repod", "device": "phone", "examples": ["Repod\/2.9.0.363 CFNetwork\/1240.0.4 Darwin\/20.6.0 (iPhone\/X iOS\/14.7.1)"], "os": "ios", "info_url": "https:\/\/repod.io\/", "description": "Repod is a social podcast app that helps creators engage, montetization, and grow their community.", "svg": "repod.svg" }, { "user_agents": ["^Repod\/.+Android"], "app": "Repod", "device": "phone", "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"], "os": "android", "info_url": "https:\/\/repod.io\/", "description": "Repod is a social podcast app that helps creators engage, montetization, and grow their community.", "svg": "repod.svg" }, { "user_agents": ["request\\.js"], "bot": true }, { "user_agents": ["^Roku\/DVP-"], "device": "tv", "os": "roku" }, { "user_agents": ["^RSSRadio \\("], "bot": true }, { "user_agents": ["^RSSRadio"], "app": "RSS Radio", "device": "phone", "examples": ["RSSRadio7\/9252 CFNetwork\/1107.1 Darwin\/19.0.0", "RSSRadio\/9710"], "info_url": "http:\/\/rssrad.io", "os": "ios" }, { "user_agents": ["^Ruby"], "developer_notes": "The generic Ruby user-agent." }, { "user_agents": ["sp-agent"], "app": "Samsung Free", "examples": ["sp-agent"], "device": "phone", "os": "android", "info_url": "https:\/\/developer.samsung.com\/podcasts" }, { "user_agents": ["SemrushBot\/"], "app": "SEMrushBot", "examples": ["Mozilla\/5.0 (compatible; SemrushBot\/6~bl; http:\/\/www.semrush.com\/bot.html)"], "bot": true }, { "user_agents": ["SerendeputyBot\/"], "app": "Serendeputy", "examples": ["SerendeputyBot\/0.8.6 (http:\/\/serendeputy.com\/about\/serendeputy-bot)"], "bot": true, "info_url": "https:\/\/serendeputy.com\/about\/serendeputy-bot" }, { "user_agents": ["^Spotify\/.+Linux"], "app": "Spotify", "device": "pc", "os": "linux" }, { "user_agents": ["Macintosh.+Spotify\/", "^Spotify\/.+OSX"], "app": "Spotify", "device": "pc", "os": "macos" }, { "user_agents": ["Windows.+Spotify\/", "^Spotify\/.+Win32"], "app": "Spotify", "device": "pc", "os": "windows" }, { "user_agents": ["^Spotify\/.+Android"], "app": "Spotify", "device": "phone", "os": "android" }, { "user_agents": ["^Spotify\/.+iOS"], "app": "Spotify", "device": "phone", "os": "ios", "examples": ["Spotify\/8.7.10 iOS\/15.3.1 (iPhone13,2)"] }, { "user_agents": ["^Spotify\/1.0$"], "app": "Spotify cache service", "bot": true, "examples": ["Spotify\/1.0"], "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." }, { "user_agents": ["Macintosh.*AppleWebKit(?!.*(Chrome\/|GSA\/)).*Safari\/(?!.*(AdsBot\/))"], "app": "Safari", "device": "pc", "os": "macos", "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"] }, { "user_agents": ["Windows.*AppleWebKit(?!.*(Chrome\/)).*Safari\/"], "app": "Safari", "device": "pc", "os": "windows" }, { "user_agents": ["iPhone.*AppleWebKit(?!.*(AdsBot|bingbot|CriOS|GSA\/)).*Safari\/"], "app": "Safari", "device": "phone", "os": "ios" }, { "user_agents": ["iPad.*AppleWebKit(?!.*(AdsBot|bingbot|CriOS|GSA\/)).*Safari\/"], "app": "Safari", "device": "tablet", "os": "ios" }, { "user_agents": ["^Shadow"], "app": "Shadow", "os": "ios", "info_url": "https:\/\/apps.apple.com\/us\/app\/shadow\/id940127690" }, { "user_agents": ["^Slack\/"], "app": "Slack" }, { "user_agents": ["^Slackbot 1.0"], "app": "Slack", "bot": true, "examples": ["Slackbot 1.0 ( https:\/\/api.slack.com\/robots)"] }, { "user_agents": ["^Snipd\/"], "app": "Snipd", "os": "ios", "info_url": "https:\/\/www.snipd.com\/", "examples": ["Snipd\/90 CFNetwork\/1329 Darwin\/21.3.0"] }, { "user_agents": ["^Subcast"], "app": "Subcast" }, { "user_agents": ["Sonnet","^Simple Podcast Player"], "app": "Sonnet", "description": "Sonnet is a simple, easy to use podcast app aimed at new listeners", "os": "android", "info_url": "https:\/\/sonnet.fm", "examples": ["Simple Podcast Player/1.8 (Linux;Android 12) ExoPlayerLib/2.10.1"], "svg": "sonnet.svg" }, { "user_agents": ["Sonos"], "app": "Sonos", "device": "speaker", "os": "sonos" }, { "user_agents": ["^Spreaker for Android"], "app": "Spreaker", "os": "android" }, { "user_agents": ["Spreaker\/"], "app": "Spreaker" }, { "user_agents": ["support@dorada.co.uk"], "bot": true }, { "user_agents": ["^Stitcher\/Android", "^Stitcher Demo\/"], "examples": ["Stitcher Demo\/4.8.0 (Linux;Android 11) ExoPlayerLib\/2.10.7", "Stitcher\/Android"], "app": "Stitcher", "os": "android" }, { "user_agents": ["^AlexaMediaPlayer\/Stitcher"], "app": "Stitcher", "device": "speaker", "os": "alexa" }, { "user_agents": ["^Stitcher\/iOS"], "app": "Stitcher", "os": "ios", "device": "phone" }, { "user_agents": ["^StitcherBot"], "app": "Stitcher", "bot": true }, { "user_agents": ["^Storiyoh\/"], "app": "Storiyoh" }, { "user_agents": ["^Swinsian\/"], "app": "Swinsian", "device": "pc", "examples": ["Swinsian\/472 CFNetwork\/978.0.7 Darwin\/18.7.0 (x86_64)"], "info_url": "https:\/\/swinsian.com\/", "os": "macos" }, { "user_agents": ["Timpibot\/"], "app": "Timpi search crawler", "bot": true, "examples": ["Timpibot\/0.8 ( http:\/\/www.timpi.io)"] }, { "user_agents": ["TrendsmapResolver\/"], "app": "Trendsmap Resolver", "bot": true }, { "user_agents": ["^Trackable\/"], "app": "Chartable", "info_url": "https:\/\/chartable.com\/", "bot": true }, { "user_agents": ["^TuneIn Radio\/.*;Android"], "examples": ["TuneIn Radio\/24.2 (Linux;Android 10) ExoPlayerLib\/2.11.4"], "app": "TuneIn", "os": "android", "info_url": "https:\/\/play.google.com\/store\/apps\/details?id=tunein.player" }, { "user_agents": ["^TuneIn Radio Pro\/.*;Android"], "examples": ["TuneIn Radio Pro\/23.3.2 (Linux;Android 5.1.1) ExoPlayerLib\/2.10.7"], "app": "TuneIn", "os": "android", "info_url": "https:\/\/play.google.com\/store\/apps\/details?id=radiotime.player" }, { "user_agents": ["^TuneIn(%20| )Radio\/.*(CFNetwork\/|iPhone)"], "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"], "app": "TuneIn", "os": "ios", "info_url": "https:\/\/apps.apple.com\/us\/app\/tunein-radio-live-news-music\/id418987775" }, { "user_agents": ["^TuneIn(%20| )Radio(%20| )Pro\/.*(CFNetwork\/|iPhone)"], "examples": ["TuneIn Radio Pro/24.2.1; iPhone13,4; iOS/16.1"], "app": "TuneIn", "os": "ios", "info_url": "https:\/\/apps.apple.com\/us\/app\/tunein-pro-radio-sports\/id319295332" }, { "user_agents": ["^TuneIn(?!.*(CFNetwork|Android|iPhone|iPad))"], "examples": ["TuneIn Radio"], "app": "TuneIn", "info_url": "https:\/\/tunein.com\/", "developer_notes": "Other versions of this app use many other user agents." }, { "user_agents": ["^TuneIn\/"], "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"], "app": "TuneIn", "device": "pc", "info_url": "https:\/\/tunein.com\/", "developer_notes": "A TuneIn Electron app" }, { "user_agents": ["^Twitterbot"], "bot": true }, { "user_agents": ["^Typhoeus"], "bot": true }, { "user_agents": ["^VictorReader Stream"], "app": "VictorReader", "device": "speaker", "os": "victorreader" }, { "user_agents": ["^VLC\/\\d"], "app": "VLC media player", "device": "pc", "examples": ["VLC\/3.0.8 LibVLC\/3.0.8"], "info_url": "https:\/\/www.videolan.org\/vlc\/" }, { "user_agents": ["Wget"], "app": "Wget", "bot": true }, { "user_agents": ["^Winamp"], "app": "Winamp", "device": "pc", "examples": ["WinampMPEG\/2.7"], "os": "windows" }, { "user_agents": ["^NSPlayer", "^WMPlayer\/"], "app": "Windows Media Player", "device": "pc", "examples": ["NSPlayer\/12.00.18362.0418 WMFSDK\/12.00.18362.0418"], "os": "windows" }, { "user_agents": ["^WordPress"], "bot": true }, { "user_agents": ["iPhone.*XING"], "app": "XING", "device": "phone", "os": "ios", "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"], "info_url": "https:\/\/www.xing.com\/", "description": "German version of LinkedIn" }, { "user_agents": ["YandexBot\/"], "app": "YandexBot", "bot": true }, { "user_agents": ["^yapa\/"], "app": "Yapa" }, { "user_agents": ["stagefright\/"], "os": "android" }, { "user_agents": ["^Podimo\/.*iOS"], "app": "Podimo", "device": "phone", "os": "ios", "examples": ["Podimo\/1.11.3 build 121\/iOS 13.3"], "info_url": "https:\/\/apps.apple.com\/dk\/app\/podimo-a-world-of-podcasts\/id1476538730" }, { "user_agents": ["^Podimo\/.*Android"], "app": "Podimo", "device": "phone", "os": "android", "examples": ["Podimo\/1.11.3 build 91\/Android 28"], "info_url": "https:\/\/play.google.com\/store\/apps\/details?id=com.podimo&hl=en_US" }, { "user_agents": ["BingPreview\/", "adidxbot\/", "bingbot\/"], "app": "Microsoft Bingbot", "bot": true, "info_url": "https:\/\/www.bing.com\/webmaster\/help\/which-crawlers-does-bing-use-8c184ec0", "examples": ["Mozilla\/5.0 (Windows NT 6.1; WOW64) AppleWebKit\/534 (KHTML, like Gecko) BingPreview\/1.0b"] }, { "user_agents": ["^msnbot\/"], "bot": true }, { "user_agents": ["^Deezer Podcasters\/1\\.0"], "bot": true, "app": "Deezer Podcasters" }, { "user_agents": ["^devcasts\/.*CFNetwork"], "app": "DevCasts", "os": "ios", "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.", "examples": ["devcasts\/1.0.1.00 CFNetwork\/1197 Darwin\/20.0.0"], "info_url": "http:\/\/devcasts.co\/" }, { "user_agents": ["^got\/"], "bot": true, "info_url": "https:\/\/play.google.com\/store\/apps\/details?id=com.podimo&hl=en_US", "developer_notes": "Got is a HTTP library for NodeJs" }, { "user_agents": ["INA dlweb"], "bot": true, "app": "l'Institut national de l'audiovisuel", "info_url": "https:\/\/institut.ina.fr\/collecte-du-depot-legal-web", "developer_notes": "Institut National de l'Audiovisuel is a repository of all French radio and television audiovisual archives." }, { "user_agents": ["^Instagram\/"], "app": "Instagram", "examples": ["Instagram\/252729634 CFNetwork\/1126 Darwin\/19.5.0"] }, { "user_agents": ["^SoundOn\/[\\d.]+s+\\(Linux;Android", "^SoundOn\/[^12]\\.d+\\.d+$", "^SoundOn\/1\\.[^1][^0-2]?\\.d+$"], "app": "SoundOn", "device": "phone", "examples": ["SoundOn\/1.9.17 (Linux;Android 10) ExoPlayerLib\/2.9.4"], "os": "android" }, { "user_agents": ["^SoundOn\/1\\.1[0-2]\\.\\d+$", "^SoundOn\/2\\.\\d+\\.\\d+$", "^SoundOn\/[\\d.]+s+\\(iOS"], "app": "SoundOn", "device": "phone", "examples": ["SoundOn\/1.10.1", "SoundOn\/2.2.0", "SoundOn\/2.2.2 (iOS)"], "os": "ios" }, { "user_agents": ["^SoundOn\/[\\d.]+\\s+\\(bot"], "bot": true }, { "user_agents": ["^Podverse\/.*Android Mobile App"], "app": "Podverse", "device": "phone", "os": "android", "info_url": "https:\/\/play.google.com\/store\/apps\/details?id=com.podverse&hl=en_US", "description": "Open source podcast catcher for Android, with clip-sharing, playlists, device syncing and more.", "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"], "developer_notes": "The standard device user agent was formerly concatenated to the end of the Podverse\/Android Mobile App\/ user agent." }, { "user_agents": ["^Podverse\/iOS Mobile App"], "app": "Podverse", "device": "phone", "os": "ios", "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"], "description": "Open source podcast catcher for iOS, with clip-sharing, playlists, device syncing and more.", "info_url": "https:\/\/apps.apple.com\/us\/app\/podverse\/id1390888454", "developer_notes": "The standard device user agent was formerly concatenated to the end of the Podverse\/iOS Mobile App\/ user agent." }, { "user_agents": ["^Podverse\/Feed Parser"], "bot": true, "app": "Podverse Feed Parser", "info_url": "https:\/\/podverse.fm", "description": "The Podverse feed parser.", "developer_notes": "This service parses publicly-accessible RSS feeds on a timer, then stores parsed data in the Podverse database." }, { "user_agents": ["^Podcast\/1."], "app": "\u5c0f\u5b87\u5b99", "info_url": "https:\/\/www.coolapk.com\/apk\/app.podcast.cosmos", "description": "Cosmos, a chinese podcast app" }, { "user_agents": ["^Xiaoyuzhou\/.*Android\/"], "app": "Xiao Yu Zhou", "description": "Xiao Yu Zhou, a podcast app", "os": "android", "examples": ["Xiaoyuzhou\/1.9.6 Android\/10"] }, { "user_agents": ["^Xiaoyuzhou\/(?!.*(Android\/))"], "app": "Xiao Yu Zhou", "description": "Xiao Yu Zhou, a podcast app", "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", "os": "ios", "examples": ["Xiaoyuzhou\/1.9.0", "Xiaoyuzhou\/1.5.1"] }, { "user_agents": ["^yacybot"], "app": "YaCy", "bot": true, "description": "Decentralized Web Search", "info_url": "http:\/\/yacy.net\/bot.html", "examples": ["yacybot (\/global; amd64 Linux 5.9.8-zen1-1-zen; java 1.8.0_265; Europe\/de) http:\/\/yacy.net\/bot.html"] }, { "user_agents": ["^Podcast-CriticalMention\/"], "app": "Critical Mention", "description": "Critical Mention is a business intelligence company, monitoring podcasts on the web for their clients", "bot": true, "examples": ["Podcast-CriticalMention\/1.0"] }, { "user_agents": ["^RSSOwl.*Windows"], "app": "RSSOwl", "description": "A Mac and Windows app, to help organize, search, and read feeds", "device": "pc", "os": "windows", "info_url": "http:\/\/www.rssowl.org\/", "examples": ["RSSOwl\/2.2.1.201312301314 (Windows; U; en)"] }, { "user_agents": ["^ltx71 "], "app": "LTX71", "info_url": "http:\/\/ltx71.com\/", "description": "We continuously scan the internet for security research purposes.", "bot": true, "examples": ["ltx71 - (http:\/\/ltx71.com\/)"] }, { "user_agents": ["^bl.uk_ldfc_bot"], "app": "British Library", "info_url": "http:\/\/www.bl.uk\/aboutus\/legaldeposit\/websites\/websites\/faqswebmaster\/index.html", "description": "British Library's legal deposit web crawler", "bot": true, "examples": ["bl.uk_ldfc_bot\/3.4.0-20200518 ( http:\/\/www.bl.uk\/aboutus\/legaldeposit\/websites\/websites\/faqswebmaster\/index.html)"] }, { "user_agents": ["Archive-It;"], "app": "Internet Archive", "info_url": "https:\/\/archive-it.org\/files\/site-owners-special.html", "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.", "bot": true, "examples": ["Mozilla\/5.0 (compatible; special_archiver; Archive-It; http:\/\/archive-it.org\/files\/site-owners-special.html)"] }, { "user_agents": ["VurblBot"], "app": "Vurbl", "info_url": "https:\/\/vurbl.com\/about-us\/", "description": "An audio streaming destination", "bot": true, "examples": ["Mozilla\/5.0 https:\/\/vurbl.com VurblBot\/1.0"] }, { "user_agents": ["PetalBot"], "app": "PetalBot", "info_url": "https:\/\/aspiegel.com\/petalbot", "description": "PetalBot is an automatic program of the Petal search engine.", "bot": true, "examples": ["Mozilla\/5.0 (Linux; Android 7.0;) AppleWebKit\/537.36 (KHTML, like Gecko) Mobile Safari\/537.36 (compatible; PetalBot; https:\/\/aspiegel.com\/petalbot)"] }, { "user_agents": ["PodhoundBeta"], "app": "Podhound", "info_url": "http:\/\/podhound.co", "description": "AI-powered podcast discovery", "bot": true, "examples": ["PodhoundBeta"], "developer_notes": "'It grabs it once to get the audio file length.', says the developer." }, { "user_agents": ["hermespod.com\/"], "app": "HermesPod", "info_url": "http:\/\/hermespod.com\/", "description": "HermesPod is the easiest way to subscribe, download and listen to podcasts. It's a Windows app.", "examples": ["hermespod.com\/v1.5.x"], "developer_notes": "HermesPod is no longer supported by its author." }, { "user_agents": ["^gvfs\/", "^rhythmbox\/"], "app": "Rhythmbox", "info_url": "https:\/\/gitlab.gnome.org\/GNOME\/rhythmbox", "description": "Rhythmbox is your one-stop multimedia application, supporting a music library, multiple playlists, internet radio, and more.", "examples": ["gvfs\/1.46.1"], "developer_notes": "The new UA is Rhythmbox: https:\/\/gitlab.gnome.org\/GNOME\/rhythmbox\/-\/issues\/1855" }, { "user_agents": ["archive.org_bot"], "app": "Archive.org", "info_url": "https:\/\/archive.org\/details\/archive.org_bot", "description": "The Internet Archive is a nonprofit digital library that preserves web data and makes it available for research purposes through the Wayback Machine.", "bot": true, "examples": ["Mozilla\/5.0 (compatible; archive.org_bot http:\/\/archive.org\/details\/archive.org_bot)"] }, { "user_agents": ["AAABot"], "app": "AAABot - unknown bot", "bot": true, "examples": ["AAABot"] }, { "user_agents": ["^MixerBox\/.*Android"], "app": "MixerBox", "os": "android", "examples": ["MixerBox\/12.33 (Linux;Android 11) ExoPlayerLib\/2.11.1"] }, { "user_agents": ["^MixerBox\/.*iOS"], "app": "MixerBox", "os": "ios", "examples": ["MixerBox\/807.iOS (iPhone; iOS 14.4; en_US)"] }, { "user_agents": ["^Podcastindex\\.org\/"], "app": "Podcastindex.org", "bot": true, "svg": "podcast-index.svg", "info_url": "https:\/\/podcastindex.org\/", "examples": ["Podcastindex.org\/v0.3.3 (Aggrivate)"] }, { "user_agents": ["^sp-agent"], "app": "Samsung Podcasts", "info_url": "https:\/\/developer.samsung.com\/podcasts", "examples": ["sp-agent"] }, { "user_agents": ["^MixerBox\/"], "app": "Mixerbox", "info_url": "https:\/\/www.mixerbox.com\/", "examples": ["MixerBox\/869 CFNetwork\/1300.1 Darwin\/21.0.0"], "developer_notes": "This app appears to use a RSS useragent of AmazonCloudFront (or it may simply be blank)" }, { "user_agents": ["^Tumult"], "app": "Tumult", "description": "Et le podcast devient social", "device": "phone", "examples": ["Tumult"], "info_url": "https:\/\/tumult-podcast.com\/" }, { "user_agents": ["^Vodacast"], "app": "Vodacast", "description": "Podcasts with deeper digital stories", "device": "phone", "info_url": "https:\/\/auddiainc.com\/vodacast-app\/" }, { "user_agents": ["^Palco MP3"], "app": "Palco MP3", "info_url": "https:\/\/www.palcomp3.com.br\/", "examples": ["Palco MP3\/3.13.18 (Linux;Android 11) ExoPlayerLib\/2.11.0"] }, { "user_agents": ["^TheEconomist-Darwin-android"], "app": "Economist Espresso", "device": "phone", "os": "android", "examples": ["TheEconomist-Darwin-android-2.1.1-master-2999-2001024"] }, { "user_agents": ["^TheEconomist-Darwin-ios"], "app": "Economist Espresso", "device": "phone", "os": "ios", "examples": ["TheEconomist-Darwin-ios-2.1.1-master-2999-2001024"] }, { "user_agents": ["^AudioWave iOS"], "app": "AudioWave", "device": "phone", "os": "ios", "info_url": "https:\/\/apps.apple.com\/be\/app\/audiowave-podcast-player\/id1602776751", "examples": ["AudioWave iOS"] }, { "user_agents": ["iOS VG Hermes\/"], "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_"], "app": "VG iOS app", "description": "VG iOS app", "os": "ios" }, { "user_agents": ["iOS Aftonbladet Hermes\/"], "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_"], "app": "Aftonbladet iOS app", "description": "Aftonbladet iOS app", "os": "ios" }, { "user_agents": ["iOS Sportbladet Hermes\/"], "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_"], "app": "Sportbladet iOS app", "description": "Sportbladet iOS app", "os": "ios" }, { "user_agents": ["iOS AP Hermes\/"], "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_"], "app": "Aftenposten iOS app", "description": "Aftenposten iOS app", "os": "ios" }, { "user_agents": ["iOS BT Hermes\/"], "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_"], "app": "Bergens Tidende iOS app", "description": "Bergens Tidende iOS app", "os": "ios" }, { "user_agents": ["iOS SA Hermes\/"], "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_"], "app": "Stavanger Aftenblad iOS app", "description": "Stavanger Aftenblad iOS app", "os": "ios" }, { "user_agents": ["iOS SvD Hermes\/"], "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_"], "app": "Svenska Dagbladet iOS app", "description": "Svenska Dagbladet iOS app", "os": "ios" }, { "user_agents": ["iOS E24 Hermes\/"], "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_"], "app": "E24 iOS app", "description": "E24 iOS app", "os": "ios" }, { "user_agents": ["iOS Askoyvaringen Hermes\/"], "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_"], "app": "Askoyvaringen iOS app", "description": "Askoyvaringen iOS app", "os": "ios" }, { "user_agents": ["iOS Bygdanytt Hermes\/"], "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_"], "app": "Bygdanytt iOS app", "description": "Bygdanytt iOS app", "os": "ios" }, { "user_agents": ["iOS Strilen Hermes\/"], "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_"], "app": "Strilen iOS app", "description": "Strilen iOS app", "os": "ios" }, { "user_agents": ["iOS Vestnytt Hermes\/"], "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_"], "app": "Vestnytt iOS app", "description": "Vestnytt iOS app", "os": "ios" }, { "user_agents": ["Android VG Hermes\/"], "examples": ["Android VG Hermes/1000094692 app vg_app_ VG/Snarvei/1000094692 VG-App"], "app": "VG Android app", "description": "VG Android app", "os": "android" }, { "user_agents": ["Android Aftonbladet Hermes\/"], "examples": ["Android Aftonbladet Hermes/1000094692 _app_"], "app": "Aftonbladet Android app", "description": "Aftonbladet Android app", "os": "android" },{ "user_agents": ["Android Sportbladet Hermes\/"], "examples": ["Android Sportbladet Hermes/1000094692 _app_"], "app": "Sportbladet Android app", "description": "Sportbladet Android app", "os": "android" }, { "user_agents": ["Android AP Hermes\/"], "examples": ["Android AP Hermes/1000094692 _app_"], "app": "Aftenposten Android app", "description": "Aftenposten Android app", "os": "android" }, { "user_agents": ["Android BT Hermes\/"], "examples": ["Android BT Hermes/1000094692 _app_"], "app": "Bergens Tidende Android app", "description": "Bergens Tidende Android app", "os": "android" }, { "user_agents": ["Android SA Hermes\/"], "examples": ["Android SA Hermes/1000094692 _app_"], "app": "Stavanger Aftenblad Android app", "description": "Stavanger Aftenblad Android app", "os": "android" }, { "user_agents": ["SvD Hermes\/"], "examples": ["SvD Hermes/999999999 _app_"], "app": "Svenska Dagbladet Android app", "description": "Svenska Dagbladet Android app", "os": "android" }, { "user_agents": ["Android E24 Hermes\/"], "examples": ["Android E24 Hermes/1000094692 _app_"], "app": "E24 Android app", "description": "E24 Android app", "os": "android" }, { "user_agents": ["^Riddler "], "examples": ["Riddler (http:\/\/riddler.io\/about)"], "app": "F-Secure Riddler", "description": "an online research project which investigates algorithms for mapping the topology of the Internet", "bot": true }, { "user_agents": ["^RSStT"], "examples": ["RSStT\/2.2.1 RSS Reader"], "app": "RSS to Telegram", "description": "an RSS to Telegram bot", "info_url": "https:\/\/apps.apple.com\/be\/app\/audiowave-podcast-player\/id1602776751", "bot": true }, { "user_agents": ["^Newsly"], "examples": ["Newsly"], "app": "Newsly", "device": "phone", "description": "Stop Scrolling, Start Listening.", "info_url": "https:\/\/www.newsly.me\/" }, { "user_agents": ["KaiOS Downloader"], "examples": ["KaiOS Downloader"], "os": "kaios", "device": "phone", "developer_notes": "This is the KaiOS Downloader library, and this could refer to any app on this platform" }, { "user_agents": ["CPod\/"], "examples": ["CPod\/1.27.1 (github.com\/z-------------)"], "info_url": "https:\/\/github.com\/z-------------\/CPod", "app": "CPod", "device": "pc", "description": "A simple, beautiful podcast app, for Windows, MacOS and Linux" }, { "user_agents": ["DataForSeoBot\/"], "examples": ["Mozilla\/5.0 (compatible; DataForSeoBot\/1.0; +https:\/\/dataforseo.com\/dataforseo-bot)"], "info_url": "https:\/\/dataforseo.com\/dataforseo-bot", "app": "DataforSEO", "bot": true, "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." }, { "user_agents": ["Bullhorn\/"], "examples": ["Bullhorn\/1.0 (+http:\/\/bullhorn.fm\/)"], "info_url": "http:\/\/bullhorn.fm\/", "app": "Bullhorn" }, { "user_agents": ["gPodder\/.*Linux"], "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"], "info_url": "http:\/\/gpodder.org\/", "app": "gPodder", "device": "pc" }, { "user_agents": ["AirableBot-Podcast\/"], "examples": ["AirableBot-Podcast\/1.0 (+https\/\/www.airablenow.com)"], "info_url": "https\/\/www.airablenow.com", "bot": true, "description": "An aggregator of internet radio and podcasts, for connected devices." }, { "user_agents": ["Podcorn\/"], "examples": ["Podcorn\/1.0"], "info_url": "https:\/\/podcorn.com\/", "bot": true, "description": "The leading podcast influencer marketplace. Connecting unique voices to unique brands for native advertising." }, { "user_agents": ["RedCircle"], "examples": ["RedCircle"], "info_url": "https:\/\/redcircle.com\/", "bot": true, "app": "RedCircle", "description": "A platform for podcasts and brands to scale their message." }, { "user_agents": ["ProCast"], "examples": ["ProCast\/1 CFNetwork\/1240.0.4 Darwin\/20.6.0"], "info_url": "https:\/\/podcast-app.de\/", "description": "The new generation of Podcast player." }, { "user_agents": ["AnchorImport"], "examples": ["AnchorImport\/1.0"], "description": "Anchor's tool for importing podcasts", "bot": true, "developer_notes": "This useragent is used during a user importing a podcast to the Anchor platform" }, { "user_agents": ["^Podio\/"], "examples": ["Podio\/1.0"], "description": "Podcasts + Radio = Podio", "info_url": "https:\/\/podio.radio\/", "bot": true, "developer_notes": "This useragent appears to download and cache audio" }, { "user_agents": ["^Playapod"], "app": "Playapod", "examples": ["Playapod/2.4.11"], "description": "Best Cross-Platform Podcast and News App", "info_url": "https:\/\/playapod.com\/" }, { "user_agents": ["Zune\/"], "app": "Zune", "examples": ["Zune\/4.8"] }, { "user_agents": ["Headliner\/"], "app":"Headliner", "examples": ["Headliner/1.0.0 +https://headliner.app"], "bot":true, "developer_notes": "A tool that takes audio to reformat it for video platforms" }, { "user_agents": ["Iframely\/"], "app":"Iframely", "examples": ["Iframely/1.3.1 (+https://iframely.com/docs/about)"], "info_url": "https:\/\/iframely.com\/docs\/about" }, { "user_agents": ["^(radio\\.[a-zA-Z]{2,3}|GetPodcast) [0-9]+.[0-9]+.[0-9]+ \\([a-zA-Z]+;( iPhone OS)? .+; .+\\)$"], "app": "radio.net", "device": "phone", "os": "ios", "info_url": "https:\/\/www.radio.net\/", "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)"] }, { "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].+)?$"], "app": "radio.net", "device": "phone", "os": "android", "info_url": "https:\/\/www.radio.net\/", "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"] }, { "user_agents": ["^SYOK.+(iOS|Darwin)"], "app": "SYOK", "device": "phone", "os": "ios", "info_url": "https:\/\/syok.my\/", "examples": ["SYOK/965 CFNetwork/1406.0.4 Darwin/22.4.0"] }] ================================================ FILE: data/podlove_v2_schema.json ================================================ {"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"}}} ================================================ FILE: devbox.d/php/php-fpm.conf ================================================ [global] pid = ${PHPFPM_PID_FILE} error_log = ${PHPFPM_ERROR_LOG_FILE} daemonize = yes [www] ; user = www-data ; group = www-data listen = 127.0.0.1:${PHPFPM_PORT} ; listen.owner = www-data ; listen.group = www-data pm = dynamic pm.max_children = 5 pm.start_servers = 2 pm.min_spare_servers = 1 pm.max_spare_servers = 3 chdir = / ================================================ FILE: devbox.d/php/php.ini ================================================ [php] ; 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 ; memory_limit = 128M ; expose_php = Off ================================================ FILE: devbox.json ================================================ { "$schema": "https://raw.githubusercontent.com/jetpack-io/devbox/0.10.2/.schema/devbox.schema.json", "packages": ["php@latest", "php83Packages.composer@latest", "nodejs@20"], "shell": { "init_hook": ["echo 'Welcome to devbox!' > /dev/null"], "scripts": { "bootstrap": "make install", "build": "make build --always-make", "client": ["cd client", "npm run dev"] } } } ================================================ FILE: docker-compose.yml ================================================ services: db: image: mysql:latest container_name: db restart: unless-stopped environment: MYSQL_DATABASE: wordpress MYSQL_ROOT_PASSWORD: wordpress MYSQL_USER: wordpress MYSQL_PASSWORD: wordpress expose: - 3306 networks: - app-network wordpress: build: . ports: - 8080:80 restart: always depends_on: - db environment: WORDPRESS_DB_HOST: db:3306 WORDPRESS_DB_USER: wordpress WORDPRESS_DB_PASSWORD: wordpress WORDPRESS_DB_NAME: wordpress networks: - app-network networks: app-network: driver: bridge ================================================ FILE: includes/about.php ================================================

Podlove Publisher joins the networks section.
Get an overview of your network and quickly switch between podcasts.

  • The network dashboard provides a birds-eye view over your podcast empire.
  • Manage templates in your network and access them in all podcasts.
  • Create podcast lists and use them in templates spanning multiple podcasts, for example to list the 10 latest episodes in your network.


Upgrade Notices

Custom Template Parameters are Handled Differently.

This section is relevant if you are using templates with custom variables passed in shortcodes, like this:

[podlove-template template="example" param="foo" dog="wow"]

Before 2.1 you have accessed those variables simply by calling param and dog. For compatibility, all shortcode options are now prefixed with option., so you need to change those calls to option.param and option.dog etc.

Other

The Flattr parameter in [podlove-episode-contributor-list] now defaults to "no". If you like to include Flattr, use [podlove-episode-contributor-list flattr="yes"]

[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.

[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.

It is now preferred to reference templates using the template parameter instead of id: [podlove-template template="example"].


register_routes(); }); class WP_REST_PodloveOnboarding_Controller extends \WP_REST_Controller { /** * Constructor. */ public function __construct() { $this->namespace = 'podlove/v2'; $this->rest_base = 'admin'; } /** * Register the component routes. */ public function register_routes() { register_rest_route($this->namespace, '/'.$this->rest_base.'/onboarding', [ [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [$this, 'get_onboarding_options'], 'permission_callback' => [$this, 'get_item_permissions_check'], ], [ 'methods' => \WP_REST_Server::EDITABLE, 'callback' => [$this, 'update_onboarding_options'], 'permission_callback' => [$this, 'update_item_permissions_check'], [ 'args' => [ 'banner_hide' => [ 'description' => __('Hide the banner', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'boolean' ], 'type' => [ 'description' => __('Type of the onboarding', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'string', 'enum' => ['start', 'import', 'reset'] ], ] ] ], ]); } /** * GET route. * * @param mixed $request */ public function get_item_permissions_check($request) { if (!current_user_can('administrator')) { return new \Podlove\Api\Error\ForbiddenAccess(); } return true; } public function get_onboarding_options($request) { $banner_flag = Onboarding::is_banner_hide(); $type = Onboarding::get_onboarding_type(); return new \Podlove\Api\Response\OkResponse([ 'banner_hide' => $banner_flag, 'type' => $type ]); } /** * PUT/PATCH/POST route. * * @param mixed $request */ public function update_item_permissions_check($request) { if (!current_user_can('administrator')) { return new \Podlove\Api\Error\ForbiddenAccess(); } return true; } public function update_onboarding_options($request) { if (isset($request['banner_hide'])) { $option = $request['banner_hide']; Onboarding::set_banner_hide($option); } if (isset($request['type'])) { $option = $request['type']; Onboarding::set_onboarding_type($option); } return new \Podlove\Api\Response\OkResponse([ 'status' => 'ok', ]); } } ================================================ FILE: includes/api/admin/plus.php ================================================ register_routes(); }); class WP_REST_PodlovePlus_Controller extends \WP_REST_Controller { public function __construct() { $this->namespace = 'podlove/v2'; $this->rest_base = 'admin/plus'; } public function register_routes() { register_rest_route($this->namespace, '/'.$this->rest_base.'/episodes_for_migration', [ [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [$this, 'get_episodes_for_migration'], 'permission_callback' => [$this, 'get_item_permissions_check'], ], ]); register_rest_route($this->namespace, '/'.$this->rest_base.'/features', [ [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [$this, 'get_features'], 'permission_callback' => [$this, 'get_item_permissions_check'], ], ]); register_rest_route($this->namespace, '/'.$this->rest_base.'/set_feature', [ [ 'methods' => \WP_REST_Server::EDITABLE, 'callback' => [$this, 'set_feature'], 'permission_callback' => [$this, 'get_item_permissions_check'], ], ]); register_rest_route($this->namespace, '/'.$this->rest_base.'/token', [ [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [$this, 'get_token'], 'permission_callback' => [$this, 'get_item_permissions_check'], ], ]); register_rest_route($this->namespace, '/'.$this->rest_base.'/validate_token', [ [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [$this, 'validate_token'], 'permission_callback' => [$this, 'get_item_permissions_check'], ], ]); register_rest_route($this->namespace, '/'.$this->rest_base.'/save_token', [ [ 'methods' => \WP_REST_Server::EDITABLE, 'callback' => [$this, 'save_token'], 'permission_callback' => [$this, 'get_item_permissions_check'], ], ]); } public function get_episodes_for_migration($request) { $episodes = Episode::find_all_by_time(); if (empty($episodes)) { return new \Podlove\Api\Response\OkResponse([ 'episodes' => [], ]); } $episodes_with_files = []; foreach ($episodes as $episode) { $media_files = $episode->media_files(); $episode_title = $episode->title(); $files = array_map(function ($file) { $local_url = FileStorage::get_local_file_url($file); $plus_url = $file->get_file_url(); return [ 'local_url' => $local_url, 'plus_url' => $plus_url, 'filename' => $file->get_file_name(), ]; }, $media_files); $episodes_with_files[] = [ 'episode_title' => $episode_title, 'files' => $files, ]; } return new \Podlove\Api\Response\OkResponse([ 'episodes' => $episodes_with_files, ]); } public function get_features($request) { $podcast = Podcast::get(); return new \Podlove\Api\Response\OkResponse([ 'file_storage' => $podcast->plus_enable_storage, 'feed_proxy' => $podcast->plus_enable_proxy, ]); } public function set_feature($request) { $feature = $request->get_param('feature'); $value = (bool) $request->get_param('value'); $valid_features = ['fileStorage', 'feedProxy']; if (!in_array($feature, $valid_features)) { return new \Podlove\Api\Error\ArgumentError(message: 'Invalid feature'); } $podcast = Podcast::get(); if ($feature === 'fileStorage') { $podcast->plus_enable_storage = $value; } if ($feature === 'feedProxy') { $podcast->plus_enable_proxy = $value; } $podcast->save(); if ($feature === 'fileStorage') { do_action('podlove_plus_enable_storage_changed', $value); } if ($feature === 'feedProxy') { do_action('podlove_plus_enable_proxy_changed', $value); } return new \Podlove\Api\Response\OkResponse(); } public function get_token($request) { $plus_module = \Podlove\Modules\Plus\Plus::instance(); $token = $plus_module->get_module_option('plus_api_token'); return new \Podlove\Api\Response\OkResponse([ 'token' => $token ?: '' ]); } public function validate_token($request) { $plus_module = \Podlove\Modules\Plus\Plus::instance(); $token = $plus_module->get_module_option('plus_api_token'); if (!$token) { return new \Podlove\Api\Response\OkResponse([ 'user' => null ]); } $api = new \Podlove\Modules\Plus\API($plus_module, $token); $user = $api->get_me(); if ($user && isset($user->email)) { return new \Podlove\Api\Response\OkResponse([ 'user' => [ 'email' => $user->email ] ]); } return new \Podlove\Api\Response\OkResponse([ 'user' => null ]); } public function save_token($request) { $token = $request->get_param('token'); $token = sanitize_text_field($token); $plus_module = \Podlove\Modules\Plus\Plus::instance(); $plus_module->update_module_option('plus_api_token', $token); return new \Podlove\Api\Response\OkResponse([ 'success' => true ]); } public function get_item_permissions_check($request) { if (!current_user_can('administrator')) { return new \Podlove\Api\Error\ForbiddenAccess(); } return true; } } ================================================ FILE: includes/api/analytics.php ================================================ [ 'sanitize_callback' => function ($param, $request, $key) { return $param == 'csv' ? 'csv' : 'json'; }, 'default' => 'json', ], ]; register_rest_route('podlove/v1', 'analytics/episodes', [ 'methods' => 'GET', 'callback' => 'podlove_api_analytics_episodes', 'permission_callback' => 'podlove_api_analytics_permission_callback', 'args' => $args, ]); register_rest_route('podlove/v1', 'analytics/episodes/(?P[\d]+)', [ 'methods' => 'GET', 'callback' => 'podlove_api_analytics_episode', 'permission_callback' => 'podlove_api_analytics_permission_callback', 'args' => $args, ]); register_rest_route('podlove/v1', 'analytics/episodes/(?P[\d]+,[\d,]+)', [ 'methods' => 'GET', 'callback' => 'podlove_api_analytics_episodes_selected', 'permission_callback' => 'podlove_api_analytics_permission_callback', 'args' => $args, ]); register_rest_route('podlove/v2', 'analytics/episodes', [ 'methods' => 'GET', 'callback' => 'podlove_api_analytics_episodes', 'permission_callback' => 'podlove_api_analytics_permission_callback', 'args' => $args, ]); register_rest_route('podlove/v2', 'analytics/episodes/(?P[\d]+)', [ 'methods' => 'GET', 'callback' => 'podlove_api_analytics_episode', 'permission_callback' => 'podlove_api_analytics_permission_callback', 'args' => $args, ]); register_rest_route('podlove/v2', 'analytics/episodes/(?P[\d]+,[\d,]+)', [ 'methods' => 'GET', 'callback' => 'podlove_api_analytics_episodes_selected', 'permission_callback' => 'podlove_api_analytics_permission_callback', 'args' => $args, ]); } function podlove_api_analytics_permission_callback($request) { if (!current_user_can('podlove_read_analytics')) { return new WP_Error( 'rest_forbidden', esc_html__('You cannot view the analytics resource.'), ['status' => \Podlove\Api\Permissons::authorization_status_code()] ); } return true; } function podlove_api_csv_response($data) { header('Content-Type: text/csv'); header('Content-Disposition: attachment; filename=podlove-episode-downloads.csv'); $csv = Writer::createFromFileObject(new \SplTempFileObject()); // $csv->setEncodingFrom('utf-8'); $headers = array_keys($data[0]); $csv->insertOne($headers); $csv->insertAll($data); echo $csv; exit; } function podlove_api_analytics_episodes(WP_REST_Request $request) { $data = \Podlove\Downloads_List_Data::get_data(); if ($request->get_param('format') == 'csv') { podlove_api_csv_response($data); } else { $data = array_map('podlove_api_analytics_prepare_episode', $data); return new WP_REST_Response($data); } } function podlove_api_analytics_episodes_selected(WP_REST_Request $request) { $ids = explode(',', $request['ids']); $ids = array_map(function ($id) { return (int) trim($id); }, $ids); $data = \Podlove\Downloads_List_Data::get_data(); $data = array_filter($data, function ($row) use ($ids) { return in_array($row['post_id'], $ids); }); $data = array_values($data); if ($request->get_param('format') == 'csv') { podlove_api_csv_response($data); } else { $data = array_map('podlove_api_analytics_prepare_episode', $data); return new WP_REST_Response($data); } } function podlove_api_analytics_episode(WP_REST_Request $request) { $id = (int) $request['id']; $post = get_post($id); if (empty($post)) { return new WP_REST_Response([], 404); } $data = \Podlove\Downloads_List_Data::get_data(); $data = array_map('podlove_api_analytics_prepare_episode', $data); $data = array_filter($data, function ($row) use ($id) { return $row['post_id'] == $id; }); $data = array_values($data); if ($request->get_param('format') == 'csv') { podlove_api_csv_response($data); } else { $data = array_map('podlove_api_analytics_prepare_episode', $data); return new WP_REST_Response($data[0]); } } function podlove_api_analytics_prepare_episode($item) { $item['_links'] = [ 'self' => rest_url('podlove/v1/analytics/episodes/'.$item['post_id']), 'podlove:episode' => rest_url('wp/v2/episodes/'.$item['post_id']), ]; return $item; } ================================================ FILE: includes/api/api.php ================================================ register_routes(); }); class WP_REST_PodloveChapters_Controller extends \WP_REST_Controller { public function __construct() { $this->namespace = 'podlove/v2'; $this->rest_base = 'chapters'; } public function register_routes() { register_rest_route($this->namespace, '/'.$this->rest_base.'/(?P[\d]+)', [ 'args' => [ 'id' => [ 'description' => __('Unique identifier for the episode.', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'integer', 'required' => 'true' ], ], [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [$this, 'get_item'], 'permission_callback' => [$this, 'get_item_permissions_check'], ], [ 'args' => [ 'chapters' => [ 'description' => __('List of chapters, please use MP4Chaps format.', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'array', 'items' => [ 'type' => 'object', 'properties' => [ 'start' => [ 'description' => __('Chapter begin timestamp', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'string', 'required' => 'true' ], 'title' => [ 'description' => __('Chapter title', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'string', 'required' => 'true' ], 'href' => [ 'description' => __('Chapter url', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'string' ], 'image' => [ 'description' => __('Image', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'string' ] ] ], 'required' => 'true', 'validate_callback' => '\Podlove\Api\Validation::chapters' ] ], 'methods' => \WP_REST_Server::CREATABLE, 'callback' => [$this, 'create_item'], 'permission_callback' => [$this, 'create_item_permissions_check'], ], [ 'args' => [ 'chapters' => [ 'description' => __('List of chapters, please use mp4chaps format.', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'array', 'items' => [ 'type' => 'object', 'properties' => [ 'start' => [ 'description' => __('Chapter begin timestamp', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'string', 'required' => 'true' ], 'title' => [ 'description' => __('Chapter title', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'string', 'required' => 'true' ], 'href' => [ 'description' => __('Chapter url', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'string' ], 'image' => [ 'description' => __('Image', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'string' ] ] ], 'required' => 'true', 'validate_callback' => '\Podlove\Api\Validation::chapters' ] ], 'description' => __('Edit the chapters list to an epsiode, old chapter list will be deleted.', 'podlove-podcasting-plugin-for-wordpress'), 'methods' => \WP_REST_Server::EDITABLE, 'callback' => [$this, 'update_item'], 'permission_callback' => [$this, 'update_item_permissions_check'], ], [ 'methods' => \WP_REST_Server::DELETABLE, 'callback' => [$this, 'delete_item'], 'permission_callback' => [$this, 'delete_item_permissions_check'], ] ]); } public function get_item_permissions_check($request) { return true; } public function get_item($request) { $id = $request->get_param('id'); $episode = Episode::find_by_id($id); if ($episode) { $data = array_map(function ($c) { $c->title = html_entity_decode(trim($c->title)); return $c; }, (array) json_decode($episode->get_chapters('json'))); } return new \Podlove\Api\Response\OkResponse([ 'chapters' => $data, '_version' => 'v2', ]); } public function create_item_permissions_check($request) { if (!current_user_can('edit_posts')) { return new \Podlove\Api\Error\ForbiddenAccess(); } return true; } public function create_item($request) { $id = $request->get_param('id'); if (!$id) { return; } $episode = Episode::find_by_id($id); if (!$episode) { return new \Podlove\Api\Error\NotFound(); } $chapters = new Chapters(); $npt = 0; if (isset($request['chapters']) && is_array($request['chapters'])) { for ($i = 0; $i < count($request['chapters']); ++$i) { $timestamp = ''; if (isset($request['chapters'][$i]['start'])) { $timestamp = $request['chapters'][$i]['start']; $npt = NormalPlayTime\Parser::parse($timestamp, 'ms'); } $title = ''; if (isset($request['chapters'][$i]['title'])) { $title = $request['chapters'][$i]['title']; } $url = ''; if (isset($request['chapters'][$i]['href'])) { $url = $request['chapters'][$i]['href']; } $image = ''; if (isset($request['chapters'][$i]['image'])) { $image = $request['chapters'][$i]['image']; } if (strlen($url) == 0 && strlen($image) == 0) { $chapters->addChapter(new Chapter($npt, $title)); } else { if (strlen($image) == 0) { $chapters->addChapter(new Chapter($npt, $title, $url)); } else { $chapters->addChapter(new Chapter($npt, $title, $url, $image)); } } } } $chapters->setPrinter(new Printer\Mp4chaps()); $episode_data['chapters'] = (string) $chapters; $episode->update_attributes($episode_data); return new \Podlove\Api\Response\CreateResponse([ 'status' => 'ok' ]); } public function update_item_permissions_check($request) { if (!current_user_can('edit_posts')) { return new \Podlove\Api\Error\ForbiddenAccess(); } return true; } public function update_item($request) { $id = $request->get_param('id'); if (!$id) { return; } $episode = Episode::find_by_id($id); if (!$episode) { return new \Podlove\Api\Error\NotFound(); } $chapters = new Chapters(); $npt = 0; if (isset($request['chapters']) && is_array($request['chapters'])) { for ($i = 0; $i < count($request['chapters']); ++$i) { $timestamp = ''; if (isset($request['chapters'][$i]['start'])) { $timestamp = $request['chapters'][$i]['start']; $npt = NormalPlayTime\Parser::parse($timestamp, 'ms'); } $title = ''; if (isset($request['chapters'][$i]['title'])) { $title = $request['chapters'][$i]['title']; } $url = ''; if (isset($request['chapters'][$i]['href'])) { $url = $request['chapters'][$i]['href']; } $image = ''; if (isset($request['chapters'][$i]['image'])) { $image = $request['chapters'][$i]['image']; } if (strlen($url) == 0 && strlen($image) == 0) { $chapters->addChapter(new Chapter($npt, $title)); } else { if (strlen($image) == 0) { $chapters->addChapter(new Chapter($npt, $title, $url)); } else { $chapters->addChapter(new Chapter($npt, $title, $url, $image)); } } } } $chapters->setPrinter(new Printer\JSON()); $episode_data['chapters'] = (string) $chapters; $episode->update_attributes($episode_data); return new \Podlove\Api\Response\OkResponse([ 'status' => 'ok' ]); } public function delete_item_permissions_check($request) { if (!current_user_can('edit_posts')) { return new \Podlove\Api\Error\ForbiddenAccess(); } return true; } public function delete_item($request) { $id = $request->get_param('id'); if (!$id) { return; } $episode = Episode::find_by_id($id); if (!$episode) { return new \Podlove\Api\Error\NotFound(); } $episode_data['chapters'] = ''; $episode->update_attributes($episode_data); return new \Podlove\Api\Response\OkResponse([ 'status' => 'ok' ]); } } ================================================ FILE: includes/api/episodes/contributions.php ================================================ namespace = 'podlove/v2'; $this->rest_base = 'episodes'; } public function register_routes() { register_rest_route($this->namespace, '/'.$this->rest_base.'/(?P[\d]+)/contributions', [ 'args' => [ 'id' => [ 'description' => __('Unique identifier for the episode.', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'integer', ], ], [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [$this, 'get_item'], 'permission_callback' => [$this, 'get_item_permissions_check'], ], [ 'methods' => \WP_REST_Server::CREATABLE, 'callback' => [$this, 'create_item'], 'permission_callback' => [$this, 'create_item_permissions_check'], ], [ 'args' => [ 'contributors' => [ 'description' => __('List of contributors of the episode', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'array', 'items' => [ 'type' => 'object', 'properties' => [ 'contributor_id' => [ 'description' => __('Id of a contributor'), 'type' => 'integer', 'required' => 'true' ], 'group_id' => [ 'description' => __('Id of group of the contributor', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'integer', 'validate_callback' => '\Podlove\Api\Validation::isContributorGroupIdExist' ], 'role_id' => [ 'description' => __('Id of role of the contributor', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'integer', 'validate_callback' => '\Podlove\Api\Validation::isContributorRoleIdExist' ], 'comment' => [ 'description' => __('Comment to the contribution', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'string' ], 'default_contributor' => [ 'description' => __('Is the contributor a default contributor', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'boolean' ] ] ] ] ], 'methods' => \WP_REST_Server::EDITABLE, 'callback' => [$this, 'update_item'], 'permission_callback' => [$this, 'update_item_permissions_check'], ], [ 'methods' => \WP_REST_Server::DELETABLE, 'callback' => [$this, 'delete_item'], 'permission_callback' => [$this, 'delete_item_permissions_check'], ] ]); register_rest_route($this->namespace, '/'.$this->rest_base.'/contributions/(?P[\d]+)', [ 'args' => [ 'id' => [ 'description' => __('Unique identifier for the contribution to an episode.', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'integer', ], ], [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [$this, 'get_contribution'], 'permission_callback' => [$this, 'get_item_permissions_check'], ], [ 'methods' => \WP_REST_Server::EDITABLE, 'callback' => [$this, 'update_contribution'], 'permission_callback' => [$this, 'update_item_permissions_check'], ], [ 'methods' => \WP_REST_Server::DELETABLE, 'callback' => [$this, 'delete_contribution'], 'permission_callback' => [$this, 'delete_item_permissions_check'], ] ]); } public function get_item($request) { $id = $request->get_param('id'); $results = array_map(function ($contributor) { if (self::isContributorVisible($contributor->contributor_id) == false) { if (!current_user_can('edit_posts')) { return; } } $comment = $contributor->comment; if ($comment == null) { $comment = ''; } $group_id = $contributor->group_id; if ($group_id == null) { $group_id = 0; } $role_id = $contributor->role_id; if ($role_id == null) { $role_id = 0; } return [ 'id' => $contributor->id, 'contributor_id' => $contributor->contributor_id, 'role_id' => $role_id, 'group_id' => $group_id, 'position' => $contributor->position, 'comment' => $comment, 'default_contributor' => self::isContributorDefault($contributor->contributor_id), ]; }, EpisodeContribution::find_all_by_episode_id($id)); $results_clean = array_filter($results, fn ($item) => $this->isNotEmpty($item)); return new \Podlove\Api\Response\OkResponse([ '_version' => 'v2', 'contribution' => $results_clean ]); } public function get_contribution($request) { $id = $request->get_param('id'); $contribution = EpisodeContribution::find_by_id($id); if (!$contribution) { return new \Podlove\Api\Error\NotFound(); } if (self::isContributorVisible($contribution->contributor_id) == false) { if (!current_user_can('edit_posts')) { return new \Podlove\Api\Error\ForbiddenAccess(); } } $comment = $contribution->comment; if ($comment == null) { $comment = ''; } $group_id = $contribution->group_id; if ($group_id == null) { $group_id = 0; } $role_id = $contribution->role_id; if ($role_id == null) { $role_id = 0; } return new \Podlove\Api\Response\OkResponse([ 'id' => $contribution->id, 'contributor_id' => $contribution->contributor_id, 'role_id' => $role_id, 'group_id' => $group_id, 'position' => $contribution->position, 'comment' => $comment, 'default_contributor' => self::isContributorDefault($contribution->contributor_id), ]); } public function get_item_permissions_check($request) { return true; } public function create_item($request) { $id = $request->get_param('id'); if (!$id) { return; } $episode = Episode::find_by_id($id); if (!$episode) { return new \Podlove\Api\Error\NotFound(); } $contribution = new EpisodeContribution(); $contribution->episode_id = $id; $contribution->save(); return new \Podlove\Api\Response\CreateResponse([ 'status' => 'ok', 'id' => $contribution->id ]); } public function create_item_permissions_check($request) { if (!current_user_can('edit_posts')) { return new \Podlove\Api\Error\ForbiddenAccess(); } return true; } public function update_item($request) { $id = $request->get_param('id'); if (!$id) { return; } $episode = Episode::find_by_id($id); if (!$episode) { return new \Podlove\Api\Error\NotFound(); } $contributions = EpisodeContribution::find_all_by_episode_id($id); foreach ($contributions as $contribution) { $contribution->delete(); } $warning = []; if (isset($request['contributors']) && is_array($request['contributors'])) { for ($i = 0; $i < count($request['contributors']); ++$i) { $contrib = new EpisodeContribution(); $contrib->episode_id = $id; if (isset($request['contributors'][$i]['contributor_id'])) { $contrib->contributor_id = $request['contributors'][$i]['contributor_id']; } if (isset($request['contributors'][$i]['group_id'])) { $group_id = $request['contributors'][$i]['group_id']; $group = ContributorGroup::find_by_id($group_id); if ($group) { $contrib->group_id = $group_id; } else { if ($group_id > 0) { array_push($warning, 'group_id '.$group_id.' not exist!'); } } } if (isset($request['contributors'][$i]['role_id'])) { $role_id = $request['contributors'][$i]['role_id']; $role = ContributorRole::find_by_id($role_id); if ($role) { $contrib->role_id = $role_id; } else { if ($role_id) { array_push($warning, 'role_id '.$role_id.' not exist!'); } } } if (isset($request['contributors'][$i]['comment'])) { $contrib->comment = $request['contributors'][$i]['comment']; } if (isset($request['contributors'][$i]['position'])) { $contrib->position = $request['contributors'][$i]['position']; } $contrib->save(); } } if (empty($warning)) { return new \Podlove\Api\Response\OkResponse([ 'status' => 'ok' ]); } return new \Podlove\Api\Response\OkResponse([ 'status' => 'ok', 'warning' => $warning ]); } public function update_contribution($request) { $id = $request->get_param('id'); $contribution = EpisodeContribution::find_by_id($id); if (!$contribution) { return new \Podlove\Api\Error\NotFound(); } $warning = []; if (isset($request['contributor_id'])) { $contribution->contributor_id = $request['contributor_id']; } if (isset($request['group_id'])) { $group_id = $request['group_id']; $group = ContributorGroup::find_by_id($group_id); if ($group) { $contribution->group_id = $group_id; } else { if ($group_id > 0) { array_push($warning, 'group_id '.$group_id.' not exist!'); } } } if (isset($request['role_id'])) { $role_id = $request['role_id']; $role = ContributorRole::find_by_id($role_id); if ($role) { $contribution->role_id = $role_id; } else { if ($role_id > 0) { array_push($warning, 'role_id '.$role_id.' not exist!'); } } } if (isset($request['comment'])) { $contribution->comment = $request['comment']; } if (isset($request['position'])) { $contribution->position = $request['position']; } $contribution->save(); if (empty($warning)) { return new \Podlove\Api\Response\OkResponse([ 'status' => 'ok' ]); } return new \Podlove\Api\Response\OkResponse([ 'status' => 'ok', 'warning' => $warning ]); } public function update_item_permissions_check($request) { if (!current_user_can('edit_posts')) { return new \Podlove\Api\Error\ForbiddenAccess(); } return true; } public function delete_item($request) { $id = $request->get_param('id'); if (!$id) { return; } $episode = Episode::find_by_id($id); if (!$episode) { return new \Podlove\Api\Error\NotFound(); } $contributors = EpisodeContribution::find_all_by_episode_id($id); foreach ($contributors as $contributor) { $contributor->delete(); } return new \Podlove\Api\Response\OkResponse([ 'status' => 'ok' ]); } public function delete_contribution($request) { $id = $request->get_param('id'); if (!$id) { return; } $contribution = EpisodeContribution::find_by_id($id); if (!$contribution) { return new \Podlove\Api\Error\NotFound(); } $contribution->delete(); return new \Podlove\Api\Response\OkResponse([ 'status' => 'ok' ]); } public function delete_item_permissions_check($request) { if (!current_user_can('edit_posts')) { return new \Podlove\Api\Error\ForbiddenAccess(); } return true; } private function isContributorDefault($id) { return (bool) DefaultContribution::find_all_by_property('contributor_id', $id); } private function isContributorVisible($id) { $contributor = Contributor::find_by_id($id); return $contributor && $contributor->visibility > 0; } private function isNotEmpty($var) { return $var !== null && $var !== ''; } } ================================================ FILE: includes/api/episodes/related_episodes.php ================================================ namespace = 'podlove/v2'; $this->rest_base = 'episodes'; } public function register_routes() { register_rest_route($this->namespace, '/'.$this->rest_base.'/(?P[\d]+)/related', [ 'args' => [ 'id' => [ 'description' => __('Unique identifier for the episode.', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'integer', ], ], [ 'args' => [ 'status' => [ 'description' => __('Get also episodes with status draft.', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'string', 'enum' => ['publish', 'draft', 'all'] ], ], 'methods' => \WP_REST_Server::READABLE, 'callback' => [$this, 'get_items'], 'permission_callback' => [$this, 'get_items_permissions_check'], ], [ 'methods' => \WP_REST_Server::EDITABLE, 'callback' => [$this, 'update_items'], 'permission_callback' => [$this, 'update_items_permissions_check'], ], [ 'methods' => \WP_REST_Server::DELETABLE, 'callback' => [$this, 'delete_items'], 'permission_callback' => [$this, 'delete_items_permissions_check'], ] ]); register_rest_route($this->namespace, '/'.$this->rest_base.'/related', [ 'args' => [ 'id' => [ 'description' => __('Unique identifier for the episode relation.', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'integer', ], ], [ 'args' => [ 'episode_id' => [ 'description' => __('Identifier for an episode.', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'string', 'required' => 'true' ], 'related_episode_id' => [ 'description' => __('Identifier for an related Episode.', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'string', 'required' => 'true' ], ], 'methods' => \WP_REST_Server::CREATABLE, 'callback' => [$this, 'create_item'], 'permission_callback' => [$this, 'create_item_permissions_check'], ] ]); register_rest_route($this->namespace, '/'.$this->rest_base.'/related/(?P[\d]+)', [ 'args' => [ 'id' => [ 'description' => __('Unique identifier for the episode relation.', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'integer', ], ], [ 'args' => [ 'status' => [ 'description' => __('Get also episodes with status draft.', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'string', 'enum' => ['publish', 'draft'] ], ], 'methods' => \WP_REST_Server::READABLE, 'callback' => [$this, 'get_item'], 'permission_callback' => [$this, 'get_items_permissions_check'], ], [ 'methods' => \WP_REST_Server::EDITABLE, 'callback' => [$this, 'update_item'], 'permission_callback' => [$this, 'update_items_permissions_check'], ], [ 'methods' => \WP_REST_Server::DELETABLE, 'callback' => [$this, 'delete_item'], 'permission_callback' => [$this, 'delete_items_permissions_check'], ] ]); } public function get_items($request) { $id = $request->get_param('id'); if (!$id) { return; } $filter = $request->get_param('status'); if (!$filter || ($filter != 'draft' && $filter != 'all')) { $filter = 'publish'; } $episode = Episode::find_by_id($id); if (!$episode || ($filter == 'publish' && !$episode->is_published())) { return new \Podlove\Api\Error\NotFoundEpisode($id); } $relations = EpisodeRelation::find_all_by_where('left_episode_id = '.$episode->id.' OR right_episode_id = '.$episode->id); $results = array_map(function ($relation) use ($filter, $episode) { $related_id = $relation->left_episode_id; $get_left_side = true; if ($relation->right_episode_id != $episode->id) { $related_id = $relation->right_episode_id; $get_left_side = false; } $related_episode = Episode::find_by_id($related_id); if ($related_episode) { $related_episode_title = $related_episode->title(); $post = $related_episode->post(); if (($filter == 'publish' && $related_episode->is_published()) || ($post && $filter == 'draft' && $post->post_status == 'draft') || $filter == 'all') { if ($get_left_side) { return [ 'episode_releation_id' => $relation->id, 'related_episode_id' => $relation->left_episode_id, 'related_episode_title' => $related_episode_title ]; } } return [ 'episode_releation_id' => $relation->id, 'related_episode_id' => $relation->right_episode_id, 'related_episode_title' => $related_episode_title ]; } }, $relations); // Delete the invalid entries $results = array_filter($results); return new \Podlove\Api\Response\OkResponse([ '_version' => 'v2', 'relatedEpisodes' => $results ]); } public function get_item($request) { $id = $request->get_param('id'); if (!$id) { return; } $isFilter = true; $filter = $request->get_param('status'); if (!$filter || $filter != 'draft') { $isFilter = false; } $relation = EpisodeRelation::find_by_id($id); if (!$relation) { $msg = 'sorry, we do not found the episode relation with ID '.$id; return new \Podlove\Api\Error\NotFound('rest_not_found', $msg); } $right_episode = Episode::find_by_id($relation->right_episode_id); $left_episode = Episode::find_by_id($relation->left_episode_id); if (!$right_episode) { return new \Podlove\Api\Error\NotFoundEpisode($relation->right_episode_id); } if (!$left_episode) { return new \Podlove\Api\Error\NotFoundEpisode($relation->left_episode_id); } if ($isFilter || ($right_episode->is_published() && $left_episode->is_published())) { return new \Podlove\Api\Response\OkResponse([ '_version' => 'v2', 'episode_id' => $left_episode->id, 'related_episode_id' => $right_episode->id, 'related_episode_title' => $right_episode->title() ]); } return new \Podlove\Api\Response\OkResponse([ 'status' => 'ok' ]); } public function get_items_permissions_check($request) { $filter = $request->get_param('status'); if ($filter && ($filter == 'draft' || $filter == 'all') && (!current_user_can('edit_posts'))) { return new \Podlove\Api\Error\ForbiddenAccess(); } return true; } public function update_items($request) { $id = $request->get_param('id'); if (!$id) { return; } $episode = Episode::find_by_id($id); if (!$episode) { return new \Podlove\Api\Error\NotFoundEpisode($id); } // Delete all old items $relations = EpisodeRelation::find_all_by_where('left_episode_id = '.$episode->id); foreach ($relations as $relation) { $relation->delete(); } if (isset($request['related'])) { if (is_array($request['related'])) { foreach ($request['related'] as $related_id) { $error = $this->create_episode_relation($id, $related_id); if (is_wp_error($error)) { return $error; } } } else { $related_id = $request['related']; $error = $this->create_episode_relation($id, $related_id); if (is_wp_error($error)) { return $error; } } } return new \Podlove\Api\Response\OkResponse([ 'status' => 'ok' ]); } public function update_item($request) { $id = $request->get_param('id'); if (!$id) { return; } $relation = EpisodeRelation::find_by_id($id); if (!$relation) { $msg = 'sorry, we do not found the episode relation with ID '.$id; return new \Podlove\Api\Error\NotFound('rest_not_found', $msg); } if (isset($request['episode_id'])) { $episode_id = $request['episode_id']; $relation->left_episode_id = $episode_id; } if (isset($request['related_episode_id'])) { $related_id = $request['related_episode_id']; $relation->right_episode_id = $related_id; } $relation->save(); return new \Podlove\Api\Response\OkResponse([ 'status' => 'ok' ]); } public function update_items_permissions_check($request) { if (!current_user_can('edit_posts')) { return new \Podlove\Api\Error\ForbiddenAccess(); } return true; } public function create_item($request) { if (isset($request['episode_id'])) { $episode_id = $request['episode_id']; $episode = Episode::find_by_id($episode_id); } if (isset($request['related_episode_id'])) { $related_id = $request['related_episode_id']; $related_episode = Episode::find_by_id($related_id); } if (!$episode) { return new \Podlove\Api\Error\NotFoundEpisode($episode->id); } if (!$related_episode) { return new \Podlove\Api\Error\NotFoundEpisode($related_episode->id); } $error = $this->create_episode_relation($episode->id, $related_episode->id); if (is_wp_error($error)) { return $error; } return new \Podlove\Api\Response\CreateResponse([ 'status' => 'ok', 'relation_id' => $error ]); } public function create_item_permissions_check($request) { if (!current_user_can('edit_posts')) { return new \Podlove\Api\Error\ForbiddenAccess(); } return true; } public function delete_items($request) { $id = $request->get_param('id'); if (!$id) { return; } $episode = Episode::find_by_id($id); if (!$episode) { return new \Podlove\Api\Error\NotFoundEpisode($id); } $relations = EpisodeRelation::find_all_by_where('left_episode_id = '.$episode->id); foreach ($relations as $relation) { $relation->delete(); } return new \Podlove\Api\Response\OkResponse([ 'status' => 'ok' ]); } public function delete_item($request) { $id = $request->get_param('id'); if (!$id) { return; } $relation = EpisodeRelation::find_by_id($id); if (!$relation) { $msg = 'sorry, we do not found the episode relation with ID '.$id; return new \Podlove\Api\Error\NotFound('rest_not_found', $msg); } $relation->delete(); return new \Podlove\Api\Response\OkResponse([ 'status' => 'ok' ]); } public function delete_items_permissions_check($request) { if (!current_user_can('edit_posts')) { return new \Podlove\Api\Error\ForbiddenAccess(); } return true; } private function create_episode_relation($id, $related_id) { // Don't create duplicates $relations = EpisodeRelation::find_all_by_where('left_episode_id = '.intval($id).' AND right_episode_id = '.intval($related_id)); if ($relations) { return; } $relations = EpisodeRelation::find_all_by_where('right_episode_id = '.intval($id).' AND left_episode_id = '.intval($related_id)); if ($relations) { return; } $related_episode = Episode::find_by_id($related_id); if (!$related_episode) { return new \Podlove\Api\Error\NotFoundEpisode($related_id); } $relation = new EpisodeRelation(); $relation->left_episode_id = $id; $relation->right_episode_id = $related_id; $relation->save(); return $relation->id; } } ================================================ FILE: includes/api/episodes.php ================================================ 'GET', 'callback' => __NAMESPACE__.'\list_api', 'permission_callback' => '__return_true', ]); register_rest_route('podlove/v1', 'episodes/(?P[\d]+)', [ 'methods' => 'GET', 'callback' => __NAMESPACE__.'\episodes_api', 'permission_callback' => '__return_true', ]); register_rest_route('podlove/v1', 'episodes/(?P[\d]+)', [ 'methods' => \WP_REST_Server::EDITABLE, 'callback' => __NAMESPACE__.'\episodes_update_api', 'permission_callback' => __NAMESPACE__.'\update_episode_permission_check', ]); } function list_api() { $episodes = Episode::find_all_by_time([ 'post_status' => 'publish', ]); $results = []; foreach ($episodes as $episode) { array_push($results, [ 'id' => $episode->id, 'title' => get_the_title($episode->post_id), ]); } return new \WP_REST_Response([ 'results' => $results, '_version' => 'v1', ]); } function episodes_api($request) { $id = $request->get_param('id'); $episode = Episode::find_by_id($id); $podcast = Podcast::get(); $post = get_post($episode->post_id); return new \WP_REST_Response([ '_version' => 'v1', 'id' => $id, 'slug' => $post->post_name, 'title' => get_the_title($episode->post_id), 'title_clean' => $episode->title, 'subtitle' => trim($episode->subtitle), 'summary' => trim($episode->summary), 'publicationDate' => mysql2date('c', $post->post_date), 'duration' => $episode->get_duration('full'), 'poster' => $episode->cover_art_with_fallback()->setWidth(500)->url(), 'link' => get_permalink($episode->post_id), 'chapters' => chapters($episode), 'audio' => \podlove_pwp5_audio_files($episode, null), 'files' => \podlove_pwp5_files($episode, null), 'content' => apply_filters('the_content', $post->post_content), 'number' => $episode->number, 'mnemonic' => $podcast->mnemonic.($episode->number < 100 ? '0' : '').($episode->number < 10 ? '0' : '').$episode->number, 'soundbite_start' => $episode->soundbite_start, 'soundbite_duration' => $episode->soundbite_duration, 'soundbite_title' => $episode->soundbite_title // @todo: all media files ]); } /** * Check permission for change. * * @param mixed $request */ function update_episode_permission_check($request) { if (!current_user_can('edit_posts')) { return new \WP_Error( 'rest_forbidden', esc_html__('sorry, you do not have permissions to use this REST API endpoint'), ['status' => 401] ); } return true; } function episodes_update_api($request) { $id = $request->get_param('id'); $episode = Episode::find_by_id($id); if (!$episode) { return; } if (isset($request['soundbite_start'])) { $start = $request['soundbite_start']; if (preg_match('/\d\d:[0-5]\d:[0-5]\d?.?\d?\d?\d/', $start)) { $episode->soundbite_start = $start; } else { return; } } if (isset($request['soundbite_duration'])) { $duration = $request['soundbite_duration']; if (preg_match('/\d\d:[0-5]\d:[0-5]\d?.?\d?\d?\d/', $duration)) { $episode->soundbite_duration = $duration; } else { return; } } if (isset($request['soundbite_title'])) { $title = $request['soundbite_title']; $episode->soundbite_title = $title; } $episode->save(); return new \WP_REST_Response(null, 200); } function chapters($episode = null) { return array_map(function ($c) { $c->title = html_entity_decode(trim($c->title)); return $c; }, (array) json_decode($episode->get_chapters('json'))); } add_action('rest_api_init', function () { $controller = new WP_REST_PodloveEpisode_Controller(); $controller->register_routes(); }); class WP_REST_PodloveEpisode_Controller extends \WP_REST_Controller { public function __construct() { $this->namespace = 'podlove/v2'; $this->rest_base = 'episodes'; } public function register_routes() { register_rest_route($this->namespace, '/'.$this->rest_base, [ [ 'args' => [ 'status' => [ 'description' => __('The status parameter is used to filter the collection of episodes', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'string', 'enum' => ['publish', 'draft', 'all'] ], 'show' => [ 'description' => __('Filter by show slug.', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'string' ], 'guid' => [ 'description' => __('Filter by guid.', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'string' ], 'sort_by' => [ 'description' => __('Sort the list of episodes', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'string', 'enum' => ['post_id', 'post_date'] ], 'order_by' => [ 'description' => __('Ascending or descending order for sorting of the list of episodes', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'string', 'enum' => ['asc', 'desc', 'ASC', 'DESC'] ] ], 'methods' => \WP_REST_Server::READABLE, 'callback' => [$this, 'get_items'], 'permission_callback' => [$this, 'get_items_permissions_check'], ], [ 'methods' => \WP_REST_Server::CREATABLE, 'callback' => [$this, 'create_item'], 'permission_callback' => [$this, 'create_item_permissions_check'], ] ]); register_rest_route($this->namespace, '/'.$this->rest_base.'/(?P[\d]+)/build_slug', [ 'args' => [ 'id' => [ 'description' => __('Unique identifier for the episode.', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'integer', ], 'title' => [ 'type' => 'string' ] ], [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [$this, 'build_slug'], 'permission_callback' => [$this, 'create_item_permissions_check'], ] ]); register_rest_route($this->namespace, '/'.$this->rest_base.'/(?P[\d]+)/freeze_slug', [ 'args' => [ 'id' => [ 'description' => __('Unique identifier for the episode.', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'integer', ], ], [ 'methods' => \WP_REST_Server::EDITABLE, 'callback' => [$this, 'freeze_slug'], 'permission_callback' => [$this, 'update_item_permissions_check'], ] ]); register_rest_route($this->namespace, '/'.$this->rest_base.'/(?P[\d]+)/unfreeze_slug', [ 'args' => [ 'id' => [ 'description' => __('Unique identifier for the episode.', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'integer', ], ], [ 'methods' => \WP_REST_Server::EDITABLE, 'callback' => [$this, 'unfreeze_slug'], 'permission_callback' => [$this, 'update_item_permissions_check'], ] ]); register_rest_route($this->namespace, '/'.$this->rest_base.'/(?P[\d]+)', [ 'args' => [ 'id' => [ 'description' => __('Unique identifier for the episode.', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'integer', ], ], [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [$this, 'get_item'], 'permission_callback' => [$this, 'get_item_permissions_check'], ], [ 'args' => [ 'guid' => [ 'description' => __('Globally unique id.', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'string', ], 'title' => [ 'description' => __('Clear, concise name for your episode.', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'string', ], 'subtitle' => [ 'description' => __('Single sentence describing the episode.', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'string', ], 'summary' => [ 'description' => __('A summary of the episode.', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'string', ], 'number' => [ 'description' => __('An epsiode number.', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'integer', ], 'slug' => [ 'description' => __('Episode media file slug.', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'string', ], 'duration' => [ 'description' => __('Duration of the episode', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'string', 'validate_callback' => '\Podlove\Api\Validation::timestamp' ], 'type' => [ 'description' => __('Episode type. May be used by podcast clients.', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'string', 'enum' => ['full', 'trailer', 'bonus'] ], 'cover' => [ 'description' => __('An url for the episode cover', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'string', 'validate_callback' => '\Podlove\Api\Validation::episodeCover' ], 'explicit' => [ 'description' => __('explicit content?', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'boolean' ], 'soundbite_start' => [ 'description' => __('Start value of podcast:soundbite tag', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'string', 'validate_callback' => '\Podlove\Api\Validation::timestamp' ], 'soundbite_duration' => [ 'description' => __('Duration value of podcast::soundbite tag', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'string', 'validate_callback' => '\Podlove\Api\Validation::timestamp' ], 'soundbite_title' => [ 'description' => __('Title for the podcast::soundbite tag', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'string' ], 'auphonic_production_id' => [ 'description' => 'Auphonic Production ID', 'type' => 'string' ], 'is_auphonic_production_running' => [ 'description' => 'Tracks if Auphonic production is running', 'type' => 'boolean' ], 'auphonic_webhook_config' => [ 'description' => 'Auphonic Webhook after Production is done', 'type' => 'object', 'properties' => [ 'authkey' => [ 'description' => 'Authentication key', 'type' => 'string', 'required' => 'true' ], 'enabled' => [ 'description' => 'Publish episode when Production is done?', 'type' => 'boolean', 'required' => 'true' ] ] ], 'show' => [ 'description' => 'Show slug. Assigns episode to given show.', 'type' => 'string' ], 'skip_validation' => [ 'description' => 'If true, mediafile validation is skipped on slug change.', 'type' => 'boolean', ] ], 'methods' => \WP_REST_Server::EDITABLE, 'callback' => [$this, 'update_item'], 'permission_callback' => [$this, 'update_item_permissions_check'], ], [ 'methods' => \WP_REST_Server::DELETABLE, 'callback' => [$this, 'delete_item'], 'permission_callback' => [$this, 'delete_item_permissions_check'], ] ]); register_rest_route($this->namespace, '/'.$this->rest_base.'/(?P[\d]+)/media/(?P[\d]+)/enable', [ 'args' => [ 'id' => [ 'description' => __('Unique identifier for the episode.', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'integer', ], 'asset_id' => [ 'description' => __('Unique identifier for the asset.', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'integer', ], ], [ 'methods' => \WP_REST_Server::EDITABLE, 'callback' => [$this, 'update_item_media_enable'], 'permission_callback' => [$this, 'update_item_permissions_check'], ] ]); register_rest_route($this->namespace, '/'.$this->rest_base.'/(?P[\d]+)/media/(?P[\d]+)/disable', [ 'args' => [ 'id' => [ 'description' => __('Unique identifier for the episode.', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'integer', ], 'asset_id' => [ 'description' => __('Unique identifier for the asset.', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'integer', ], ], [ 'methods' => \WP_REST_Server::EDITABLE, 'callback' => [$this, 'update_item_media_disable'], 'permission_callback' => [$this, 'update_item_permissions_check'], ] ]); register_rest_route($this->namespace, '/'.$this->rest_base.'/(?P[\d]+)/media/(?P[\d]+)/verify', [ 'args' => [ 'id' => [ 'description' => __('Unique identifier for the episode.', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'integer', ], 'asset_id' => [ 'description' => __('Unique identifier for the asset.', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'integer', ], ], [ 'methods' => \WP_REST_Server::EDITABLE, 'callback' => [$this, 'update_item_media_verify'], 'permission_callback' => [$this, 'update_item_permissions_check'], ] ]); register_rest_route($this->namespace, '/'.$this->rest_base.'/(?P[\d]+)/media', [ 'args' => [ 'id' => [ 'description' => __('Unique identifier for the episode.', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'integer', ], ], [ 'args' => [ 'asset_id' => [ 'description' => __('Identifier of the asset.', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'integer', ], 'asset' => [ 'description' => __('Name of the asset.', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'string', ], 'file_url' => [ 'description' => __('File url for the asset', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'string', ], 'enable' => [ 'description' => __('Is the asset used?', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'boolean', ], ], 'methods' => \WP_REST_Server::READABLE, 'callback' => [$this, 'get_item_media'], 'permission_callback' => [$this, 'get_item_permissions_check'], ] ]); register_rest_route($this->namespace, '/'.$this->rest_base.'/(?P[\d]+)/tags', [ 'args' => [ 'id' => [ 'description' => __('Unique identifier for the episode.', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'integer', ], ], [ 'args' => [ 'term_id' => [ 'description' => __('Identifier of the term', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'integer', ], 'name' => [ 'description' => __('Name of the term', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'string', ] ], 'methods' => \WP_REST_Server::READABLE, 'callback' => [$this, 'get_item_tags'], 'permission_callback' => [$this, 'get_item_permissions_check'], ], [ 'methods' => \WP_REST_Server::EDITABLE, 'callback' => [$this, 'update_item_tags'], 'permission_callback' => [$this, 'update_item_permissions_check'], ], [ 'methods' => \WP_REST_Server::DELETABLE, 'callback' => [$this, 'delete_item_tags'], 'permission_callback' => [$this, 'delete_item_permissions_check'], ] ]); } public function get_items_permissions_check($request) { $filter = $request->get_param('status'); if ($filter && ($filter == 'draft' || $filter == 'all') && (!current_user_can('edit_posts'))) { return new \Podlove\Api\Error\ForbiddenAccess(); } return true; } public function get_items($request) { $filter = $request->get_param('status'); if (!$filter || ($filter != 'draft' && $filter != 'all')) { $filter = 'publish'; } $order_by = $request->get_param('order_by'); $sort_by = $request->get_param('sort_by'); $guid_filter = $request->get_param('guid'); $args = []; if ($order_by) { $args['order_by'] = $order_by; } if ($sort_by) { if ($sort_by == 'post_id') { $args['sort_by'] = 'ID'; } else { $args['sort_by'] = $sort_by; } } if ($filter != 'all') { $args['post_status'] = $filter; } $show_slug = $request->get_param('show'); if ($show_slug) { $show = Shows\Model\Show::find_one_term_by_property('slug', $show_slug); if (!$show) { return new \Podlove\Api\Error\NotFound('rest_not_found', 'There is no show with slug "'.$show_slug.'".'); } } $episodes = Episode::find_all_by_time($args); $results = []; foreach ($episodes as $episode) { // filter by show slug if ($show_slug) { $show = Shows\Model\Show::find_one_by_episode_id($episode->id); if (!$show || $show_slug != $show->slug) { continue; } } // filter by guid if ($guid_filter) { if (get_the_guid($episode->post_id) != $guid_filter) { continue; } } array_push($results, [ 'id' => $episode->id, 'title' => get_the_title($episode->post_id), ]); } return new \Podlove\Api\Response\OkResponse([ 'results' => $results, '_version' => 'v2', ]); } public function get_item_permissions_check($request) { $id = $request->get_param('id'); $episode = Episode::find_by_id($id); if (!$episode) { return false; } $post = $episode->post(); if (!$post) { return false; } if ($post->post_status == 'publish' && $post->post_type == 'podcast') { return true; } if (!current_user_can('edit_posts')) { return new \Podlove\Api\Error\ForbiddenAccess(); } return true; } public function get_item($request) { $id = $request->get_param('id'); $episode = Episode::find_by_id($id); $podcast = Podcast::get(); $post = get_post($episode->post_id); $explicit = false; if ($episode->explicit != 0) { $explicit = true; } $postterms = get_the_terms($episode->post_id, 'shows'); $show = (is_array($postterms) && isset($postterms[0]) ? $postterms[0]->slug : ''); $data = [ '_version' => 'v2', 'id' => $id, 'guid' => get_the_guid($episode->post_id), 'slug' => $episode->slug, 'slug_frozen' => $episode->is_slug_frozen(), 'post_id' => $episode->post_id, 'title' => get_the_title($episode->post_id), 'title_clean' => $episode->title, 'subtitle' => trim($episode->subtitle ?? ''), 'summary' => trim($episode->summary ?? ''), 'duration' => $episode->get_duration('full'), 'type' => $episode->type, 'publicationDate' => mysql2date('c', $post->post_date), 'recording_date' => $episode->recording_date, 'poster' => $episode->cover_art_with_fallback()->setWidth(500)->url(), 'episode_poster' => $episode->cover_art, 'link' => get_permalink($episode->post_id), 'audio' => \podlove_pwp5_audio_files($episode, null), 'files' => \podlove_pwp5_files($episode, null), 'number' => $episode->number, 'mnemonic' => $podcast->mnemonic.($episode->number < 100 ? '0' : '').($episode->number < 10 ? '0' : '').$episode->number, 'soundbite_start' => $episode->soundbite_start, 'soundbite_duration' => $episode->soundbite_duration, 'soundbite_title' => $episode->soundbite_title, 'explicit' => $explicit, 'license_name' => $episode->license_name, 'license_url' => $episode->license_url, 'auphonic_production_id' => get_post_meta($episode->post_id, 'auphonic_production_id', true), 'is_auphonic_production_running' => get_post_meta($episode->post_id, 'is_auphonic_production_running', true), 'auphonic_plus_transfer_status' => get_post_meta($episode->post_id, 'auphonic_plus_transfer_status', true), 'auphonic_plus_transfer_files' => get_post_meta($episode->post_id, 'auphonic_plus_transfer_files', true), 'auphonic_plus_transfer_errors' => get_post_meta($episode->post_id, 'auphonic_plus_transfer_errors', true), 'auphonic_plus_transfer_change_time' => get_post_meta($episode->post_id, 'auphonic_plus_transfer_change_time', true), 'show' => $show ]; $data = $this->enrich_with_season($data, $episode); return new \Podlove\Api\Response\OkResponse($data); } public function get_item_media($request) { $id = $request->get_param('id'); if (!$id) { return; } $episode = Episode::find_by_id($id); if (!$episode) { return new \Podlove\Api\Error\NotFound(); } $assets = EpisodeAsset::all(); $results = array_map(function ($asset) use ($episode) { $file = MediaFile::find_by_episode_id_and_episode_asset_id($episode->id, $asset->id); return !$file ? [ 'asset_id' => $asset->id, 'asset' => $asset->title, 'enable' => false, ] : [ 'asset_id' => $asset->id, 'asset' => $asset->title, 'url' => $file->get_file_url(), 'size' => $file->size, 'enable' => (bool) $file->active, ]; }, $assets); return new \Podlove\Api\Response\OkResponse([ '_version' => 'v2', 'results' => $results, ]); } public function get_item_tags($request) { $id = $request->get_param('id'); if (!$id) { return; } $episode = Episode::find_by_id($id); if (!$episode) { return new \Podlove\Api\Error\NotFound(); } $post_id = $episode->post_id; $post_tag_terms = wp_get_object_terms($post_id, 'post_tag'); if (!empty($post_tag_terms) && !is_wp_error($post_tag_terms)) { $results = array_map(function ($tags) { return [ 'term_id' => $tags->term_id, 'name' => $tags->name ]; }, $post_tag_terms); return new \Podlove\Api\Response\OkResponse([ '_version' => 'v2', 'tags' => $results ]); } return new \Podlove\Api\Response\OkResponse([ 'status' => 'ok', 'tags' => [] ]); } public function create_item_permissions_check($request) { if (!current_user_can('edit_posts')) { return new \Podlove\Api\Error\ForbiddenAccess(); } return true; } public function create_item($request) { // create a post (only as draft) $new_post = [ 'post_title' => 'API created Podcast-Post', 'post_type' => 'podcast', 'post_status' => 'draft' ]; $post_id = wp_insert_post($new_post); if ($post_id) { // create an episode with the created post $episode = Episode::find_or_create_by_post_id($post_id); $url = sprintf('%s/%s/%d', $this->namespace, $this->rest_base, $episode->id); $message = sprintf('Episode successfully created with id %d', $episode->id); $data = [ 'message' => $message, 'location' => $url, 'id' => $episode->id ]; $headers = [ 'location' => $url ]; return new \Podlove\Api\Response\CreateResponse($data, $headers); } return new \WP_REST_Response(null, 500); } public function build_slug($request) { $id = $request->get_param('id'); if (!$id) { return; } $episode = Episode::find_by_id($id); if (!$episode) { return new \Podlove\Api\Error\NotFound(); } $title = $request->get_param('title') ?? get_the_title($episode->post_id); $slug = sanitize_title($title); return new \Podlove\Api\Response\CreateResponse(['slug' => $slug]); } public function freeze_slug($request) { $id = $request->get_param('id'); if (!$id) { return new \Podlove\Api\Error\NotFound(); } $episode = Episode::find_by_id($id); if (!$episode) { return new \Podlove\Api\Error\NotFound(); } $episode->freeze_slug(); return new \Podlove\Api\Response\OkResponse([ 'status' => 'ok', 'slug_frozen' => $episode->is_slug_frozen() ]); } public function unfreeze_slug($request) { $id = $request->get_param('id'); if (!$id) { return new \Podlove\Api\Error\NotFound(); } $episode = Episode::find_by_id($id); if (!$episode) { return new \Podlove\Api\Error\NotFound(); } $episode->unfreeze_slug(); return new \Podlove\Api\Response\OkResponse([ 'status' => 'ok', 'slug_frozen' => $episode->is_slug_frozen() ]); } public function update_item_permissions_check($request) { if (!current_user_can('edit_posts')) { return new \Podlove\Api\Error\ForbiddenAccess(); } return true; } public function update_item($request) { $id = $request->get_param('id'); if (!$id) { return; } $episode = Episode::find_by_id($id); $isSlugSet = false; if (!$episode) { return new \Podlove\Api\Error\NotFound(); } if (isset($request['guid'])) { update_post_meta($episode->post_id, '_podlove_guid', $request['guid']); } if (isset($request['title'])) { $title = $request['title']; $episode->title = $title; $post_update = [ 'ID' => $episode->post_id, 'post_title' => $title ]; wp_update_post($post_update); } if (isset($request['subtitle'])) { $subtitle = $request['subtitle']; $episode->subtitle = $subtitle; } if (isset($request['summary'])) { $summary = $request['summary']; $episode->summary = $summary; } if (isset($request['number'])) { $number = $request['number']; $episode->number = $number; } if (isset($request['explicit'])) { $explicit = $request['explicit']; if (is_string($explicit)) { $explicit_lowercase = strtolower($explicit); if ($explicit_lowercase == 'true') { $episode->explicit = 1; } elseif ($explicit_lowercase == 'false') { $episode->explicit = 0; } } else { if ($explicit) { $episode->explicit = 1; } else { $episode->explicit = 0; } } } if (isset($request['slug'])) { $slug = trim($request['slug']); // Only allow slug changes if not frozen if (!$episode->is_slug_frozen()) { $episode->slug = $slug; $isSlugSet = true; } } if (isset($request['duration'])) { $duration = $request['duration']; $episode->duration = $duration; } if (isset($request['recording_date'])) { $recording_date = $request['recording_date']; $episode->recording_date = $recording_date; } if (isset($request['type'])) { $type = $request['type']; $episode->type = $type; } if (isset($request['episode_poster'])) { $episode_poster = $request['episode_poster']; $episode->cover_art = $episode_poster; } if (isset($request['soundbite_start'])) { $start = $request['soundbite_start']; $episode->soundbite_start = $start; } if (isset($request['soundbite_duration'])) { $duration = $request['soundbite_duration']; $episode->soundbite_duration = $duration; } if (isset($request['soundbite_title'])) { $title = $request['soundbite_title']; $episode->soundbite_title = $title; } if (isset($request['license_name'])) { $license_name = $request['license_name']; $episode->license_name = $license_name; } if (isset($request['license_url'])) { $license_url = $request['license_url']; $episode->license_url = $license_url; } if (isset($request['auphonic_production_id'])) { update_post_meta($episode->post_id, 'auphonic_production_id', $request['auphonic_production_id']); } if (isset($request['is_auphonic_production_running'])) { update_post_meta($episode->post_id, 'is_auphonic_production_running', $request['is_auphonic_production_running']); } if (isset($request['show'])) { Shows\Shows::set_show_for_episode($episode->post_id, $request['show']); } $episode->save(); // DEPRECATED: clients should validate themselves. Remove in v3. if ($isSlugSet && !$request['skip_validation']) { $assets = EpisodeAsset::all(); foreach ($assets as $asset) { $file = MediaFile::find_or_create_by_episode_id_and_episode_asset_id($episode->id, $asset->id); $file->determine_file_size(); $file->save(false); } } \podlove_clear_feed_cache_for_post($episode->post_id); return new \Podlove\Api\Response\OkResponse([ 'status' => 'ok' ]); } public function update_item_media_enable($request) { $asset_id = $request['asset_id']; $episode = $this->get_episode_from_request($request); if (is_wp_error($episode)) { return $episode; } $file = MediaFile::find_or_create_by_episode_id_and_episode_asset_id($episode->id, $asset_id); $file->determine_file_size(); $file->active = true; $file->save(); if ($file->size == 0) { return new \Podlove\Api\Response\OkResponse([ 'message' => 'file size cannot be determined', 'active' => $file->active, 'status' => 'ok' ]); } do_action('podlove_media_file_content_verified', $file->id); // refetch because episode may have been edited by action $episode = $this->get_episode_from_request($request); \podlove_clear_feed_cache_for_post($episode->post_id); return new \Podlove\Api\Response\OkResponse([ 'status' => 'ok', 'file_size' => $file->size, 'file_url' => $file->get_file_url(), 'active' => $file->active, 'slug_frozen' => $episode->is_slug_frozen(), ]); } public function update_item_media_disable($request) { $asset_id = $request['asset_id']; $episode = $this->get_episode_from_request($request); if (is_wp_error($episode)) { return $episode; } $file = MediaFile::find_by_episode_id_and_episode_asset_id($episode->id, $asset_id); if ($file) { $file->active = false; $file->save(); } else { return new \Podlove\Api\Error\NotFound(); } \podlove_clear_feed_cache_for_post($episode->post_id); return new \Podlove\Api\Response\OkResponse([ 'status' => 'ok', 'file_size' => $file->size, 'file_url' => $file->get_file_url(), 'active' => $file->active, ]); } public function update_item_media_verify($request) { $asset_id = $request['asset_id']; $episode = $this->get_episode_from_request($request); if (is_wp_error($episode)) { return $episode; } $file = MediaFile::find_or_create_by_episode_id_and_episode_asset_id($episode->id, $asset_id); $file->determine_file_size(); $file->save(false); if ($file->size == 0) { return new \Podlove\Api\Response\OkResponse([ 'status' => 'ok', 'message' => 'file size cannot be determined', 'file_url' => $file->get_file_url(), 'active' => $file->active, 'slug_frozen' => $episode->is_slug_frozen(), ]); } do_action('podlove_media_file_content_verified', $file->id); // refetch because episode may have been edited by action $episode = $this->get_episode_from_request($request); return new \Podlove\Api\Response\OkResponse([ 'status' => 'ok', 'file_size' => $file->size, 'file_url' => $file->get_file_url(), 'active' => $file->active, 'slug_frozen' => $episode->is_slug_frozen(), ]); } public function update_item_tags($request) { $id = $request->get_param('id'); if (!$id) { return; } $episode = Episode::find_by_id($id); if (!$episode) { return new \Podlove\Api\Error\NotFound(); } $post_id = $episode->post_id; if (isset($request['term_id'])) { $terms = $request['term_id']; if (is_array($terms)) { $term_ids = array_map(function ($term) { return intval($term); }, $terms); } else { $term_ids = intval($terms); } $val = wp_set_object_terms($post_id, $term_ids, 'post_tag', true); if (is_wp_error($val)) { return new \Podlove\Api\Error\InternalServerError(500, $val->message); } } return new \Podlove\Api\Response\OkResponse([ 'status' => 'ok' ]); } public function delete_item_permissions_check($request) { if (!current_user_can('edit_posts')) { return new \Podlove\Api\Error\ForbiddenAccess(); } return true; } public function delete_item($request) { $id = $request->get_param('id'); if (!$id) { return; } $episode = Episode::find_by_id($id); if (!$episode) { return new \Podlove\Api\Error\NotFound(); } wp_trash_post($episode->post_id); return new \Podlove\Api\Response\OkResponse([ 'status' => 'ok' ]); } public function delete_item_tags($request) { $id = $request->get_param('id'); if (!$id) { return; } $episode = Episode::find_by_id($id); if (!$episode) { return new \Podlove\Api\Error\NotFound(); } $post_id = $episode->post_id; $val = wp_set_object_terms($post_id, [], 'post_tag', false); if (is_wp_error($val)) { return new \Podlove\Api\Error\InternalServerError(); } return new \Podlove\Api\Response\OkResponse([ 'status' => 'ok' ]); } private function get_episode_from_request($request) { $id = $request->get_param('id'); if (!$id) { return new \Podlove\Api\Error\NotFound(); } $episode = Episode::find_by_id($id); if (!$episode) { return new \Podlove\Api\Error\NotFound(); } return $episode; } private function enrich_with_season($data, Episode $episode) { if (!\Podlove\Modules\Base::is_active('seasons')) { return $data; } $season = Seasons\Model\Season::for_episode($episode); if (!$season) { return $data; } $data['season_id'] = (int) $season->id; return $data; } } ================================================ FILE: includes/api/feeds.php ================================================ register_routes(); }); class WP_REST_PodloveFeed_Controller extends \WP_REST_Controller { public function __construct() { $this->namespace = 'podlove/v2'; $this->rest_base = 'feeds'; } public function register_routes() { register_rest_route($this->namespace, '/'.$this->rest_base, [ [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [$this, 'get_items'], 'permission_callback' => [$this, 'get_items_permissions_check'], ] ]); } /** * Check if current user has permission to get the list of feeds. * * @param \WP_REST_Request $request * * @return bool|\WP_Error */ public function get_items_permissions_check($request) { // Anyone can read the feeds list return true; } /** * Get a list of all feeds. * * @param \WP_REST_Request $request * * @return \Podlove\Api\Response\OkResponse */ public function get_items($request) { return new \Podlove\Api\Response\OkResponse([ '_version' => 'v2', 'results' => self::get_feeds() ]); } public static function get_feeds($taxonomy = null, $term_id = null) { $feeds = Feed::find_all_by_property('enable', 1); $results = []; foreach ($feeds as $feed) { // Skip protected and non-discoverable feeds if ($feed->protected || !$feed->discoverable) { continue; } $episode_asset = $feed->episode_asset(); $file_type = $episode_asset ? $episode_asset->file_type() : null; $result = [ 'id' => $feed->id, 'title' => $feed->get_title(), 'url' => $feed->get_subscribe_url($taxonomy, $term_id), 'content_type' => $feed->get_content_type() ]; if ($file_type) { $result['file_type'] = [ 'name' => $file_type->name, 'extension' => $file_type->extension, 'mime_type' => $file_type->mime_type ]; } $results[] = $result; } return $results; } } ================================================ FILE: includes/api/podcast.php ================================================ register_routes(); }); class WP_REST_Podlove_Controller extends \WP_REST_Controller { /** * Constructor. */ public function __construct() { $this->namespace = 'podlove/v2'; $this->rest_base = 'podcast'; } /** * Register the component routes. */ public function register_routes() { $categories = \Podlove\Itunes\categories(false); $categories_val = array_values($categories); $categories_enum = array_map(function ($val) { return str_replace('&', 'and', $val); }, $categories_val); $locales = \Podlove\Locale\locales(); $locales_enum = array_keys($locales); register_rest_route($this->namespace, '/'.$this->rest_base, [ [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [$this, 'get_item'], 'permission_callback' => [$this, 'get_item_permissions_check'], ], [ 'methods' => \WP_REST_Server::EDITABLE, 'callback' => [$this, 'update_item'], 'permission_callback' => [$this, 'update_item_permissions_check'], 'args' => [ 'guid' => [ 'description' => __('Unique, global identifier for a podcast', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'string', ], 'title' => [ 'description' => __('Title of the podcast', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'string', ], 'subtitle' => [ 'description' => __('Extension to the title. Clarify what the podcast is about.', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'string', ], 'summary' => [ 'description' => __('Elaborate description of the podcasts content.', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'string', ], 'author_name' => [ 'description' => __('Name of the podcast author. Publicly displayed in Podcast directories.', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'string', ], 'cover_image' => [ 'description' => __('Cover art for the podcast', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'string', 'validate_callback' => '\Podlove\Api\Validation::url' ], 'podcast_email' => [ 'description' => __('Used by iTunes and other Podcast directories to contact you.', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'string', 'validate_callback' => 'is_email', ], 'mnemonic' => [ 'description' => __('Abbreviation for your podcast.', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'string', ], 'funding_url' => [ 'description' => __('Can be used by podcatchers show funding/donation links for the podcast.', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'string', 'validate_callback' => '\Podlove\Api\Validation::url' ], 'funding_label' => [ 'description' => __('Label for funding/donation URL.', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'string', ], 'copyright' => [ 'description' => __('Copyright notice for content in the channel.', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'string', ], 'explicit' => [ 'description' => __('Is the overall content of the podcast explicit?', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'boolean', ], 'category' => [ 'description' => __('iTunes category of the podcast', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'string', 'enum' => $categories_enum, ], 'language' => [ 'description' => __('The language that is spoken in the podcast.', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'string', 'enum' => $locales_enum, ] ] ] ]); } /** * Check permission for read. * * @param mixed $request */ public function get_item_permissions_check($request) { return true; } /** * Check permission for change. * * @param mixed $request */ public function update_item_permissions_check($request) { if (!current_user_can('edit_posts')) { return new \Podlove\Api\Error\ForbiddenAccess(); } return true; } public function get_item($request) { $podcast = Podcast::get(); $explicit = false; if ($podcast->explicit != 0) { $explicit = true; } $feeds = $podcast->feeds(['only_discoverable' => true]); $feed_urls = array_map(function ($feed) { return ["{$feed->slug}" => $feed->get_subscribe_url()]; }, $feeds); $res = []; $res['_version'] = 'v2'; $res['guid'] = $podcast->guid; $res['title'] = $podcast->title; $res['subtitle'] = $podcast->subtitle; $res['summary'] = $podcast->summary; $res['mnemonic'] = $podcast->mnemonic; $res['itunes_type'] = $podcast->itunes_type; $res['author_name'] = $podcast->author_name; $res['podcast_email'] = $podcast->owner_email; $res['poster'] = $podcast->cover_art()->setWidth(500)->url(); $res['link'] = \Podlove\get_landing_page_url(); $res['funding_url'] = $podcast->funding_url; $res['funding_label'] = $podcast->funding_label; if (!$podcast->copyright) { $res['copyright'] = $podcast->default_copyright_claim(); } else { $res['copyright'] = $podcast->copyright; } $res['explicit'] = $explicit; $res['category'] = $this->getCategoryName($podcast->category_1); $res['language'] = $this->getLanguageName($podcast->language); $res['license_url'] = $podcast->license_url; $res['license_name'] = $podcast->license_name; $res['feeds'] = $feed_urls; $res = apply_filters('podlove_api_podcast_response', $res); return new \Podlove\Api\Response\OkResponse($res); } public function update_item($request) { $podcast = Podcast::get(); if (isset($request['guid'])) { $guid = $request['guid']; $podcast->guid = $guid; } if (isset($request['title'])) { $title = $request['title']; $podcast->title = $title; } if (isset($request['subtitle'])) { $subtitle = $request['subtitle']; $podcast->subtitle = $subtitle; } if (isset($request['summary'])) { $summary = $request['summary']; $podcast->summary = $summary; } if (isset($request['mnemonic'])) { $mnemonic = $request['mnemonic']; $podcast->mnemonic = $mnemonic; } if (isset($request['author_name'])) { $author = $request['author_name']; $podcast->author_name = $author; } if (isset($request['cover_image'])) { $cover = $request['cover_image']; $podcast->cover_image = $cover; } if (isset($request['podcast_email'])) { $podcast_email = $request['podcast_email']; $podcast->owner_email = $podcast_email; } if (isset($request['funding_url'])) { $funding_url = $request['funding_url']; $podcast->funding_url = $funding_url; } if (isset($request['funding_label'])) { $funding_label = $request['funding_label']; $podcast->funding_label = $funding_label; } if (isset($request['copyright'])) { $copyright = $request['copyright']; $podcast->copyright = $copyright; } if (isset($request['explicit'])) { $explicit = $request['explicit']; $explicit_lowercase = strtolower($explicit); if ($explicit_lowercase == 'false') { $podcast->explicit = 0; } elseif ($explicit_lowercase == 'true') { $podcast->explicit = 1; } } if (isset($request['category'])) { $category = $request['category']; $category = str_replace('and', '&', $category); $category_key = $this->getCategoryKey($category); if ($category_key) { $podcast->category_1 = $category_key; } } if (isset($request['language'])) { $language = $request['language']; $podcast->language = $language; } if (isset($request['license_url'])) { $license_url = $request['license_url']; $podcast->license_url = $license_url; } if (isset($request['license_name'])) { $license_name = $request['license_name']; $podcast->license_name = $license_name; } $podcast->save(); return new \Podlove\Api\Response\OkResponse([ 'status' => 'ok' ]); } private function getCategoryKey($category) { $categories = \Podlove\Itunes\categories(false); foreach ($categories as $key => $val) { if ($val == $category) { return $key; } } } private function getCategoryName($category_key) { $categories = \Podlove\Itunes\categories(true); foreach ($categories as $key => $val) { if ($key == $category_key) { return $val; } } return ''; } private function getLanguageName($language_key) { $language = \Podlove\Locale\locales(); foreach ($language as $key => $val) { if ($key == $language_key) { return $val; } } return ''; } } ================================================ FILE: includes/api/show.php ================================================ 'GET', 'callback' => __NAMESPACE__.'\show_api', 'permission_callback' => '__return_true', ]); } function show_api() { $podcast = Podcast::get(); $response = [ '_version' => 'v1', 'title' => $podcast->title, 'subtitle' => $podcast->subtitle, 'summary' => $podcast->summary, 'mnemonic' => $podcast->mnemonic, 'itunes_type' => $podcast->itunes_type, 'author_name' => $podcast->author_name, 'poster' => $podcast->cover_art()->setWidth(500)->url(), 'link' => \Podlove\get_landing_page_url(), ]; $response = apply_filters('podlove_api_podcast_response', $response); return new \WP_REST_Response($response); } ================================================ FILE: includes/api/tools.php ================================================ register_routes(); }); class WP_REST_Podlove_Tools_Controller extends \WP_REST_Controller { public function __construct() { $this->namespace = 'podlove/v2'; $this->rest_base = 'tools'; } public function register_routes() { register_rest_route($this->namespace, '/'.$this->rest_base.'/clear-caches', [ 'methods' => \WP_REST_Server::DELETABLE, 'callback' => [$this, 'clear_caches'], 'permission_callback' => [$this, 'clear_caches_permission_check'] ]); } public function clear_caches($request) { \Podlove\Repair::clear_podlove_cache(); \Podlove\Repair::clear_podlove_image_cache(); return new \Podlove\Api\Response\OkResponse([ 'status' => 'ok' ]); } public function clear_caches_permission_check() { if (!current_user_can('edit_posts')) { return new \Podlove\Api\Error\ForbiddenAccess(); } return true; } } ================================================ FILE: includes/auto_post_titles.php ================================================ get_feed_episode_title_variant()) { case 'blog': return $original_title; break; case 'episode': $episode = Episode::find_one_by_post_id($post_id); if ($episode && $episode->title) { return trim(wp_strip_all_tags($episode->title)); } return $original_title; break; case 'template': $title = podlove_generated_feed_post_title($post_id); if ($title) { return $title; } return $original_title; break; default: return $original_title; } } function podlove_generated_post_title($post_id) { $template = \Podlove\get_setting('website', 'blog_title_template'); return podlove_get_episode_title_by_template($post_id, $template); } function podlove_generated_feed_post_title($post_id) { $template = Podcast::get()->get_feed_episode_title_template(); return podlove_get_episode_title_by_template($post_id, $template); } function podlove_get_episode_title_by_template($post_id, $template) { $episode = Episode::find_one_by_post_id($post_id); if (!$template || !$episode) { return false; } $title = $template; $title = str_replace('%mnemonic%', wp_strip_all_tags(podlove_get_mnemonic($post_id)), $title); $title = str_replace('%episode_number%', $episode->number_padded(), $title); $episode_title = trim(wp_strip_all_tags((string) $episode->title)); if (!$episode_title) { $episode_title = get_post($post_id)->post_title; } $title = str_replace('%episode_title%', $episode_title, $title); $title = apply_filters('podlove_generated_post_title', $title, $episode); return trim($title); } function podlove_override_post_title_script() { if (!\Podlove\is_episode_edit_screen()) { return; } $data = [ 'enabled' => podlove_is_title_autogen_enabled(), 'template' => \Podlove\get_setting('website', 'blog_title_template'), 'episode_padding' => \Podlove\get_setting('website', 'episode_number_padding'), 'mnemonic' => podlove_get_mnemonic(), 'placeholder' => __('Fill in episode title below', 'podlove-podcasting-plugin-for-wordpress'), ]; $data = apply_filters('podlove_js_data_for_post_title', $data, get_the_ID()); ?> mnemonic; } function podlove_is_title_autogen_enabled() { return (bool) \Podlove\get_setting('website', 'enable_generated_blog_post_title'); } ================================================ FILE: includes/cache.php ================================================ episode()) { $episode->delete_caches(); } } }); // devalidate caches when episode content has changed add_action('podlove_episode_content_has_changed', function ($episode_id) { if ($episode = Model\Episode::find_by_id($episode_id)) { $episode->delete_caches(); } }); function podlove_clear_feed_cache_for_post($post_id) { $cache = \Podlove\Cache\TemplateCache::get_instance(); foreach (Model\Feed::all() as $feed) { if ($feed->slug) { $cache_key = 'feed_item_'.$feed->slug.'_'.$post_id; $cache->delete_cache_for($cache_key); } } } ================================================ FILE: includes/capabilities.php ================================================ add_cap($capability); } } } ================================================ FILE: includes/chapters.php ================================================ ['regexp' => '/^(psc|pijson|json|mp4chaps)$/'], ]); if (!$chapters_format) { return; } if (!$episode = Model\Episode::find_one_by_post_id(get_the_ID())) { return; } switch ($chapters_format) { case 'psc': header('Content-Type: application/xml'); echo ''."\n"; break; case 'mp4chaps': header('Content-Type: text/plain'); break; case 'json': case 'pijson': header('Content-Type: application/json'); break; } echo $episode->get_chapters($chapters_format); exit; }); /* * When changing from an external chapter asset to 'manual', copy external * contents into local field. */ add_filter('pre_update_option_podlove_asset_assignment', function ($new, $old) { global $wpdb; if (!isset($old['chapters']) || !isset($new['chapters'])) { return $new; } if ($new['chapters'] != 'manual') { // just changes to manual return $new; } if (((int) $old['chapters']) <= 0) { // just changes from an asset return $new; } $episodes = \Podlove\Model\Episode::find_all_by_time(); // 10 seconds per episode or 30 seconds since 1 request per asset // is required if it is not cached set_time_limit(max(30, count($episodes) * 10)); foreach ($episodes as $episode) { if ($chapters = $episode->get_chapters('mp4chaps')) { $episode->update_attribute('chapters', $chapters); } } // delete chapters caches $wpdb->query('DELETE FROM `'.$wpdb->options.'` WHERE option_name LIKE "%podlove_chapters_string_%"'); return $new; }, 10, 2); // extend episode form add_filter('podlove_episode_form_data', function ($form_data, $episode) { if (Model\AssetAssignment::get_instance()->chapters !== 'manual') { return $form_data; } $form_data[] = [ 'type' => 'callback', 'key' => 'chapters', 'options' => [ 'callback' => function () { ?>
800, ]; return $form_data; }, 10, 2); // add PSC & podcast index json to RSS feed add_action('podlove_append_to_feed_entry', function ($podcast, $episode, $feed, $format) { // PSC $chapters = new \Podlove\Feeds\Chapters($episode); $chapters->render('inline'); // podcastindex $doc = new \DOMDocument(); $node = $doc->createElement('podcast:chapters'); $url = $episode->permalink().'?chapters_format=pijson'; $attr = $doc->createAttribute('url'); $attr->value = esc_attr($url); $node->appendChild($attr); $attr2 = $doc->createAttribute('type'); $attr2->value = 'application/json+chapters'; $node->appendChild($attr2); echo "\n".$doc->saveXML($node); }, 10, 4); ================================================ FILE: includes/compatibility.php ================================================ query($sql); if ($success === false) { update_option('podlove_db_migration_error', [ 'error' => $wpdb->last_error, 'query' => $wpdb->last_query, ]); } return (bool) $success; } add_action('admin_notices', 'podlove_show_database_migration_error'); function podlove_show_database_migration_error() { $data = get_option('podlove_db_migration_error'); if (!$data || !isset($data['error']) || !isset($data['query'])) { return; } if (isset($_REQUEST['podlove_hide_migration_error']) && $_REQUEST['podlove_hide_migration_error']) { delete_option('podlove_db_migration_error'); return; } ?>

:

:

community.podlove.org' ); ?>

cache_for('podlove_template_deprecations', function () { return podlove_get_deprecations(); }); podlove_render_deprecations($deprecations); } function podlove_get_template_deprecations() { $deprecations = []; $deprecation_matcher = [ 'shortcode' => [ 'data' => podlove_get_deprecated_shortcodes(), 'matcher' => array_keys(podlove_get_deprecated_shortcodes()), ], 'template tag' => [ 'data' => podlove_get_deprecated_template_tags(), 'matcher' => array_keys(podlove_get_deprecated_template_tags()), ], ]; foreach (\Podlove\Model\Template::all() as $template) { foreach ($deprecation_matcher as $deprecated_type => $matcher) { foreach ($matcher['matcher'] as $regex) { if ($template->content) { if (preg_match('/'.$regex.'/', $template->content, $matches)) { $deprecations[] = [ 'context' => ['type' => 'template', 'id' => $template->id], 'deprecated' => [ 'type' => $deprecated_type, 'content' => $matches[0], ], 'instead' => $matcher['data'][$regex], ]; } } } } } return $deprecations; } function podlove_get_episodes_deprecations() { $deprecations = []; $shortcodes_data = podlove_get_deprecated_shortcodes(); $shortcode_matcher = array_keys($shortcodes_data); $query = new \WP_Query(['post_type' => 'podcast']); while ($query->have_posts()) { $post = $query->next_post(); foreach ($shortcode_matcher as $shortcode) { if (preg_match('/'.$shortcode.'/', $post->post_content, $matches)) { $deprecations[] = [ 'context' => [ 'type' => 'post', 'id' => $post->ID, ], 'deprecated' => [ 'type' => 'shortcode', 'content' => $matches[0], ], 'instead' => $shortcodes_data[$shortcode], ]; } } // hint: template tags don't need to be checked in episodes because they only work in templates } return $deprecations; } function podlove_get_deprecations() { $deprecations = array_merge( podlove_get_template_deprecations(), podlove_get_episodes_deprecations() ); return apply_filters('podlove_deprecations', $deprecations); } function podlove_get_deprecation_context($context) { switch ($context['type']) { case 'template': return sprintf( '%s', admin_url('admin.php?page=podlove_templates_settings_handle'), sprintf('template "%s"', \Podlove\Model\Template::find_by_id($context['id'])->title) ); break; case 'post': return sprintf( '%s', get_edit_post_link($context['id']), sprintf('post "%s"', get_the_title($context['id'])) ); break; default: return '!!unknown context type '.$context['type'].'!!'; break; } } function podlove_render_deprecations($deprecations) { if (!count($deprecations)) { return; } ?>

You are using outdated shortcodes. Please fix as soon as possible.

  • '.$deprecation['deprecated']['content'].'', podlove_get_deprecation_context($deprecation['context']), $deprecation['instead'] ); ?>

'{{ episode.subtitle }}', '\[podlove-episode-summary[^\]]*]' => '{{ episode.summary }}', '\[podlove-episode-slug[^\]]*]' => '{{ episode.slug }}', '\[podlove-episode-duration[^\]]*]' => '{{ episode.duration }}', '\[podlove-episode-chapters[^\]]*]' => '{{ episode.chapters }}', '\[podlove-episode\s+field[^\]]*]' => 'episode template tag', '\[podlove-podcast\s+[^\]]*]' => 'podcast template tag', '\[podlove-show[^\]]*]' => '—', '\[podlove-podcast-license[^\]]*]' => '{% include \'@core/license.twig\' with {\'license\': podcast.license} %}', '\[podlove-episode-license[^\]]*]' => '{% include \'@core/license.twig\' with {\'license\': episode.license} %}', '\[podlove-contributors[^\]]*]' => '[podlove-episode-contributor-list]', '\[podlove-contributor-list[^\]]*]' => '[podlove-episode-contributor-list]', '\[podlove-web-player[^\]]*]' => '[podlove-episode-web-player] (or {{ episode.player }} in templates)', '\[podlove-subscribe-button[^\]]*]' => '[podlove-podcast-subscribe-button] (or {{ podcast.subscribeButton }} in templates)', ]; } function podlove_get_deprecated_template_tags() { return [ '\{\{\s*contributor\.publicemail\s*\}\}' => 'the social module to manage and display the email', '\{\{\s*[^\}]*license.html\s*\}\}' => '{% include \'@core/license.twig\' %}', ]; } ================================================ FILE: includes/detect_duplicate_slugs.php ================================================ prepare( ' SELECT p.ID FROM `'.$wpdb->posts.'` p JOIN `'.Episode::table_name().'` e ON e.`post_id` = p.`ID` WHERE p.`post_status` IN (\'publish\', \'private\') AND p.post_type = "podcast" AND p.ID != %d AND e.slug = %s LIMIT 0, 1', $current_episode->post_id, $current_episode->slug ); return $wpdb->get_var($sql); } function podlove_duplicate_episode_slug_notice(Episode $episode, $duplicate_id) { add_action('admin_notices', function () use ($episode, $duplicate_id) { ?>

slug(), sprintf('%s', get_edit_post_link($duplicate_id), get_the_title($duplicate_id)) ); ?>

.px-4 { padding-left: 1rem; padding-right: 1rem; } .py-12 { padding-top: 3rem; padding-bottom: 3rem; } .mx-auto { margin-left: auto; margin-right: auto; } .h-56 { height: 14rem; } @media (min-width: 640px) { .sm\:px-6 { padding-left: 1.5rem; padding-right: 1.5rem; } .sm\:h-72 { height: 18rem; } } @media (min-width: 768px) { .md\:absolute { position: absolute; } .md\:left-0 { left: 0px; } .md\:h-full { height: 100%; } .md\:w-1\/2 { width: 50%; } .md\:ml-auto { margin-left: auto; } .md\:pl-10 { padding-left: 2.5rem; } } ================================================ FILE: includes/donation_banner.img.src ================================================ data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAASABIAAD/2wCEABoaGhoaGiwaGiw/LCwsP 1U/Pz8/VWxVVVVVVWyCbGxsbGxsgoKCgoKCgoKcnJycnJy2tra2tszMzMzMzMzMzMwBICEhNDA0W TAwWdWRd5HV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1 f/CABEIBQAHgAMBIgACEQEDEQH/xAAaAAEBAQEBAQEAAAAAAAAAAAAAAQIDBAUG/9oACAEBAAAAA Pz4oAKAAAggAVSgKCKAAAAAAAAAQIEIAIAAAKAAAAAAAAAAgooCgAAAggAVVCgAAAAAAAAAAEBBA gCAAAoCgAAAAQoCAAAilAoAAACAgClUUAAAAAAACgCABAQIICAAAoCgKAgAAAAAIAAUKAAAAICAF VRQAAAAAAKAAIAEBAggIAAKAoUAAAAAAgAIAFCgAAAAgQApVFAAAAAAKAAAIAQECEAgACgKKAAAA AAACACAUKAAAACBACqUUACKAAAFAAACAEBAhAIAAoFFCgAAgAAACACAUKAoAIAIIAKpSgEAlFAAA oAACAEAgQQCAAKFFFlAAAAAgAAIAIUKAoAIAIIAKpSghAApQAAoAACAEAgQQCAAoFKUCgAgAACAA IAIooFAAAIIQAKqikIQBRVBCoKBQAIAICAhAQAAoKUUUAABAAAgAAgAKBQAACCEACqoEIhQopQQI KAKAABAIEEBAAChSiigAACAAAgACAAoFAAAQIQALSiEQUooCiIQFAWEEqCloICEBAACgpRRQKIAA IAAIAAIAoFAAEpAhAAtKgiVRVAASRCBYKpEIAKWqBBAQAAoUopQoCAACAAAgACAUCgAAEEEAC0EC qUKAEkkIAilhARQAWqpCAIAoAVRRQoCAAAIAACAAgUCgAAEEEAFUgLVWUUBCSSECWUEAAAAUtCAI KABSilAoAgAACAAAgAgUFAAAEEIAFWAtWlFAREkkILBUQKWqpAkiBVAAAABSlFCgAgAAAIAIogAh QUAAAQQQAUC21RVAIiTKIAEgNW61UQq24mELKhKgAAUAoooKACAAAACAAgAhQUAAAQQgAoKttKoK lJEkhILIRBbvepMxbVkkkqSW3TUNZzmACgAoUUKAACAAAAIAIAIUFAAAEEIAKKq6KpQAiSCQGUkF 103MQtolkmrqSZEgjet4xICgKAUoCgAAAIAAAIAgCKCgKIAIIIAspVtpaAoCIhCEmZBdddYyW0Z1 JlvcMqiSIga1MygoAKKCgAAAACAAAIBAIoKAogAggQFirVqqoAoIhBCZzIXXXWMltkS3ONbqWhJE RIBbcigKCgKAAAAACAAAIBAJQUCggAhAgAtW0qqIUoRAQTGcjfTecyrWZVYzvc1akmSiSRBBpKtl AoFAAogAAAAIAAIAglBQKCACECFgWrVKqwFFESxYic8wvTrMS22Zl3qYzNt1EzLVRIkQEIrVUBQC gAAIAAACAACAIAKCgIAgIEsoVVpSoFCqQCGeeYb6dOcW3WebruZZsu2WYtCJIIgCIurSgoAAAAAg AAEAAIAgAoKAgCAgIoqqVQlApQiiM4xF32c1t1nOb17cNRmb1lmKCIgEiJQiW6tKAoAAgAACAAIA AgAgBQoCAQCAAqqVSBQFWCyxOeC77Zzatzzmu+ZtnGtxnNoiEC1YTMkhYDeqKAAAAIAAEAAIACAC AoKAgEAgAKtKCKAFCgnPMb6dOarWMTe5duc6WTFtlkRCBVAkmZBbG90KAAgAACACAAIACACBZSgC AICARaLSiBSApQVnOFvXeJV0582rrdc265zVEkkACgBJmRaTXS0AAAAgAIAQACAAQAgWUoAgEAgE qlUpAEsoKKGMLenTmq1jm1N9TF0YmhJlAAKBAGItib6WgAIAACACAgAIAAgCBZSgIAgCACqosAgA pRTOJdb3gXcxzmnTpc43qTGrCZkAQooCRS3OJbI300oCAgAAAgICACAAIEqBZSgIAgCAKLQJUIAV QVMS66dOcG2OZe2tZ57qY1UZhIABRREirZMRYuutoAQAgAAIICACAAIEqCgoICUgCAULRYEgAUoG c3WuuENsczd7XPLpWZokhEgQu7c5i0EmFWxJUrXWqgCAAIAAggQAgACAIKCggCACAoLRYIgihQFz GtdJlLq55x0nXeeW9STQzm2EhBNdOkxzi0CYzU0qLFm+tBAEAACACEEAIAAgCCgFgJSAIFCxVKgi BCrYAy1elzldaxzjep3nLWploYlUSIgN7ZxGlCZxKWWxbGe26AgIAACAEIECAACAIKAABACCikqi wJAIqiKZXW94ya3nHO72rOtTNtTEW0SJEBWplqgTOJSW2NzOdd9AEAIAACAhBBAAAgCBQABAAgpQ KARFgAqLcy6vbGC9JzxW989TWmbomYLaSRIQBLbaEznMspS7nOb70AgAgAAQIQQIAAIAgUAAgAQK qwUAQlhKALcNut5S3d54a1XPV1c3VkiBasZSIEEW6oXOM5UKXecOvUAIACAAQIIIIAAIAgKACAAI UtIpSCAgBYW5mrrrzxW9YxnWOnTMutZnSyQiLasSSQIQLdKGeeZQo3rnL20ACAAgAICEIBAAIAzQ KAIAAhVoKBCAkoFSri6ds5lvTPOMO1yumem2cgirRJJCCCLbSjGMNFmsrthrrQAgAAgCAhAgIACA MqAoIAAgVaFLLCICEUUXMuum+ebda5Sc3TqwtnTWsSKQW0TMRBCA1RSc850qy41rWY6bAAgAAgCC CCAQAIAyUAqAACBVotlEICIJSjU53WusxNXeM4xOnWM3Wem+nPMKEtWkzERBCDVCmM5mlsSdDNda ACAACAEIECAgAgDJQAAACCrVCgkAQgVS85rXTWM3epznJfRMpuXp1zjIUFtpMxEhBAaKExMXVrNz 0kJ02AAIAAgCBEBACBAJcUoAAACCrVDSCQAIlUVya6dJza6Oc5x13iy2dernmBQW21MxEiCEFooz nM06TMOmZLelAAIAAgBAiAgBAIDCigAAAhS2hagkAVELUtxjWulzl11nnObXq4w3nffXLEAoW20m UiRAIlUUmcxvVnOtyZ3N6AACAAIBBBAQAgIDCigAAAhVtBdXAkAoRVReTpek5t9c83KX0TFzqu/T HPICiW6tGYkkAJJVFGIm3S8s26zna6AACAAIBAhAQAgIDBQoAAAhVqpbrTCIJRSFEs5dm9Yxem8Y cb09Hnstx37zlgBQlt1SZRJIpUkFKWZka1vWOTW84dcaoAACACAIIQCACAgwUKAAAIVasW66ZwgQ KWALynpx2zymu0xOeb6+WE1L6bjEihQlttXMiTMLaJIFLWcxemuvLk3qYx1zqgAAIAIAghAIAIBD BQCgAACrUN71zhAiikUS8dejPo8/O9OuMTGde7wals69sYyBVCNW1M2SYkLq0iAFszma6Xvjlne5 jm3aLAACACAgIIAgBAIwUAKAAAtpDVAiwFWFInLp11vhm9tcmJPVnzmjuxkBVBF0BnMyi21SQBSY zenTpeXPXfGeedtVFgACACAgIIAQAQEwoAKAAAtqJdKAuSigExjtrd4zfackzr6Pzs51qdOnOQBV BFUtmcxJVVRkClzjLp2u+fN2Tnz3aoBABAIAgQIACAIEwUAKAABVWDSgVkpSxRnlPTO3LDr0xiOf p9Pzo1b3zjIBVAACRBVVJAKXOczfbW885d3HPWooACAIIAgQIACAQEwUCgAAClCXSqCQpaAZ4a9L t583trnnLn9DHkl6Xp3885gKolAKREClJIBSZzNdumpjM3158p1yoAAgEEAgQCAAgEEwUCgAAUCk W3QJcyqWyyhz5de16cJrtnncJ9Pwcs9N69GufLkAqiKAoRAUIgBGcy9vQY5r2582s2gACAQQEBAI AAgITFAoAABQFRbq2BIKqoUnPn6p01xa64xJOvu+bmdNdPVnPmwAVSBQoEACACGcS9e+tY5x254v TjaAAICBAgEAgACAg50CgAAUWIoNatQkCqqBZxnt5dJmdOnPGWfV28OL1vb0c8eUAUAFKAgAgAsY xHXtvrjjnW8yd+OaAAICBAgCAgAAgQwCgABZZSkRUXd1qZJCWqWAnG+zl25p03yc2fdz8+Nddeje OHEAKAFCgQAhSCpnGXT1umOeLqR1xzWiAAgQIEAgIAACBMCgAAoKCQF3rW5zJCWqWAnHXpz25J1v HXOZ9/mxjprp6s582AEoChQLAoiwCCpnEm/T03nnz1RvPJaIAEBAgQEAgAAEEwKAAFCgJAXet7nK ERVpYDPLfXfTkdM53xZ78857a798c/LAsIsCloAKAQAQTnGuvTq58201XKWgIBAIECCAgAAAQ50A ABQopJAXW97nORELaWCJy306dOU1vnrPNevLL059e+fLzoCBEpVtCwCgQsAhOZreuuueJvWbvPJa AgCAgQIICAAABDACggKFKEkA3vepiSElW0ESct9O2uc259/NV3jnr1Y9dxw4iIQiQturVCAoskUA LnGV663rGJveHblx1aAICAgIEEAIAAAjAoAgoKUqJIF1vVSYCS20lSMZvXvcTpjn6PLuaTl06b9T lwwSIRIyLbrVqiAoqZltgGk55jv0usYmt5vXjw3aAICAgIEEAAgAAjCgAAKKKSQC6urmQiKtsRZM zXTvmTWeffz6enhnn1vT055c5EkkEmZC61rWqUQChnNtsC2zniO/TXTnzl1NduPm3oAIAgQIEEAA gAAOagAAKUoRIUXWrzRUBakLOetde/OXXPHTlXq82cdWvRyzEkkkEmYLvWrbSwAoTMa0QLnEO2+n XHHN3nfXl57oAIBAQIEEAAgAAOagCUCiigklWLdamCIoUkhrnrXo7Y3Jxzrmz7vNjO2u3OEkzJAi DV1bSqAAJmXVpCTHSTpvt0zw563nfbl57QAQCAgQIQACAAA5qAAClFFRIoWgkQpUzJZvn0e5bxjl pz37PHjPS66ZSSTOS2mYi20irVKAEhSqZ5revTXbHDGtZ13xwlAAgICAggCACAADmFAAooookAVQ CSFJnNb59cdenZjONb5Y7enxM9HfWMSSYlttSSSSqZirbq1SKIALE5ta31vTnyl1nXfHEAAgIBAg QAgCAADmFAChRRQiShVUgqZgZkusdufvwlvDtOW+l4856M+lOWZmS20jMzJCsyFautaqoAKJUmcu m2t654VnXbHNQAICAQIEAIAgABMCgCgooosSShaUC6zmMxIazdfU4YJz9M83T08OLfTPpy4ZzC21 ISZzEkgFut6tABQCYy67TrcYGW8zVACAIAgQIAgQAAIwKAoKFCixIlKqgq7ywmJBrLf0+GLrHDvf P6vR4+fPvrXp5zzYIurZJdVMkzjMCLd3VoAKAmM3po6sYtmYzelAIAIAgQIAgQAAIwKBQoFCipEl KqgHZgt88kupNe3MdMeb058/r9Phzy9F69MPLiZXWtWSb3qsRM4zlEhbrVtAAKJjOtarrMZbkzia 6UCAAgICAgCCAAAjAoKFBQooiSKqlBJr0Zm7eXDC25vo3mdJ5fZnzdPZ5McvRrrvlryc5Gt70Na1 pMSTOM5SQlaurqgAFSYmtat6Zzl0zM4muiiABACAgIAggAAIwqUUKBRRQSMqLVCszp11brPm5ZOm Nd+vK2cvRjlPd5+XP059PPGvJjLW96t1bamMRMZkSIFuta0oACyc5vWrrec5upMZmt0AAgAgIBAI BAAEMKFBQUFFBEkoqqW1jXfVrnw4w3m+v0+Xl0cevThj28s8u7v5pPNhb01vVpnMmJJIqIhC61rW lUALM4NdL0uc51ZM8502ACFgAgIAIBAgAEMgoKBQooCRJRapql301ZnljzyrNdfTz49OvC+jz8fo cud268E8vKXe9bSM5kkSQUWJBrWtWtWgCzGa10vWZwtyxhvVAEAAICACAEEABDIKCgUKKAiSKLaX cu97058XPjFsvf18ppxz6efm904OuunJz83FbvbGZMyIBKVREt1bWtatKhWcTbW71zjKk54vS0IA AAgIACAIAgEMhQUChQUCJALRd3Wt7uOGbz5RbHr9vm7b4ebn6seb3b8rprr52eHFbrU5xMyAQKqo Lbat3rSiFZ53o1rW84yLnGNbtgAAAgCABAAgIEDIUFAoUFAiQUKl3rW9WcMnPMlser28e2uXm8/q eb29PJq63xk5cS2ySTMgIQq0JdVau9W0SLZjGurbesYlS4xneqAAACACAAgCAgQMhQUCgFFBEhSU XW9auecTMkW4dvdy76x589uPP09/MN5mOXCFqyZkkWCBboFq01dWrEzNamMb6Xd2xhUuOc6WgAAA IAIAIAQQEDIUFAKBRQRIUil1q3OSIhd8Z09U6Uk546erlhNSZx50SjMkyURC26tBozbbaWTGd7c8 7661d5xGpGMN6AAAAIACACAQQEEgUCgChQCxEKRVtQRSS3pxxr0dc7aZ46z7MYmLZM8cZkJEgUQi 3WtUNpjK0pTE3qYm+u7u5ygmM3dAAAAIACACAggIIgUCgBRQARCgtkLZFpF3ni7+jlq9bjgnsc2K yx5MRCAoAS61rVqa0xzwtk0l2xNazJ067vSZzZDOZrVAAAAIACACAggIEgKAoAoKARCqTSZXURaS Xd886ejMvbpjix6+nnkuudx4eYCirAF1rWhV1OfOVJdJrUzLZHTru7mBDGW9AAAACAAgAgIIECIC gCygWUKgEKtFzm2wjVSNa4Z131ia9GfOerr58W653Pm4oAKQF1u3RCreeCZbsauYJL1661IXMszm 7oAAAAgAIAICCBAiAoAKAoACCrSpm0ItIXni9evPLpM2d/Ry8+7c2Yu8c5nLMghDXS3VBEWmOc1q FkVmNdt6sipGZNaoAAAAgACAIEEAgiAoACgFAEBbaJNVCFqGc4b75zKzu3vrzbXnpnr0kTXQxx58 8Swt3uRAQl6XnyqJF1ZiOnbVQIzI3oAAAAIAAgCBBAIIgKAAKBQCAW21CghQkziXvrnLc63z9OuQ xU66auddLU5c+M2nHGtbSQEEu+ueGEkutzOF69NWIsTMmt0AAAAIAAgEBBAQIgKAAKBQCALbaS0I UQzjDp3xlpLnt1xiySr3WZxenWc+WLcScWrWrmBBF13vm45L0ucR06a0QGcxvVAAAACAAIBAQQEC IKAAKAUCAC22hQSgJjEei4asm89nLUZrDVzz5wkSp2uVq6sghAa9Ty+ea3qYk103oCGIu9AAAgKC AAIBAggECIKAAoAUCAhVttFAKQTjl17Yasyna4GKcuczJAg36+JVtKskgFvpnm462xld70llDOZd 6oAAICggACAQIIBAMlCgAAFCAQLVtpQCgOXKb9CXXPOp11iWQTUznOcZkhe3bkqhVqSAF9GeBnC6 3pLFpMRrdAAAAAAAEBAgQCAyUUAogBQgICratoBQCcMXt1yuWNurAzQkmcSSTL03CrcqUEAL2zyz nN1rdgpWMm9UAAAAAAAQECBAIDKlALKAgAAAVbaqgAFcuM33uYc9XrrMM2x0zMYzImcvTMq1JYoA Ab6cuOV3uotslmJbvQAAAAAAAgEBAgCDNKAoACAAApVWrVIAoZ4ZnbtiSsbdbiySno1nlzziSZxf TnKioKAItTq4c2925NXWDGa6aoAAAAAACAEBAgAjNKUAKgAAAKUq22iIFA5cs677xlvOL03JZjQ1 c5ZSc8X0Z52iwBQIC9uXG70ytqLnObvpQAAAAAAIAEAgCASFUAoACAKBSlW21SSQVRM8cTfoYi8u l3rNklSKImczvjmosigUQIdbxaszbdTNZw101QACAAAgCgAQAgEAypQKAAACgVVVbbQSSSlCc+OX X0YyqTXRjUxokoSRNXioIsoKIIa684kXVYtzmXpqqAgQgAIQClUoAgAggMqoKABQAKClWrbVBJJB QTjyzrt2xGspvWYuaSiTI1vzKJYKBQiF65zMtXTKuZvdAEQiIQoIQBVW1VAgAIIMqoBQoFACgpVt WqoiSSCgzwxnffpiWM6u8EpGiTMp38kohC0KFZI6MZXVSLEu7QJEiIhAlAgAVbbqqAIAQQypQCgV VABQVVq2lCJJEKCY44nTvvEJNbYtxolpJIejjwUgFClLJI3rlLqpEtk3pQkkkiBAJZQAAF1rVUAE BBDKlAChbSoKCgWrVoAkiQUE58cunfWEknTUzctGdWEkdOnjQLCiktAmdb5LqSxKmtVSSZiIgCBQ ACUC7uqoAIEEMlAKCltqoAqkFq1SgRIkCrCcuOXXvrMkz0azGdW5lBJfT5eQAqhKpFmdawqBI1qq JM5hAACkCAAC3WtWgAgQQyKACi220QKqyxFq0oCEJApSZ5cs3fbpnLF1bkx0XCoRO3Tx5VAqgUQZ uskAACySAACiEpCoBCl1vYoIEsCMUAClltttqQKqkRVoUIBBFFDPHllvt0mXPTaXOelZzURN+jhx lEKosVBJNJlC2giBFQABQgVIqAAF1otqgQCMKACirbqqiFVaJJVUAAEAUGeXHLXTrrOec6XeWXSz MSIde/kwRYVRSQRFzAtosIQhUABQQLARUBYFgturpYIDBQAUW3VtIiqaqySFUAAAhQpJy44XXbpM 8p1tjLoZlxGZvv18eSLFqaixEgqRKVQECIWVAFEAWAlgARYhS63qiA50KACtXVtICrbamZKUAAAA VSTnwxGunXWefLXVNRtGLmRN9+viyipRQSRQjLUVQCBEEtgWUgCUgBYBFggLvpQDmUAKFutW0gKt tpmQoAAABVBJnjyyu+nS8uN74u5nciGRrv28OEoAJKBEAVQglIRDWaCiAAAgVBIoA11oDmUCKFW6 1q2IFLbaTMUAAUACgInLjjN103ucnPrdSakM25zrfbv4ucRQLmCrJYhKWhCLKiJK3hY2yJRagAyq CVmWgEvaijmUCCi3Wrq2IFLWlkiUAAoAKABMcuOV1vWpzz2WZtlSbuM73348JJVCZCiCCVaSkJZU SZ1dYauLvWeZUKACAlMxaAS9aoMKAhZS3WtW2IFKtEAAFlACgUBGccuWVt1ZnptcTdkrbEXs8+IU GRRIglKqAqAmZnepEib3vTGMhYCwASSLaAS9KoMqJQFLdauqiBRSiKABQoAKUIITny55C6jpuzLV lJMN66OfKCKyBIAC2AAkzM63ZhvETVuunTrOXOZiAoiSELaAkuiwNlAClt1dWogUKAoAoKAFilAg JM8+fOQurbvUzaSyRbrUznKSds4iCAlS0AEkkjemJLqc4t0tu6WUmYmYQIW1KJEWgOqgBS23V0qI FCgKAKFAAKUAASZ5c+chrUatWsyXd551oaznLe5mYhAAUqxmSJbZIi6coVdWhCBAEKqiIIWgOqgo UturbSIFBQCgUCgAKUAAqGc5xjOYBrVtkt54yVLtLvet61mZmYqBImWJIWySJFtvKFFt0EIQIVVE iBKWgOpRSirbq2iIFBQAoUCgAKUCgAJEzMxDOZm3RJIy1u4jNuunXrq6LamMYnPGZGVZkiSQW6mL q3RQiK0kJAESIiCloDqUtFFturaiRKKBQAooKAApQKACIzJlFVSROXNI11WZykzGt9enTV1rTOJN TKYnPGsYymWVGtbqoELAEAEUgiJgVaA6lVaUq3VtsSRKCgoAooKAAUUKBJMySTOYFVbq7Y8+Jl06 a055iTOULrW973q61dYxMYxjMxJFkq63oKCWWBBLALEpAJMwpQOqqtqhbdW1EkgoKAoKKAoACgUJ M4mZIkxFtILbvpPLk69N7xyzETEIEq6t1bJM5zAVczW96IKoEQJYgKEBCBMrYA6qttopbbpUSZAo UCgoqUACgKCyYxnMgSTK0BA1cY12vTu83KCZyiKkAWxWYqlSdN2kpVIEQFkQKCFhIBIpA61VttFL q2rJMwAoKAooBQBQWKHLniRQkRRCCBM76b3u8+YRJJEzAQtNM5lqrF3apKpUAgKkiBQQIgCQA7Ft tUVWrbUkzAAUCpVFAUIpQsKTjyyKGUqiRAhLl23YkgVMyLUxBC3Wtc8ZaLDdq0SqWCUgAkQKQCEA SAOxbbaFW221JmSAoKAoooKCKoUBy80UokS0IkEDetAzBQygtYyKu9OWMrVmda0VQFWASUBEQlok oRAJZAO5baoVbbbUmZICgoCiigoAosoJw4FUkRVJZCEN7supGYlUpEVpObW+vPLPPMl1ZJd2igCk QKJQiEiqgIIAkA7lttCltuqSZkgKAoUUKCgCgAcfOKJBVgIkXdRdJSZlKtQLV54t0znEjWkzLvRQ AURCWkUIgyCkACIsA7LVtCltuqSZkgKAoUUKBRKCgBjhzURFoCQjWpldW2SQsFoDQ54aMSNakkzd 7UABSECoUhBMzQVAAiLkK7VWlClt1aSZmQAoKFFFJQAKApOOMtbznOVoCZLpIrVXMiiqogtZ5Lcy a3c5mczXTVFQACWKEAQhMrQpJQIGS23oW2gpbdWkmcxKAoKFCqIUgoFCjPPDfomunnzvTnjnvOM1 molWrJKsoqhBZjmVd3MzI1VtKIAIJbYQEIhMtUVZASoEtqtltoFW6tpJnMhQKCgoqkACgFCpJiNa vO7t5ZyGcFS21JCWxVAlpMc4a0ZhSNXSqSAICWiBCEJmXVoEBAJVtXRbaBVuraSZzIUCgKFLQgAK BZQJVmZkQWscgFtkgoKSBVZ55i2JFVE3vVqxIAhYUQgRLJJJrVoSwEIsLat0W2gVbq2ozMyFAKCh VpYgAFBQAW1lSIZ80BLbZEUFEQUzjOYAKU1vVWyIAAECBESSRrVUgAgQXVXStWwFW6tqMzMgoBQo VapIJQFBQAVbJaiRw5ALaRJQChCZznMhVWqBrVNREKCAQARJJlLvQAAhCLdVdLbbALbq2wzmSAoF AotqogCUKFAAFASeWANNQkAJVEkzmZQVbVQhdaoIAsAQARJMwu6AAgRFtttq21KC26tsM5ygFBQK LVIAAKCgCFADPmzQWqhAAqGcyZk315QtoIQurVglAAIAQzJJdWgEAIhq3VUurZCi261YSZzAFBQK LRYhQSgoAAoAM+fChShAAlkkzJku4WgqRErdUIoSgAgIkkLaIsAIQ1dW0W22QKtutWEmcwBQUCii oFAAUAQkBbSs8uJQKqUgRZJJMwXS20LZJlJDeigAAAgISQpSCWAQt1q0ouqkCrbrVhJnMAKBQKoA AoAJIkSSQFW61pjigoVQkoSSZmRa01aVL0c5nKRdWgAAAEBEQAAgRbdW1QLqyAq3WrYSZzACgUCl igAUEzmZkEohAW73mOc0ALd9LFznOZmYkhVq22tVl1vNnOUW1QIAABARCJQJUBbbbaANWyAq3WtI SZzACgUCgoAAZxjMhQqwRKW9uvPjMxrW92W6vXpWeeMY54znMBVqrVpG61jEkqqAICWAAEgkUigV a0tAIrWkgLWtXSEmcwAoFAoKAAmMZzAKLSES0vfpz4SMy9N61dau+lTPPly5ZkgKpVKVBbMxVoAI CAACIJBRS1VqgAitauYFVrV0lZmcwAKCgUKACY54iChS0SAp13jnkmJrW9b1db3qzPPjzxmCFKFK CLCS1aIKgIAAEQRFWqWqpQgCF1pIFW3WqGZnMACgoFCgBnljMAoqrSSAtXUxFmZbrWqt3rUmMc8w iFAUogRYUtQAEAAIEEKtqqKoCAIXepkFW3WqGc5kACgoCgoVMcsZAotuiyIkFFrJreJqraq1JnOZ IIFgUVAkUUtQAIAAIEELbapSgEAgmt6mQVbdaoZzmQAKCgKCimePKAFVq6LMokoChobttKmYzmSE ACFFQSRaUoACAAgCCJbbbRSgEAgzvesyULbdaokznIAChQKBS2Y8+ABVa1qRZEiUBUW61rS0TOMy SCAAigERevbrrn5+SgAgAEAQRFtttKoAgEDO+lzlQtt3aJM5yigChQKBSnPz4KBVt3qZESQoCVdb 1rWpUZxzzCCAAhQETXbv1snDz5KIqAAEAhCFtttpQAgECb6M5KLbrWkWZmcoKAoKAoKpjz4gKW27 VkJJCggu97uraGefHMAgAIChF1369LM8fPkAAACCAhBdW21SiBAICb6TMKLbrWkWZzmQKAoKAoKp njzygpbbqklJmKBYN73bbVVOfLlEAAIKAhdduu6zx44AAACBAQkW3VttKCBAIBvpnMFLbrWkWTGZ AoBQoCgpUxyyiIVbq0hJAKFutatpVpM8+WYhBRShCIhrXTp0rGOWEAACAgSwiVbq3SqAggCAdNTM FLbrWkWZzmQKAUKAoKKmMS61ZWcru1MyZkQFttt1VBVM455VVLrpZjOZJEkiFutb3q3OMZyDIIAE qEEFturaoAggCAvS5yFLbrWkqZzmQFAKCgKCqSSW61al6brPPOM4kyFttqqoCiZzmLdW27qZxjMz JlEtautXV0kzjMUVCmZEIIQlVbq2qAQCAgDXRnIUtutaSpnOZAUAoKAoFUBbaa1tZzxnGYilVVLK AUTKQttuqmcYzmSQtW6tttVMzMJVLrcmM5mYRCUWrbaoAQCAgE31mICrda1UqZzmQCgKAoCgpSrS l1us4znMCKUUJQAERQuiZxmSAq21VKskiAW6smcTMiCFVVqqACAgEATp0mM0VV1rVSpnOcgKAoAo LKClVVpbrTOMyQAqKIUJQBKBbGcyQCqpQoSAFaMzOZEQFKpRYAICAQCOu84ilVda1UqZxMgKAKAo AoKVVpbVmYkIKsBBQIKAUtqZzIgoKAolgBbbJmZkIAKFAgCCAIBDtrOIqlXWtVKmcTICgBQKAKAK tFtpISAtkCBRAAoUuhM5QAAAsqAKtJJmICWWUACAIIAgIO7OYqi3WtWKznOYAKAKAUAKBRS22yWS BbcyKgAAAoq2mZIEAAAAClJJCAJYACAICAgCDuzgpS3W9IrOc5gAoAoBQAoBQW6BkF0zIsAEUABS 2oygQAAAABSJAgCAAgCAgIAg9ExkVS3etIsmc5gACgoAKACgULaWQi6rOSwAlAAAtEQIAAAAgFEQ QAgAgCAgCAgPRnOQtLd61CyZzmAlAoFACgAoCiqWRYurMwCUlgACpYoJUACAChAAIEAIACAICAIE A9GMwKq61uosmMyQKAKAoAoAKApRYC6uZAlCAAAAAACAAACAAgCACACBAEAgDvnMClutbqLJjMkA oCgKAKAAoFKAlumYAioAAAAACAAABFgCACAAgAggBAIA//EABgBAQEBAQEAAAAAAAAAAAAAAAABA gME/9oACAECEAAAAN0AAAAoAAACgIEAgACgAAABCgAAAFAAoSpQAICBAAUAAAAASgFRSAilAAUAA AgIEACgoCUgAAAKAIEoUAAAoAEAggAsooAAEAABQBAAUQBQAAABAgAoUAAAIAAUAIIKVAUBCUBAU WCABQooIABKgAKACCVKBSgSIAQAVQgAoUUCAAAQAoAIIKFpQRJAItpZWUKCFAKKASoAAIAoAQAFq lCREEjVqURclLIAChQABAAEBQAQALVCiIJlNaSiGiJEUAoFAAAlQAIFACABaUFIJMy7SpUaEEhBa CgAAACAEBQAQAqligCYmtxY0ipKiCCLaAWUQAAEACKACACqAKEw3Rm6SoEAJIW2gABAAAgCKACAC qgFBlqjLWoSpIChElXSggABAAgCKACAFARQrLVlZa1LCJAKIWQuwAIAIAQAigCABVhAoJqy3DWs0 QiF1M0SKhrVQABAQCACUAIAoWEChNWNYaoXIQjSKTIss3oQABAgIAIoBAFAICypd5azLZpUiiRAt JkKnShAAQICACKAgCikCCiasrM2W3IUkgi2mIWal2CAAgQEAIKEAKoIIKS7zbnN1Ld5lSqyhBaTC itUCABAgEAgLAAtFiAlE2m84blu5lRaykEUrMaixsAQAgQCAgAALSoRUUzrWdM56SXozFFqJBAqZ upLLaAQAQQBAgAAVVsgBYaZ3MXWbemIUWpElIFZbuKloAgBAgECAABbGrEsKGdbxdZz05ruQUlpJ laQVlrpz1M2gBAECAIM0AAWxaEoWY6axuSdOV0gWVKJCiFTO95awoAICAgAiAAFBaIqiY63HTOd5 zvpnAUAQVEVM66zOsygAgQAgCIABQF1CKsXN0axO3FvrzwCwoAQEzeuuepcgAQIAQCIACgi61JCr CXRczryvXXGACgBKlTF7a5lkqAECACAMgChYhvTMKsM3pLJNY6ds8QgWgABMt9MLcyiAQIACAyoA KSLdswUSXpLM1OnXnzQiLbQBKDM6aytwoQCBABAIAlFEF0yCwzrrlJvM10xlJEi60oBFDM6bmbrE oQCBABAJKAKWJSkBJW+jOZvC6jMlRLooUSWkjpuYu8QEAQQACDNAFKQKCSyVraXO+d3cSKSQRboq FizOtbzm6kAgECACWDKgUUQKGpJJZ2rOs75b255WpZJIS60ApM7bzEWiAECAIAzQKFCSqF0vPJ00 zrHTlvpOOW6ukmcyFugWWSaus2SaqAEsBAIAgKCgkpSze7z5Lrpmax0561njNbtsmZIgbtASa1c1 hoACAQBAIFBQEFW3dzjma65rHXGrnhNakkhKEt1bbFmbrTNmWgAQAQBAgUFAQK1rTnMGvReeHXnp OMtzJAKKt1pBnd1MmNUABABAQIFAoEBbplIO25nPTK555lSQQ1RVtM1npdZRjVABLAAgQICgUCBQ C463TGtZTniRCiXVqzFVc6jppIk0AAQAIECAUCiBSUlLjddOd3lOfIFSzV0LnJVia6VDLQABAAgQ QAoABalRSZu82a6c6mpDOcl1RC4Cyb2JJqgACAECAgCgAFosBmbDplG6bnPLOdVCLrOJbl00hm6A ACAECAgKAAC0oExdyrZEzMoaq0QOmOe8zegyugABLAECAgoAACrQDGepG8hM5knWKLAbznOtCzN1 QEKEAEBAigAACrQKnLW5G0LcZkz0gUA1zaKZaoAKgAEBAihQgCiraiKYx0JdIEkzvICkNSWWpnWg AAABACAoABRVtEgTnOsstksqRcAFQaktiNUEIAhSqEASUsoKBRVqoyDPPW0UBJrEAoRrKpZdESIE qAVq0EAzQKWgCrQRAYxraKllQvMFAms0hoZhAFgBdWggMqAttgpFoBBUxjW2bYsI3zgUCWRQgEFI ABdUASAUtthVQUCBRzw6XFpYku+QUIlkqiAIUllQAC20TNAW6qFtSKAAVM8mt5miyJrfKCoCBRAg KgABAuqM0BbqoVagABQZ55bubUC75yKgQFICLFQUICAatM0BdWoVQAKABnnlV0Fk3MCBACoSUjVq SAJFBaKAtuiFBQKAKQTGItqosqRZIALEiyWSroIhChCjVCrbSFAKKAKARnMQpmKNb2izMzJCSFSq qVCKRAo1VKttiCgKKAKBEkgqznlvWZJNa1vQmcZzENWgQAEJFDVqqtqQCgoBQBnMiRYLrnd9OeUk lirZIVNaFCBACCA2totqSUBQoCgYxKQIIdKyMkhLbmUtooIWIAgQNraLqySLKCigKCcSwCE1aZKR WNWMxZqgVACAECDdqltskhQKKAUJxACKWpLKFZXOaTVAqACIVCwg6LRbbJIKBSpQBZnOrJZJAVCl hZkWSaqoAAhJVQCVdLRbbJkKAqwFAIJZEyUhQLMSkpbSAAgkWhAltq0W2yZAoLSAoAoSchbAAmZB Rq0gAQSS2iBFrVoNWyZAoLUAUAAxgqwATMCiNUAEBMrQCGqtqVbSZAUKACggUzzFAQzI0pYkaoAE EigENWlspbSZAUFACRIQNVgqXRJJIW1VTMWgAIIBBbaGkpbpMwBQKBM5gUitb5Rd272xz55hVKKk oARYEJQq0BpKXVjMAUCgxhKKIW3LLWrrVzzwRQpLCgCAEFVVCBpFXVTMAKBRzzFLakShTS2JmIAq IpQIqAQtUoIG8i3VTMlAoCpygq6REoNa0MZggAL13jkAEAiW2qBAbyLdVMwAoCnLIrVREoXd0Mcx AAN9bjlAAEEW20BAusi3VTMlAUCmcgtRILbbSs8waSIg3uznCACCLdUAgNILdVM5UBQFEujdznEi rSgZlXVmMyRbdWzMC2ZkILVoAgNILrRM5KAoBVU1ZnICgCKWZyKqhAtzmQFUWAQDczVa1LM5FAUA VVVJFCFgULZlBQAKkkAoQCATpmUutGcwUAUAUtIlsgACqSAAAEQAQAgE6ZlLrSTMBQCgBSkWyAAB UAgVABABAEAjpgq7qTMAoBQAUFuQAAAQAAEAJYBAIP/xAAWAQEBAQAAAAAAAAAAAAAAAAAAAQL/2 gAIAQMQAAAAIAABRAAAAAAAAoAAQACxYFAIAAAKgAAAAAAAFAAgAAAAsUgAAAAAAAAAAAKABAAAA ACoAABYqAAAAAAAAoAQAAAAAAAABZRAAAAAAABQAQAAAAAAAAAKgAAAAAAAFAIAAAAABYAAAVAAA CkAUSkBZQBAAAAAAAAAAAAABQgAAAoQAAAAAAAAAAWVAAAAAAAAWAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAACwAAAAAAAAAAAAAAAAAAAAAAAsAAAAAAAAAAAAAAAFgAAAACUAAAsAAAAAAAAAAAAAAAB BQAAAAAAAAAAAAAAAAAAJZQAAABKAAAAAAAAAAAAAASiUAAACAUAAAAAAAAAAAAAJUoAAABAsoAA AAAAAAAAAAAQpKACKAQCgAAAAAAAAAAAACCoKAJQAgWFAAAAAAAAAAAABAAUAAAQAKAAAAAAAAAA BKCAAKAAAQALFBKAAAAAAAAASggABQAAEABQAAALAAACUAAJQQpABQAEWKgAKAAACwAAAAAEqVKR SAChKAJYqAAUAAAAAAAAAEsVKiggChKAIKgACgAAAAAACUAIFiwUgAVAoCAAAUAAAAAAAlAAgWVJ aEVAACpSAAAUAAAAAAAigAgWWJaAIAACoAAAoAAAAAACKACBZZFoAgAAUgAACgAAAAAAIoAIKlkp QBABYCoAAAUAAAAAAAAAgsqCgsEAFgKgAAFAAAAAAAAACCyoKAAgFQKkoAAFAAAAAAAAAILKgpFA CABUAAACgAAAAAABFBALBULFABFgVALAAFAAAAAAABBRALCiAKARUFQFQAAoAAAAAAAIKQLLCiBQ sqAiyiBUAAUAAAAAAAQSqILAtgFCUQRYVAVAAFAAAAAAACBQyoCgoAIECoBUAAUAAAAAAAIKCSgK BQEAAIBYAAFAAAAAAAAAgJSqACAACAqALACgAAAAAASgJUCUtACAAAgKgAAUAAAAAAAABLLABQsA AAgVAAAFAAAAAAAQsFSiAEUoASgIKQAABQAAAAAAIAKgApRAAJQAEAAsAUAAAAAAIAKhYBRUAASg AgAABQAAAAAAEAFSkAUsAAAAIAABQAAAAAAEABUogFAAAABKShKSgAAAAABKlgAAqFgCgAAAAAAA AAAAAAQAAAVFQBUoAAAAALBRAAAAAIAAAAUioBUUAAAACiCwAAAAAQAAAAKRUCiUAAAAUEFiosAA AACAAAABRKlRRKAAAACoKgFIVAAAEAAAAAolCKJQAAAAWCpYsKRRLAABAAAAACgSgigAAALAKgFg oEAAEAAAAACgEKigBYAFQqAAKAQAAgAAAAABQAAAABSUBAKAlAIAIAAAAAABQABACgUAQBRKACAE AAAAAAAogoCogClBAAAFlAICWAAAAAACgAEUoEFAgAAABQIBAFgAAAAoAAAAoRQgAAAAWKIBALAA AABQAAAAAogAAAALCiAQAAAAAFAAAAACxYAAACwKlCAQCkAAAALCygAAABUAAACoKCWAIApAAAAA AUAABQEAAAFQKSwAEAogAAAAAUAACgEAAAKEUIsCUQBSAAAAABQAAUJUWAAAsqVKEVABAUEAAAAA FAAFEAVAAAFlICoAEBQgAAAoASgAFEAAAAAsUgAEogFAgAAoABKEIqqQAAAABUAABKEAoQAAoAAA IFAAAAAALAAASiAUBAAVYAAAQKAAAAAAqWAABKEBQEABaQAAEBQBYBCgAAAAAEoQFAQAFogAAgFA ABCgABYsAABKEBQIABQAAEAUAAEKAApFgAAEoQFAIAACFUQAAAoCBQSgqASgAEoQFAIAACKAAAAK EAAFAAAACUlQKACAAAAAAAAAAAKAAAAJQQFABAAAAAAAAAAABQAAAJQQFAAgAAAAAAAAAABQAAAJ QgFAAIAAAAAAAAAAFAAAAP/xAAkEAEAAgICAgMBAQEBAQAAAAABABECEBIgAzBAUGATcAQUsP/aA AgBAQABAgD/AOGpcvd3/pdfBuXd3dy/Vd3d3d3d3d3f+iV77u7u7l3d3Lu+13d3d3/mFV3r7a7u/ wDXaqvfd3/ktVX019alVx48ePHhx48ePHjxqqr/ACyq+kvtVGPDhUu7vYVHLlytZXGjHjx41X+RV 8itVXtqjAwnLleqqqpl3HXHjW7MjNyMrNcHBK/zWq1VV2vpRiYRz5aqqrV3ON3x4VGJVVSVVSxc8 c3JE/zx9AGJhbluqrV2SubmYmE5Xdyqr12ZWn+dMW+lAYW5bqpd3YcXyKYByuVxqXq7vVei+Ur/A DdVvdBjhHLdVFuBVOTKDQcavS8pXGu9VXXlf+OX8hVXYY4mLk7qqZcMeLhaZZGNEMeN3Lcq41uq9 ldb/wAxVV2GOFuUqqoMlQx8dZZUtGKmNLrlK73d/Csf8uVV1RjjjllKqqpeShhi5wGU5AY3KWg7X 2qqqvcJ/lirsMcQyyqtVHJYY4mWZjpbMduUrrfou7u5XHilbvsI/wCUquwMSLW6VVgTPPHHWWQBp ZXS9XfvvVJVda0P+TquqDHFj2VVhsNKY7cgrtfyKSu4n+TL0xxj1IqrAZjjUYpjpV47uLfx7vdJX YT/ACRXdY43fQiqsNGIRbDSwOt/MqEpNX0xT/ImLoDGPUFVYDMcQjlA05dl+BXtGXcRK6if5Cu8S cugKqwJgayygacg6L7gMDDgnrd3Yy4mq3i/4+uwB6gqroGYkWBcUOi+8TIYxPUxOg7Ttjl/jrs1i K9AirrElArA0odF+EZGTET1InrqOscv8cdGsRj0CKugpmEc1DTA2r8a+Sw9SJ2Ogo6Ef8aWGgI9A BVuqGZOOTAjFDa/Sox73oSI6xT/ABw1iKuwVVAcYDGBDS4kr3V8E9DGOj0jE1i/4uRYExGLsJkqa vEjs0oSn6E9KMSHUiaGJBH/ABZ0bxF3QZMdKAbIRYAL8B+WjHuRNESqxf8AFlhrEV6AxYqzA6EuB iLF+qYxIR3RHRDTsf8AFF2BGOgqZMXWONXbDeIaY/CflpSQ6IRCJtmL/imTogRdACzJlACx1iKFB kvw35aMYwiHbGJHeL+2PmMYQmIadADMmLrDBdgwmAY5PxH5jpIMqIaIx0RMf25H5eTKA0sIEWZOs RF2axxBmSvw35j0JWKibI6ZRD9tT8tgMx26xCKpMnXjxyyjoFDEJmvwDq/MepvjkaNOsIh+1NZfJ Jkzx4sBi6xI6yZWGOe2EUmBFfjPzXpjKx1luojByP2oVl8rNJiBiZLoAYsylTxYeXM2RmJiLkr7j uvVbu/hvbGIiQhERmLD9mAK/KZgZTEI6JiRYxjMBdmqxLW/gD0Xq9h+Cx1Y46JlMiYxIjMVh+zG/ S/BdYGcJnHRMRmUIxK8Pi/6U0aYTEVfhDd31flMdEx1gokFIiRh+zPW/ByZ4y8Z5F1iAx2zDHE8j o3iPzFflumEIQjGMIOkhD/DmZJMJiYGTKxGMIwjPAebKMISgxxyX7djvGY7yiS8FMhmMT9ifMYzE yhCOiYjGEYwnhx/6WMJRPHjkIj9u7xmMomQ7wjGZQi/vH4OWvGeWBnHRCKx0uJhPKusSV48cpWb9 u7xmMAjEdYrGJiJ+zPlZ68OK4zyusSMIsynhxyYsITExMtZv27shMciImW7YzGJ+zNvxmZQnimEw mbrGMZTCL/zn/QxmIFeM0r9w9MZiEYzLR0x0n7E0QMj4rMpjGYzCO8dZRNE8B/0OsYa8ZkzyP3DH eM8cSMyIadkyP2BDRCZHxWMwnkhMY6IRjEIwnhcsnWJMTEyjM37hjvGeNUjMtEIwKJlH9gQ0QmXx nWEzhthpcDyQmUJkkySY6wxjMo/csd4wnLBY6sjohMo/sCEIay0/Ey1hLJlpmOsnA8rMpjPJCZTG YY4TAdZvuu7u/pmOyEJhFd46ZhGKv7AhCW6fUerLWMxmMz2RWYzJmUxmbGYy8DCKzyPsu7u7ux+i qmOmYw1jGO8IxmEyMtH7Agmrj8TLeEwmbq3oTOYYu8JnljMZlMnJ9d3d3d3YnxX2kpHTMZjKxiI6 w0zEymWj9kNy791jHWUIzGYzLowmWRCMxxt1jMnFxXJX0XfRZfQh8Z9zMtkxl4ykdY6ZjGZEP2Bo hGPvdWOmYmcwxrLWJnGYzIJi3p1jqxch6va7XrQHyHY+i1WqJjrxkZkTGOsRmQn7MV63d93pcZi5 zxSuPEnl1hM0mEZRMtENCw7Peqqqqut3fw3d33dEdY68emZ6xjrFYx/d1XoepGYF4Ahzyyu8ZmUT A80HLRMYAJ2d1VVVVVVUOl3d3d38pjLhMZWDa56wjGYxjE/eVVdXszx6AcMss8lwyy8WGeOUHxzz 7QMZhijim2OqCqr1Xa3d3d2N38e1dAGMvGKqzGKzGWy/wB7SV1erPGk8eGWMdZ5ZnhMjKePDHHzZ UhMphMIxmW3VVXtu7W71d3YnyXRDd4xjsl3iqv7I9YUxlV3IwmJgeWBUzc3wTKZTxQPNCLgeWYGA mUyl3664uKVVV6yHyWMJj0wjHo6I5OQ/u6JVONTj2I6xmD5oRgeeZzwrllPCB5oGU8czcDGMymTq 7v0BVOPHjx4uLilVVVs+UxhDpjGOjToizGH7q+V4KMugccsa2RJjMMs4awn/QeSeLHIynhZ5YOT4 pmYzGLlM5d3YkOwBVUlUiIyvRd/HdnQjHZHbrH946xbQxCqyxcYRhEmMxcpc8T/AND5J4J5JmeLM y8pGeOMxmMIubcuyHSqMQrTFu1tVl3d9bG7+Gx2bNMdGmMY6x/WvvpJiwIaymUdMJcxmI46xfNl5 DwTMzmMwnmhGYTGYwzFc27hCGjRu7tycnLlycnJbvtd3diI38FjDRDRt0aYxjrH96iYgVvJzjp6E 8LlPJBxPNhnP+d8uOZx8Z5jEyPGENszb0Qho1d3ycuTly5Xd3d+q7EbG7v3MYaIaxjp0dGOj90dA xCVUyyXLsawR80xCZzyn/O+U8kHB80xaxKJSE8pshCXfLlycuXLld3d3d3d3fa4Qg3d3ftehDQPQ 2x0Q/dGyBDeSsz9Hix4eUwwywmT/wA75Z5QnjfKBMG8ZbrzdCEqOXLlycuV3d3q7u7u7vudLshD2 MYSqCBGOzbHRD90Q0QhDWWTlrPJ7+CEyMSgZ4p5YyvFM94RmMylzzFVogrKr5NdiHtYzGVQTGMY9 SMdEP3RCEIQl5ZOWlYnbwpo1mZTGZTFTxzIqjWDmVPN3flVKrrQHou9usdmsYxj2Y6P3hCEJbmur 3VVWvG45WN5OZjgnjnkPHGJVExmZGeX00/DqqqqqVWz1LYxjrGENkY9mMdH7wly3K5fcgOsXFymO TlECeM8p4mZGxz0zISq6Xa3d/AqqqqoK63d3d2q2aY6IQ0aY6OjGOj97d36yYzyEHBYMEgMxPJj4 XFy06HKEdZS1u7u7u795DVVUoKYxbu7u75XcdGldYwhojGOjox0fvr92M8uiYIjyEWZzNnimETTC ZQjFyy5Xd3d38EhDsGmLu1vVkIx0NrrGGiEY93Z+9fSdxzjrFxXQ4s8jlDLFwWZS4RhpnlPj0Y0F dTTMl6OjToIx0S94w0S17MdEP3r8A06xcHLV45Y5ZyyMxWeSCwl6Z5ceHCqqqqq9dGIem7mQ41GM IGg6V0JiG79Do/fPwRdExWO8cs8sWZTxuM8kIpHbEPH/PLw/wAnxPi/l/L+fHjxruAVVVXpuuLiy gju71XTExIae93o/wAPYx0OMTQ5OOk8SMQ2y4zFuVXExMP5/wAnw5eB8T4+PHh/P+fCoFV7BGssO PS70EY7xD1sdEP8QdkwcjdUQzIRmYRhHbMCq2J1yMsXAxMUTLRCX7hxyjjlh2CMY6DGEPS9D/EaT WKR2dMpguWQaJltmGV3drzPKeUyfI+Z8r5Tyc/6c3J2S7NPtM8Vxz8b0AIrsAPUuz/EWI6HBR0Qj CZvjTTCMejP6f1/s+Z83Pny5cuXLlyc+WBk9iX7scsck8njdAEY7APUx2Q/xJHRMHpWbo0TKEYdc sePHjW7u+mBT3u/fjljnkeTCgjF0AHW+q7D/E8h1i4qQ0wlsxbQjCMOlOLi4uDg4uPGuniMn5OLj lkOOldgbvursh/iiJCYOrtl3MHWW3qN6tiyqpKnjMnoR9F+wRRiuyEPVa7A/wAVzHWLiuruJi5QY hoj0Jw4uKJVVp0TGZdSL8XFmQx0Q2vQjtXZD/FkTWLHZMg1hBjvIOmDTi4oiVp0GMy+bipkaA3ez or0D/Ba1XuyHWLhkmxzBZcQjDrf9Obky5VIlYwMvlXrFmQgBpet6Yug/fVVVVVVVqq9iZGsXFyIw mWgxcWMNZQd13YjiYkz+di5RA0uzS7XRD4d3dy9Xq7vrd3d/lKqq+PkIwcUibcYxhHTCJ78ZmfNx WJpehF2uiB7bu7u7u7u7u7u7u7u7u7u7u7v8jVfHZlikHHKJ1IayBjD1PXGZj84jq/QD0D13d3d3 d3d3d3d3d3d3d3d3d3d3d/qkyx1i45J1YIxLFB2darZKT49dcVi3o3erlQD1Xd3d3d3d3d3d3d3d 3d97u7u7v8AVZCaxyxyTuL6R9PjfLj8mq1jGOiG3odA73d3d3cvV/OsR/VZYpBxcck6JjtNZQdJB 9GD5MX5uMY6N30FYHotZd/Un6pmWKQccscqTTLJcYMQdJY9xwfJj8p1jGO79Bu7u9PS/fVfHuxEf 1CZYpBxyxyiaTFhpNjpPTg+XFNV6D4GMd3d3d9ru7vld39cIiN/pkyxTRljmKRKslyokHSejFxfL jofkYq3d38WvrbMuXLly5crv7eq+nTLFNCZY5VSZF4o0goI6Qa6jizPD5j9ifJu7u+XLld/a1VfT JlgmrHHO0RxMjK7SII6S6645YZZY5HpI+q76srR8m6+HfS/k3ZkZX+dTLDLGpYmRlEyxg4KcrQdu rrVQcPJngnwb73e7v4zq/tbMh/POOWCaEccjKssExyUaIgxlyrqtkw8nk8eWPvv6V9Npd+qq1XSq 9b7cX55+ATLBxSCJkZGTi4EvFu0HkmqoaSzIcPLkZ+P2vur5N6I9CEcZd9h9d972+0h9AfgXHLBx qCZCZckzxxRQWIZVWkhE4Ji45GWfhSPpetfROjoSqYZXHBPlVLX2kPsbv69HBwcal3d8sgmOe0gy tUZMqWOHkzxcPVX0jLWGmMIy9GQmQvjfE4/Gu795Dvd/GPwtODg47u7MnETMaiDfR1bCXimbjl40 7YxPoru1gBpTTLux5cjM82PlMX/AJ3wPi/m4caqqquPHjUu7u/gjy5cuXLlyu/ztOOXjfGnQeUJy M4lcrl8rvVEEeS8XDi4xg8nHj8273dGMtdEJmdL6CZ/0/r/AF/r/Tnz58nLlycru/g3fzz8bSOD4 nxuNasbg8zO5dpxl2Ri8jycuXK3JWMEZxfH/Nx3frNVxqpe7ucQu77Zdru7+gu/2acHxvifE+Phx qVU5GfKXaURWOru7snGWZGQkJx/m+F/5/8AzP8Ayv8Azf8Am/8AN/5v/N/5v/P/AA/j/LhUdPWqr tfQmXeqrV37LvpVVVS7v91VVx48eHDg+J8X8uMMr1eqqqMTFl3qzI8h5MczKxu4dWMYqrcrhw48O Mvpd9iZSuHAw4ceNVuqlUSgca48eDjx48a3VVVVWq/Y3d3fJy5cuXLly5XdyqrjWTfLly5EMZUpI 9LMzy4+c8x5f6czMz5cnK64fyfFw4ulXSrd3b3AxMeNfTVXGkr9Vd25cuXLly5Obnz5cuXLly5cu XIzMrmUy07xwCGl6vazM8h5TzHlPIZ8jKx5LauTk5uTm5uV3t6UYmFS79FVX0KJX6W7cnJycnLld 3drfe7u7MjIyycm4GOIY48WK9WPqu+XP+h5Dy/2/s+X+r5OblyvtUYQDEx61VfWVUu7/Pq5ublyu /RVeu7MuWWgCsAMplHpVJXwDs+plGIfcV1r5r96rm5cr9dV77vGBCGT5Msq1d3Lu5Vca9NaqqY+o xD88/eLlk5fPYYY46u7u+ydqcarVaqUBiivatAH5g/CZq/OAxMaqq416qqqqqpHHVVx4mPGlV61F gB/jvk+dRiF2S3Ll7K1VVVUiEDEDI05LqoFbAPqX5Duqr5b95meh+GBjLu6NVxr4uWhHldquiBUv RA+qflXd/mMhH4b2CXejpd3d6qvUdcnd2uuJhUuWoB9Wv3D94iJxow/g+Jwr2XcrsdLeldK9qrtV hiYxVlrDAA+Hfxl+VVVVV8l+9RxqYp5HPHJyyTH+X8P/O+PI2nHgnoCtPx7W7u5xC3K9JxCx+sYw h9s6fvkcOJBFyseRm+RXs5dqCvg36VXYDd3d9j610Q+2dP4GnDhw4cePHjx4cP5/wA+GXou/RVeq +yrq7vlyu7l3d3eMPrHR9gd3T+BNVVSqqq3kr6K+Taqu79FVVUH1x9gd38Jd3u+2eXvfTd+i1WPo qqqq3difWn2B3fyGTl8B+Dd3fSutV6hv6o+xO7+QymXsv4V2t3dwOCbrvXUbv6k+wId38gzM+Zd3 q7vRLZVVXWvRY/T18kKqviEO797d3duXPnz58+fPlyvbMj593qqoJVVKqqqnGqrsfgKoK9B7iHdj 9tfLlz5OXLly5Xd3d3fLlyMjIy5XlH4Vyqrtd+oNVxrQI6etV97VUFV6T2kId2P2VuTnzc+XK7u7 v2hxZdreq412o8R/wA//mfB/LiiO7Vb95LXWMYnFxqqqq+6qqqqr2Gn1EId2P19uTk5cru/iExlZ RlurESDcoKDENsRGMVyclv42OXK7V+8qqqqqvlkId2P1quTlyv5JMdZL0ZZkZDcIaIJ0cUYr8i+l 3d/bVVVVVVVXwDb6j0MY/Vq5OS38zHK1ejsREyMuRkZGQ8uXJyXJY7qvxtVVVVVVV8U9p6WP1SuS 37Kr4JFeqVVAFauxMuXJyVjuv1xDT6yHdjH6lcm/ZVSqp999DHi4hxqqqqqtXa2v7Ihp9ZDuxj9Q zJv2nZ+JdwAqqqqrd3a3f7Ehp9ZCHdj9RlMn3EPlkCVK6MYt3f0Bjj48fEeLhlhlgn4shCMfWehj 9TlMveQ2/HAACqqVpjGX9BQYYgbZnH8aQjH1noY/U5TI9pA+SABo61SJl9GTGEGMZlMvxhDTH1kO 7H6lmQntH49AAepjMh+iJiiOlyj+MIaY+sh6H6pEquPHjxrsN/DqgAA9bGZRlVVemuPHjxqvYQhB G4xjKqq+lqq+eQ0x9ZDux+qpxSqAnHjwfE+H+X8v4nh/h/H+X8v58HGqqq61VVVVXtYxKqqrjw4f z/meI8fBwcXGkr2gBCG2MRN1VVVfMqqqvoCGn2EO7H6yuNaJQVKADaRiMTpVcePGqqpXvYiV2qg6 MYx3faqoKAO6JVbqqrjx4cP5/z4car31VVVV9CQjH2EO7H62pVV1IS9undVVVVVVVVV8OqqupDdq qsetVVVVVVV6aqqrsGmMY+2qqqqqr6M0+whDux+4IbYxj3qqqqr5FVVVu7u2O6qqqqqqqq91VVVV VR0dPeqqqqqqqqvpcdMfWQh3Y/cjdr9jVR3VVVVVVVfHqVqqSqqqqqqqqqqqqq+mx0x9ZCHZjH7G /Rd/apVVVVVVX0LupVVVVVVVV9VjGMfWQh2Yx+2u7vb9ox+rfRXSvrcYx9hD0MfuLsfe/Tv1N/ek Y+wh6GP4p/w9j7CHoY/hzTH6O7u/wBQx9ZCHoY/hzT/AIgx9ZCHdjH8Oaf8QY+shDuxj+HNP+H/A P/EADEQAAIBAwIGAgICAAYDAQAAAAABEQIQISBBMDFAUFFwEiIDYGFxE4CBkJGgIzJCsP/aAAgBA QADPwD/APHVd3/vcP3Sx2QkIXBpX/synYp8e3WeRIVmMepI8D2G+bIGxjHo8iXMQmUtZFZTAhr2W 3alHgfC8DfMRSiWN2SurIQhaGMckj3KX7IbN2JDfDlHkpQ3yKquYkJctLHdcSBP2JNlSN8uG2JZY uVI6hlK0NnkS0oVmPiT7CVJPDcwJZY//k8kiHuIdkrM8iPA3dC479bxrkS5myJ1ReSOZ8Twbsbwh sS5ngb5iV/IkPYb4S9lKkb1tWbJPizYbzURyG+YkbIb5iVmzyeBv2rHMjC4DXIbyyTIqcDqEjZW2 Q3zEiBsjmeBvWrMfsuRUrJOqNEiXMh4G8u6Q28aFueBvgMXFXsFJEvVGn45Z84IvshvmRdLCG+Av aCSJ4UZZuVV8xK88tHm0617RSRPAm0ZY27RaCcLRA2efbCpROqLTbdjqZBFoHURaSOQ2R2+PVKSJ fAm0k4IV0j5Mi25sie1TwI9TZIRPAm0kIlzeBtkW8ngnpJuut29SwpJ4E2ki0KySJ0T00Wn2dFpZ CIJZHWNEiZHWz6hkjHAgnTuZgb0T3qGT6fkhaovLvmLwvbcLTvwJvCE+YtrSyNM+1s6JIUa9iLRw pZsu+wzHpeFokha4tJBJJC1yR3eSNe3peeJLskN23tjTJClm5HdofBn0rC0xxJIvNoVtyWQT3OHq h3n0xLvPAhEkK+YHSo82m+5LtsQjbvEO8qGQRbHpXHGl3lyxvL07W8m5Bv3SbStMMm2PTGOFLIV5 Z8aEtMKSSTJBL7vsZtm+NEr95x1km1scCES7qJZLvm21oVtumx2HBFscDYh+ksWlkubbcOWfCmNM Wm857zOCMCd99EMlT+7TZTjrIobJqJZlvRjW+Z8qo8ac2hd8h3zbGmV+6zZLl1WDNopghEUt65tN peB0fj/AKRLnRBJnv0rRkh6IZn0hi2TkjYxGvFoRJH2HKp/Q82204m2LZJXpOajJtoi2dHycHxpP n+R1EaoXfoen66cekM2ilsxNpb1b6JqPj+N/wA64U/omIJ0ZIfo/NvjQl5MEUt6sGLRSZIRlU6cE kIz33Nsac6U16OxbJ9kvFooS86swLa2IJY/jk+VbeqEbWjHfM2zpipacekMk/kZkyl4WiFbOTFsk 1Cppb8apZCM2z31bn205JV8mPR2TJ8U6vCNzJNT0Ytg2MjkyRRHm+dEK0SyX3zBiSHqxeKrZ/do6 fJkj8L/AJMEZM6NrciWRkkwfdL+NGL4MGO/fQhmOBuYtn0ZkyfSlGDBm8EE1GJNz62+ss+VTqehk shWwZ79sKDHAwY/d8dRkmpLxaKdGLS2yKDB9bNUxplzfNpf6BK0P98x02LZJrbHJFGnGDA5gwcjJ hWzaVNsXhfoOOBkgx+7Y6bFsmTJhasImps2MmSXbNo/GzBi2Db9J+37yupwcldfGLSyDJ9kfZol6 cJWxbMEvv2TNs3zaHfPpDJgihsyYtFskJmT7E1CVE6ZatgyS2Z79m+b5tm+TFs/usLpFbNvs0SiK IZkbtCS3t9hfG32PsfQnRNRkwZ787ZvLvm2dGLZ/dZ6bJDG6nVsRkbpbJcmJEiYJcH2hiiDc+xkf +FO5jRkyY79kQtGXfOrFs+i8ksz8XykpjwSnDxaMRaeZHImoUEUkCdWSPxQYM2xbJFlH6FDvngZ9 FYMn2R9hwYtknCHTHhkPAquYk4IRg+6PpZO30nRgj9AzbN5erFs+isGSKkZMI+NTRBNpSRmbfcy0 fJQL5Jboz8RbDG2R+OL5/T8+i82mohH3tkyNNIx/qYF/iM+zH8mkfaT7uyE2TSkrRkyYM9Ix9txw M+hGrPmuBm0MlH3tkyL5U/0b/yQhVVSfY3H8iam7ZMDhK2DJgz+hQvRKeDN5KlyFVz5jpcPgwiXe T7J2wfYzbI1VBCMkoiDBgXywSZ/QsacehIPkv5J0Tg2ZF4dsWzbE38mVb6i+cEVQfFwyT7WyP4nI wYMmD7foOdOfQ86U9WLyyFm8MVUQfUwf+RyfYXyJUn2tk+v+g8GCKWZt9ulXbM6c+hsmOH9brcTp PjgkTYqUqkYmz+U2cyP4n2MGTBgwc7/AG6B9xx6Qi2NcEDZm0M+X4z6p/xaWjBLUkKDe1LItjR9/ wBBx6ShcD5MhYNxsayTQ0TSv9RSZRNEmVb66MmBZX8iMn21MehjGMYxj7TnTL9JeRPgZs20iESQ4 ZC/pkVDiljdMH2MH1ZkyZMswRUyTJnUx+x8E8SHb7u+UzLRFQ3R/QnS5IqtgzaGZdvs7ZOT9mwN8 WGYPtN0zMm4vg7RUYt9tP2vj9Ix6FniZvi0IbMCduaEnDN0YMGZWnN5Xf8APpqKtErTkhk1MhQbE rQzOnPY49a5T0Y05khj+UobWSKmrYenF8Ge/Y9NTTOiVGnAoRiTEEV3h2zbGiKuyx6zlNaMmbubT +NxklWhpm5KHM8CbMfYpH6ygzolaaqVC3FFpRNImiabTfN5wZsrMYyoqGMY+rfrWVoglZMWcSbW2 RDvjhKyKSnwUsRBF2Mdo6SRrn6030tWaTS3IrJGhTPAlcRErQhGejiyZI16zlanNmsmJJRKM2yZ0 Q4ehWSEU7spe5SubKfIp5iEUwInp45idoyvWOdWxtal0pI2vDvidLWGIpETqYxjGMfUOliqUq269 ZStEZFh3iq+b40zyGMYx8KWfGnqXS5QqlKtuvW2Gh2wShk6IelCELhvq3S5QmrR6snTOjJDMkohx fN5RnosdZDJVo9ayQbEP5IlTqzozxcmOt2J9WytMrTKsmjnTfN99KeBCfCyY7BD9YzpjK3tD+SN9 rTfYh6Gs3nStGTBnrYZK4kepp0yrSjHxZteSDfoIZgz2DPrGURoVocm6vDNycEdDy67JK9ZpkXxe MEX2tKnTvw8n06+VxJI9QzqnXKNtOdMPhZPkoI67HFn1FKItF5I0xklSYtFpUMjj4Iq/v2nJOppw Q9O+rbgwz5USua67PEj1VJOrA0xmLx/WmeFKyfGprrc+uE9O1n5tGbwyb7ong5PlTK5o36zPrlPT syDdaYJvuuDsYghyuT6yPXUkkEjRBKIwzxfe06NnrgzIqlHkdLh+0Y1J6IYmJE87+LTeSOYmRp2I FXTH/A04fPvEemExrUmTaRqyahjpYrbkE6GuQnrUQL8i/kacPvU6sdxn9fT4CZJI1aaZE+ZA+VoJ 0+RMejc2KfyKVhjpcVc+tXW49LyNa5EyDEDTgbQ0SSQJ89EjVkxq6GnGxR+WmGVUPP/AD3nGvx2/ P7FPCT5E5RBJF4ybMnloTGuZPIW6N0NWaclNahkOaM/wRz7vjQiLp8zwNc+hfV5/ZE+Enlcxo8id /A0J6VsNFD5YHtkW9oHOSn8n9lVPeYvF8CfMpfIq2GufTL95Ts1rXJm6GjyJ38D3E9MjXIe4jxZo kpqI5ZI568wNdnnTNpRGtpnkpeGj8VRQ+TIGVFQxjGMYxjshfv6dmh6WhPndoTFaCOYnaBCuhlSI HvZMpdmipc0O0MTQnyGuyN2S178JoqQ5GPcdqSkQhC9DITs0ND0qzQyRWdkzwMelqyupFHMTELQm eCrYa6JjutbHuJHjg44C7Cv3dCuxjGO7Q7Jni64bKmeeBSykoZQJnhj8jKisqKh2QhIXBdlxca2M Yx9TAv35CFrTs0VorQ91rQhXSHGqLKyF0LexUVMZ5EIXQMY7IQhCEIQhC0U+CjwUL+T8ZQfjKCkQ hC9HoWhCPGh2bElpjUxoaPIhCFZCFduyfMpRT4EhW2vjjsYhL1gtcnnoWND0ITshCFdCEIQuK7L1 tjQ2Rojo2MYxlRUVFTGNjHx16ajsE2m+bYFpYxj7E335C9ATdbiR4G9xCELUhCF10evmyOxx6phd hbFZjHZIXTsehCXqrHXN2S1NjH06EIU+rpI6t2S/dl+vR1S1LsjZ5EvVMaG+RX4K1sVrmmNcN2Vn 0j6eTyJa2ypkeo50QYJsoyUt8kUPmj8Xg/F4Pxn41sULkhFEctKXDfXzZIWhjPJShep0zwNDEQY0 O06V22PWqFdjKioqKhjshCXuTHDhEv/ADv594Z7PJSt/ZGe0P1orKy1oQhC9toXbn1L9SLrGuAxj 1N7H5HyRW+cFXkjmxIS5e5UIXgQuyxaf82jHpXvBjH7inmU/wCwjNvJQtinwU+CPR+f1uf9ntjH3 R8FjGMfq5PKEUspFdjKh3jtKEIQhCKFsU+CnwL/ALA+PeGP+lh//8QAIREAAgEEAwEBAQEAAAAAA AAAAREAAhASIDBAUGATA5D/2gAIAQIBAQIA/wAhW227Nttttv7l/VrFJJJKJL6BbAJvZJKNuEfPG OwCd3HF9QYTAE4rJRJRxfRkmwFlCQFDyJfOkxAaEgONR8iWoPywF24Bu+ZXB+SNgNCYBcnqEXB+S AJuSABzCI7HgB+QWhNh0G9Twg/HiE2JiPZOwhEHxwBsAbiwsB1iNRCB8kYwLgdcwjQXHxRsIbmGA WEER7B1Fx8WrAEmwFwCeI8xsLEe8OYykAG5hlIqNqQSe2IIbCED2xHyGCCGAG4FxCeF3PKbCxghH xJguIbAf0NgD3DYQQwWPxBgtVr/ADFREQBHbNqYjYQwfDmUAQwQ2MphggBlR7oghhghg9sQ8ZlMF hDDYkwSkGHvCCGGCwh9kQQ8ZggsLEw2QB74gjMFhD7IjPGYNBcwRyo7Nt9JWEEMEMEMHsiHkANjB BKYYIISTsdB0nYQCoQwSr4YSixhvQDBBDskkrtvoCCVQQwSr30rGwlMoAsYkLCUw6JatttvmAEEJ hIJPvJLX+UMVUdMqsJSId0accUlcchsLm7fvAkJEWMoJglcVJrhggjJcFkArnZviNhcwQwwe+RTF KtKZUAarGVQiGMmCC7yNWWT0bBB4TYQQ2FjB7osgISdKYQKapTKtDEbC2WWWTbbb1b4EIIdDB74g hqhN6JVaoUyqCIQ2q6w2OghuYYPeEBNTuiBGzADKYobVDmSSsI22xCRBc3PwL2NgWCbUQw3qLbb4 BojG23DASRBBDoYPfPFSTASXSatKwklugFoYrjUQbGD4gwE2cEYuYKTT+f5fif5GhY4gALZEbjY/ FGwujAdBYwVAkkxk8LEqpsLiDcfEkGUnQQ2NjMjW226RyA1UgQ2A1Nh8URBY2B3IISg3ezIJg2Nh 8WbUk6A6CGkhQ9IwbkwfApRLaoQGx3dyEecEgcA+NMIB0HGuwN222222222/VIMB0HApTCOsNW22 7vhbB9UiA3HEJWOYbMbPqN+qaYC7PRaAkczb1bfYBbb7i6hpgIKIBsNgTD6jbyfaPVNJEFTNIOyg JpXrMHmHgkGmAiqEA7IEwjtnRdYc48M0mmNxtXceWJHWdjdirJYGlcD4AW2/PNJoNKbbbs08rgrF cKu4o4IbuAvLLJt/BJGnDHHFR2MUSgIqBWOH5/n+eBFjEtzFjjikksUkkl7beWWWWWWTdjHANWKh /Qf0H9M3FCSWS7pLrL03kam29m8jAAEeJ5ZZMnVAfGGp84EZqeyWiR1XxtXQAXAlEQscUSTovjjD yrV8RsyYrj5EhU04GjD8zSkl0SW4o4gPDSXIfAWKMbiR6ZNm22x6Z8gnpE6pKDrjsDQ+RV0W4BZb A9I3HYGh8Btt3q523EIlZLxElxDQ9p5ZZZNttsFvfHHFWb4RCEou8kugeuzUS+OmGGzBBsLkGEk8 bb7qSS3HGesS+YR2NhBGDlkSfLS7B6pPTFkklGT7I5D1DD1RAErGPoAClVDxhuIND1DCNhygLUjo 0wWqh8YbjU9UhLHDDEU44pLRJLYxJY44fnhitxBGYRZdBJc4h2Gp6yWgsYbJJJcSVxc6pJaJKyWJ C3SS6Ih2Gp7gMJ7JskklwrQxJJJJLpiHYd999dhLsHYeC38qND4g6j9saHxB8T/AP/EACYRAAEEA gECBgMAAAAAAAAAAAEAESFgECAwQVACEjFwgJAiQFH/2gAIAQIBAz8A+4AaBC+HIsrcBtzXdvTV0 Bd+g+Ms4e7Mn+lNhd3Km4Rv5Q+jW6NXK6XGdpdOcx2OKVOJ1bwvcY3/AB7JFNjSVOJU4jsUU2NZx KlRbp0i6TgJ1GIJ7NFLlOmx/FCcphc5zClRiLg2JUlQmKjEXCU2PV0/hUqLlKjP4qVGs22dIbEZj E3B0yhRmMTbIvsbxcYxOwQQyUbRG07hBBBBDkeylFH9J7MNY5ntUc71ydztFpfni1PzteOtrflhk 1xbfzC2vxsvNItwPEyHiTXjov52AdUCj0RRpB5gh2Ao0kIalHc6hBeFeFBBBBC0ngKOoQQs78xRR R9zI98wfVBN1wbqUUUcFH50FH55BD3GKKKKP7B9jx9O/wD/xAAbEQEAAgMBAQAAAAAAAAAAAAARA WCAkKBAsP/aAAgBAwEBAgD5tcXed3M5wTnaqvSE0hvS+NVbiAaPFV5N1dgf/8QAFBEBAAAAAAAAA AAAAAAAAAAA0P/aAAgBAwEDPwAWA//Z ================================================ FILE: includes/donation_banner.php ================================================ posts.'` p WHERE p.`post_status` IN (\'publish\', \'private\') AND p.post_type = "podcast"'; return $wpdb->get_var($sql); } ================================================ FILE: includes/downloads.php ================================================ episode(); $title = $episode->title(); // see https://developers.google.com/analytics/devguides/collection/protocol/v1/parameters $ga_params = [ // Basics 'v' => '1', // version 'tid' => $ga_tracking_id, // tracking id 'cid' => $request_id, // client id 'ua' => $ua_string, // user agent override 'uip' => podlove_get_remote_addr(), // IP override 'ds' => 'podlove', // data source // We highjack the campaign fields for context/source data. // Source / Medium maps to Podlove context / Podlove source. // This way all Podlove sources can be easily grouped into GA Channels. 'cs' => $ptm_context, // campaign source 'cm' => $ptm_source, // campaign medium 'ci' => $episode->number, // campaign id 'cn' => $title, // campaign name // Pageview params 't' => 'pageview', // hit type 'dh' => $_SERVER['HTTP_HOST'], // document host 'dp' => $_SERVER['REQUEST_URI'], // document path 'dt' => $title, // document title ]; $ga_params = apply_filters('podlove_ga_track_params', $ga_params, $episode); $ga_param_fragments = []; array_walk($ga_params, function ($item, $key) use (&$ga_param_fragments) { array_push($ga_param_fragments, sprintf('%s=%s', $key, rawurlencode($item))); }); $body = implode('&', $ga_param_fragments); $curl = new \Podlove\Http\Curl(); $curl->request($ga_collect_endpoint, [ 'method' => 'POST', 'body' => $body, ]); if (!$curl->isSuccessful()) { if ($debug_ga) { header('x-ga-debug: http error'); } \Podlove\Log::get()->addDebug('GA Measurement Protocol request failed.'); } else { \Podlove\Log::get()->addDebug('GA Measurement Protocol request successful: '.$body); if (!$debug_ga) { return; } $response = json_decode($curl->get_response()['body'], true); $hit_paring_result = $response['hitParsingResult'][0]; if ($hit_paring_result['valid']) { header('x-ga-debug: valid'); \Podlove\Log::get()->addDebug('GA Measurement Protocol hit valid.'); } else { $debug_message = sprintf('%s(%s): %s', $hit_paring_result['parserMessage'][0]['messageType'], $response['hitParsingResult'][0]['parserMessage'][0]['parameter'], $response['hitParsingResult'][0]['parserMessage'][0]['description']); header(sprintf('x-ga-debug: '.$debug_message)); \Podlove\Log::get()->addDebug('GA Measurement Protocol hit invalid.', $hit_paring_result['parserMessage'][0]); } } } function matomo_track_download($request_id, $media_file, $ua_string, $ptm_context, $ptm_source) { // Matomo Tracking $matomo_url = trim(\Podlove\get_setting('tracking', 'matomo_url') ?? ''); $matomo_site_id = trim(\Podlove\get_setting('tracking', 'matomo_site_id') ?? ''); $matomo_token = trim(\Podlove\get_setting('tracking', 'matomo_token') ?? ''); if (!$matomo_url || !$matomo_site_id) { return; } $episode = $media_file->episode(); $title = $episode->title(); // see https://developer.matomo.org/api-reference/tracking-api $matomo_params = [ 'idsite' => $matomo_site_id, 'rec' => '1', 'apiv' => '1', 'action_name' => $title, 'download' => $media_file->get_file_url(), 'url' => $media_file->get_file_url(), 'ua' => $ua_string, 'cip' => podlove_get_remote_addr(), 'send_image' => '0', ]; if ($matomo_token) { $matomo_params['token_auth'] = $matomo_token; } if ($ptm_context) { $matomo_params['mtm_campaign'] = $ptm_context; } if ($ptm_source) { $matomo_params['mtm_kwd'] = $ptm_source; } $matomo_params = apply_filters('podlove_matomo_track_params', $matomo_params, $episode); $matomo_param_fragments = []; array_walk($matomo_params, function ($item, $key) use (&$matomo_param_fragments) { array_push($matomo_param_fragments, sprintf('%s=%s', $key, rawurlencode($item))); }); $body = implode('&', $matomo_param_fragments); $curl = new \Podlove\Http\Curl(); $curl->request($matomo_url, [ 'method' => 'POST', 'body' => $body, ]); if (!$curl->isSuccessful()) { \Podlove\Log::get()->addDebug('Matomo Tracking request failed.'); } else { // Strip token_auth from URL encoded body for logging $log_body = $body; if ($matomo_token) { $log_body = str_replace('token_auth='.rawurlencode($matomo_token), 'token_auth=[SECRET]', $body); } \Podlove\Log::get()->addDebug('Matomo Tracking request successful: '.$log_body); } } function podlove_handle_media_file_tracking(Podlove\Model\MediaFile $media_file) { if (\Podlove\get_setting('tracking', 'mode') !== 'ptm_analytics') { return; } if (strtoupper($_SERVER['REQUEST_METHOD']) === 'HEAD') { return; } $intent = new Model\DownloadIntent(); $intent->media_file_id = $media_file->id; $intent->accessed_at = date('Y-m-d H:i:s'); $ptm_source = trim(podlove_get_query_var('ptm_source')); $ptm_context = trim(podlove_get_query_var('ptm_context')); if ($ptm_source) { $intent->source = $ptm_source; } if ($ptm_context) { $intent->context = $ptm_context; } // set user agent $ua_string = isset($_SERVER['HTTP_USER_AGENT']) ? trim($_SERVER['HTTP_USER_AGENT']) : ''; if ($agent = Model\UserAgent::find_or_create_by_uastring($ua_string)) { $intent->user_agent_id = $agent->id; } // save HTTP range header // @see http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.35 for spec if (isset($_SERVER['HTTP_RANGE'])) { $intent->httprange = $_SERVER['HTTP_RANGE']; } $ip_string = podlove_get_remote_addr(); if (function_exists('openssl_digest')) { $intent->request_id = openssl_digest($ip_string.$ua_string, 'sha256'); } else { $intent->request_id = sha1($ip_string.$ua_string); } if (Geo_Ip::is_enabled()) { $intent = $intent->add_geo_data($ip_string); } $intent->save(); ga_track_download($intent->request_id, $media_file, $ua_string, $ptm_context, $ptm_source); matomo_track_download($intent->request_id, $media_file, $ua_string, $ptm_context, $ptm_source); } function podlove_handle_media_file_download() { $download_media_file = podlove_get_query_var('download_media_file'); if (!$download_media_file) { return; } // tell WP Super Cache to not cache download links if (!defined('DONOTCACHEPAGE')) { define('DONOTCACHEPAGE', true); } // use this hook to short-circuit the download logic if (apply_filters('podlove_pre_media_file_download', false, $download_media_file)) { exit; } $media_file_id = (int) $download_media_file; $media_file = Model\MediaFile::find_by_id($media_file_id); if (!$media_file) { status_header(404, 'Media File not found'); exit; } $episode_asset = $media_file->episode_asset(); if (!$episode_asset) { status_header(404, 'Asset not found'); exit; } // if a file exists but no valid episode reference, // that means it has been removed $episode = $media_file->episode(); if (!$episode || !$episode->is_valid()) { status_header(410, 'Gone'); exit; } do_action('podlove_download_file', $media_file); // build redirect url $location = $media_file->add_ptm_parameters( $media_file->get_file_url(), [ 'source' => trim(podlove_get_query_var('ptm_source')), 'context' => trim(podlove_get_query_var('ptm_context')), 'request' => substr(md5(uniqid(microtime().wp_rand(), true)), 0, 12), ] ); header('HTTP/1.1 301 Moved Permanently'); header('Location: '.$location); exit; } // add route for file downloads add_action('init', function () { add_rewrite_rule( '^podlove/file/([0-9]+)/s/([^/]+)/c/([^/]+)/.+/?$', 'index.php?download_media_file=$matches[1]&ptm_source=$matches[2]&ptm_context=$matches[3]', 'top' ); add_rewrite_rule( '^podlove/file/([0-9]+)/s/([^/]+)/.+/?$', 'index.php?download_media_file=$matches[1]&ptm_source=$matches[2]', 'top' ); add_rewrite_rule( '^podlove/file/([0-9]+)/.+/?$', 'index.php?download_media_file=$matches[1]', 'top' ); }, 10); add_filter('query_vars', function ($query_vars) { $query_vars[] = 'download_media_file'; $query_vars[] = 'ptm_source'; $query_vars[] = 'ptm_context'; return $query_vars; }, 10, 1); // don't add trailing slash to file URLs add_filter('redirect_canonical', function ($redirect_url, $requested_url) { if ((int) get_query_var('download_media_file')) { return false; } return $redirect_url; }, 10, 2); ================================================ FILE: includes/episode_number_column.php ================================================ __('Ep. #', 'podlove-podcasting-plugin-for-wordpress')] + array_slice($columns, $insertIndex, count($columns) - 1, true); } function podlove_add_episodeno_column_content_to_episodes_table($column_name) { if ($column_name === 'episode_number') { // check for null to prevent fatal error if (\Podlove\get_episode() != null) { echo \Podlove\get_episode()->number(); } } } ================================================ FILE: includes/episode_number_quick_edit_form.php ================================================
number = sanitize_text_field($_POST['_podlove_meta']['number']); if (is_object($episode) && is_string($episode->number)) { $episode->save(); } } } function podlove_episodeno_quickedit_populate_form() { global $current_screen; if ($current_screen->post_type !== 'podcast') { return; } ?> post_type !== 'podcast') { return $actions; } $episode = \Podlove\Model\Episode::find_or_create_by_post_id($post->ID); // Not nice but seems like there is no more elegant way to do this right now if (isset($actions['inline hide-if-no-js'])) { $actions['inline hide-if-no-js'] = str_replace(' 'select', 'key' => 'explicit', 'options' => [ 'label' => __('Explicit Content?', 'podlove-podcasting-plugin-for-wordpress'), 'type' => 'checkbox', 'html' => ['style' => 'width: 200px;'], 'default' => '-1', 'options' => [0 => 'false', 1 => 'true'], ], 'position' => 770, ]; return $form_data; }); ================================================ FILE: includes/extras.php ================================================ addInfo('Copying cover art from asset to manual'); $episodes = \Podlove\Model\Episode::find_all_by_time(); foreach ($episodes as $episode) { if ($cover_art = $episode->cover_art()) { $url = $cover_art->source_url(); \Podlove\Log::get()->addInfo('Copying cover art '.$url.' from asset to manual for episode '.$episode->id); $episode->update_attribute('cover_art', $url); } } return $new; }, 10, 2); ================================================ FILE: includes/feed_discovery.php ================================================ cache_for($cache_key, function () { $feeds = Model\Podcast::get()->feeds(); // only discoverable feeds $feeds = array_filter($feeds, function ($feed) { return $feed->discoverable; }); $links = array_map(function ($feed) { return '\n"; }, $feeds); return "\n".implode('', $links); }); } add_action('init', function () { // priority 2 so they are placed below the WordPress default discovery links add_action('wp_head', 'podlove_add_feed_discoverability', 2); // hide WordPress default link discovery if (\Podlove\get_setting('website', 'hide_wp_feed_discovery') === 'on') { remove_action('wp_head', 'feed_links', 2); remove_action('wp_head', 'feed_links_extra', 3); } }); ================================================ FILE: includes/frontend_styles.php ================================================ = 200 && $status < 300 || $status == 304; } ================================================ FILE: includes/images.php ================================================ validate(); if ($validator->hasChanged()) { wp_schedule_single_event(time(), 'podlove_refetch_cached_image', [$cache['source'], $cache['filename']]); } } $stop_time = hrtime(true); $duration = ($stop_time - $start_time) / 1e+6; $duration_string = round($duration).'ms'; \Podlove\Log::get()->addInfo(sprintf('Finished validating %d images in %s', count($cache_files), $duration_string)); } function podlove_refetch_cached_image($url, $filename) { (new Image($url, $filename))->redownload_source(); } // add routes add_action('init', function () { add_rewrite_rule( '^podlove/image/([^/]+)/([0-9]+)/([0-9]+)/([0-9])/([^/]+)/?$', '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]', 'top' ); }, 10); add_filter('query_vars', function ($query_vars) { $query_vars[] = 'podlove_image_cache_url'; $query_vars[] = 'podlove_width'; $query_vars[] = 'podlove_height'; $query_vars[] = 'podlove_crop'; $query_vars[] = 'podlove_file_name'; return $query_vars; }, 10, 1); add_action('wp', 'podlove_handle_cache_files'); function podlove_handle_cache_files() { $source_url = \Podlove\PHP\hex2str(podlove_get_query_var('podlove_image_cache_url')); $file_name = urldecode(podlove_get_query_var('podlove_file_name')); $width = (int) podlove_get_query_var('podlove_width'); $height = (int) podlove_get_query_var('podlove_height'); $crop = (bool) podlove_get_query_var('podlove_crop'); if (!$source_url) { return; } // Tell WP Super Cache to not cache download links if (!defined('DONOTCACHEPAGE')) { define('DONOTCACHEPAGE', true); } $image = new Image($source_url, $file_name); if (!$image->source_exists()) { $image->download_source(); } // Bail if download fails if (!$image->source_exists()) { Log::get()->error('Download failed for image: '.$image->url()); status_header(307); header('Location: '.$source_url); exit; } $imageinfo = getimagesize($image->original_file()); // Bail if we cannot determine image meta if ($imageinfo === false) { Log::get()->error('Image size cannot be determined for file: '.$image->original_file()); status_header(307); header('Location: '.$source_url); exit; } list($orig_width, $orig_height) = $imageinfo; // Do not try to enlarge images if ($width > $orig_width) { $width = $orig_width; } if ($height > $orig_height) { $height = $orig_height; } $image ->setWidth($width) ->setHeight($height) ->setCrop($crop) ; if (!file_exists($image->resized_file())) { $image->generate_resized_copy(); } $file = $image->resized_file(); // Bail if resize fails if (!file_exists($file)) { Log::get()->error('Image resize failed for file: '.$file); status_header(307); header('Location: '.$source_url); exit; } $imageInfo = getimagesize($file); switch ($imageInfo[2]) { case IMAGETYPE_JPEG: header('Content-Type: image/jpeg'); break; case IMAGETYPE_GIF: header('Content-Type: image/gif'); break; case IMAGETYPE_PNG: header('Content-Type: image/png'); break; default: Log::get()->error('Unsupported image type for file: '.$file); return; } header('Content-Length: '.filesize($file)); header('Cache-Control: public, max-age=86400'); header('Expires: '.gmdate('D, d M Y H:i:s \G\M\T', time() + 86400)); $time = filemtime($file); $etag = md5($time.$source_url); $last_modified = gmdate('D, d M Y H:i:s \G\M\T', $time); $if_modified_since = isset($_SERVER['HTTP_IF_MODIFIED_SINCE']) ?? false; $if_none_match = isset($_SERVER['HTTP_IF_NONE_MATCH']) ?? false; if ((($if_none_match && $if_none_match == $etag) || (!$if_none_match)) && ($if_modified_since && $if_modified_since == $last_modified)) { header('HTTP/1.1 304 Not Modified'); } else { header("Last-Modified: {$last_modified}"); header("ETag: {$etag}"); readfile($file); } exit; } ================================================ FILE: includes/import.php ================================================ 1) { foreach ($metas as $meta) { if ($meta !== $_meta_value) { delete_post_meta($post_id, $meta_key, $meta); } } } }, 10, 4); // Ensure WordPress importer keeps the mapping id for old<->new post id. // This is required for the Im/Export module. To avoid user errors, it is // better to keep this behaviour in core. add_filter('wp_import_post_meta', function ($postmetas, $post_id, $post) { $postmetas[] = [ 'key' => 'import_id', 'value' => $post_id, ]; return $postmetas; }, 10, 3); ================================================ FILE: includes/jetpack.php ================================================ get_license(); $form_data[] = [ 'type' => 'callback', 'key' => 'podlove_cc_license_selector', 'options' => [ 'label' => '', 'callback' => function () { ?>
522, ]; return $form_data; } ================================================ FILE: includes/merge_episodes.php ================================================ is_main_query() && !isset($wp_query->query_vars['post_type'])) { $wp_query->set( 'post_type', array_merge(['post', 'podcast'], (array) $wp_query->get('post_type')) ); } }); // Checking "merge_episodes" also includes episodes in main feed add_filter('request', function ($query_var) { if (!isset($query_var['feed'])) { return $query_var; } if (\Podlove\get_setting('website', 'merge_episodes') !== 'on') { return $query_var; } $extend = [ 'post' => 'post', 'podcast' => 'podcast', ]; if (empty($query_var['post_type']) || !is_array($query_var['post_type'])) { $query_var['post_type'] = $extend; } else { $query_var['post_type'] = array_merge($query_var['post_type'], $extend); } return $query_var; }); ================================================ FILE: includes/modules.php ================================================ load(); } else { Modules\Base::deactivate($module_name); add_action('admin_notices', function () use ($module_name) { ?>

addInfo('Deactivate module "'.$deactivated_module.'"'); do_action('podlove_module_was_deactivated', $deactivated_module); do_action('podlove_module_was_deactivated_'.$deactivated_module); } } if ($activated_modules) { foreach ($activated_modules as $activated_module) { Log::get()->addInfo('Activate module "'.$activated_module.'"'); // init module before firing hooks $class = Modules\Base::get_class_by_module_name($activated_module); if (class_exists($class)) { $class::instance()->load(); } do_action('podlove_module_was_activated', $activated_module); do_action('podlove_module_was_activated_'.$activated_module); } } }, 10, 2); ================================================ FILE: includes/no_enclosure_autodiscovery.php ================================================ postmeta} WHERE post_id = '{$post_id}' AND meta_key = '_encloseme' AND meta_id = '{$meta_id}' "; $wpdb->query($sql); } add_action('added_post_meta', 'podlove_no_enclosure_autodiscovery', 10, 4); // legacy support add_action('added_postmeta', 'podlove_no_enclosure_autodiscovery', 10, 4); ================================================ FILE: includes/permalinks.php ================================================ add_rewrite_tag('%podcast%', '([^/]+)', 'post_type=podcast&name='); // Use same permastruct as post_type 'post' if (podlove_and_wordpress_permastructs_are_equal()) { $permastruct = str_replace('%postname%', '%podcast%', get_option('permalink_structure')); } // Enable generic rules for pages if permalink structure doesn't begin with a wildcard if ('/%podcast%' == untrailingslashit($permastruct)) { // Generate custom rewrite rules $wp_rewrite->matches = 'matches'; $wp_rewrite->extra_rules = array_merge( $wp_rewrite->extra_rules, $wp_rewrite->generate_rewrite_rules('%podcast%', EP_PERMALINK, true, true, false, true, true) ); $wp_rewrite->matches = ''; // Add for WP_Query $wp_rewrite->use_verbose_page_rules = true; } // Add archive pages // // set the constant `define('PODLOVE_ARCHIVE_PAGES', true)` to enable this. // I removed this once because it broke stuff, see https://github.com/podlove/podlove-publisher/commit/b4d9f148ecb5fc82520a775cc38a77ec505aeb3a#diff-0533ec9c53ef1127dfc1a79fa5c24199 // However it's a feature still in use, so I at least want to give the choice to enable it via constant // see https: //github.com/podlove/podlove-publisher/issues/978 if ('on' == \Podlove\get_setting('website', 'episode_archive') && defined('PODLOVE_ARCHIVE_PAGES') && PODLOVE_ARCHIVE_PAGES) { $archive_slug = trim(\Podlove\get_setting('website', 'episode_archive_slug'), '/'); $blog_prefix = \Podlove\get_blog_prefix(); $blog_prefix = $blog_prefix ? trim($blog_prefix, '/').'/' : ''; $wp_rewrite->add_rule("{$blog_prefix}{$archive_slug}/?$", 'index.php?post_type=podcast', 'top'); $wp_rewrite->add_rule("{$blog_prefix}{$archive_slug}/{$wp_rewrite->pagination_base}/([0-9]{1,})/?$", 'index.php?post_type=podcast&paged=$matches[1]', 'top'); } } /** * Add podcast episode rules to post rules. * * Add to post rewrite rules our rules for a podcast episode to respect correct * rule order. Needed to not interfere with other rules (like feeds). * * @since 1.10.17 * * @param array $post_rewrite the rewrite rules for posts * * @return array an associate array of matches and queries */ function podlove_add_podcast_episode_rules_to_post_rules($post_rewrite) { global $wp_rewrite; // Get permalink structure $permastruct = \Podlove\get_setting('website', 'custom_episode_slug'); // Use same permastruct as post_type 'post' if (podlove_and_wordpress_permastructs_are_equal()) { $permastruct = str_replace('%postname%', '%podcast%', get_option('permalink_structure')); } // Don't add rules here, if use the other method // @see \Podlove\add_podcast_rewrite_rules if ('/%podcast%' == untrailingslashit($permastruct)) { return $post_rewrite; } // Generate rules for podcast episode and merge them with post rules $post_rewrite = array_merge($wp_rewrite->generate_rewrite_rules($permastruct, EP_PERMALINK, true, true, false, true, true), $post_rewrite); return $post_rewrite; } /** * Filters the request query vars to search for posts with type 'post' and 'podcast'. * * @param mixed $query_vars */ function podlove_podcast_permalink_proxy($query_vars) { global $wpdb; // Previews default to post type "post" which is unfortunate. // However, when there is a name, we can determine the post_type anyway. // I don't think this is 100% bulletproof but seems to work well enough. if (isset($query_vars['preview']) && !isset($query_vars['post_type']) && isset($query_vars['name'])) { $query_vars['post_type'] = $wpdb->get_var( $wpdb->prepare('SELECT post_type FROM '.$wpdb->posts.' WHERE post_name = %s', $query_vars['name']) ); } // No post request if (isset($query_vars['preview']) || false === (isset($query_vars['name']) || isset($query_vars['p']))) { return $query_vars; } if (!isset($query_vars['post_type']) || $query_vars['post_type'] == 'post') { $query_vars['post_type'] = ['podcast', 'post']; } return $query_vars; } /** * Disable verbose page rules mode after startup. * * @uses $wp_rewrite */ function podlove_no_verbose_page_rules() { global $wp_rewrite; $wp_rewrite->use_verbose_page_rules = false; } /** * Replace placeholders in permalinks with the correct values. * * @param mixed $post_link * @param mixed $id * @param mixed $leavename * @param mixed $sample */ function podlove_generate_custom_post_link($post_link, $id, $leavename = false, $sample = false) { // Get post $post = get_post($id); // Generate urls only for podcast episodes if ('podcast' != $post->post_type) { return $post_link; } // Draft or pending? $draft_or_pending = isset($post->post_status) && in_array($post->post_status, ['draft', 'pending', 'auto-draft']); // Sample if ($sample && true == $leavename) { $post->post_name = '%pagename%'; } // Get permastruct $permastruct = \Podlove\get_setting('website', 'custom_episode_slug'); if (podlove_and_wordpress_permastructs_are_equal()) { $permastruct = str_replace('%postname%', '%podcast%', get_option('permalink_structure')); } // Only post_name in URL if ('/%podcast%' == untrailingslashit($permastruct) && (!$draft_or_pending || $sample)) { return home_url(user_trailingslashit($post->post_name)); } // Generate post link if (!$draft_or_pending || $sample) { $post_link = home_url(user_trailingslashit($permastruct)); } // Replace simple placeholders $unixtime = strtotime($post->post_date); $post_link = str_replace('%year%', date('Y', $unixtime), $post_link); $post_link = str_replace('%monthnum%', date('m', $unixtime), $post_link); $post_link = str_replace('%day%', date('d', $unixtime), $post_link); $post_link = str_replace('%hour%', date('H', $unixtime), $post_link); $post_link = str_replace('%minute%', date('i', $unixtime), $post_link); $post_link = str_replace('%second%', date('s', $unixtime), $post_link); $post_link = str_replace('%post_id%', $post->ID, $post_link); $post_link = str_replace('%podcast%', $post->post_name, $post_link); // category and author replacement copied from WordPress core if (false !== strpos($permastruct, '%category%')) { $cats = get_the_category($post->ID); if ($cats) { if (function_exists('wp_list_sort')) { $cats = wp_list_sort($cats, 'term_id', 'ASC'); } else { usort($cats, '_usort_terms_by_ID'); } $category_object = apply_filters('post_link_category', $cats[0], $cats, $post); $category_object = get_term($category_object, 'category'); $category = $category_object->slug; if ($parent = $category_object->parent) { $category = get_category_parents($parent, false, '/', true).$category; } } if (empty($category)) { $default_category = get_category(get_option('default_category')); $category = is_wp_error($default_category) ? '' : $default_category->slug; } $post_link = str_replace('%category%', $category, $post_link); } if (false !== strpos($permastruct, '%author%')) { $authordata = get_userdata($post->post_author); $post_link = str_replace('%author%', $authordata->user_nicename, $post_link); } return $post_link; } ================================================ FILE: includes/podlove-web-player-5.php ================================================ post_id); $podcast = Podcast::get(); $chapters = array_map(function ($c) { $c->title = html_entity_decode(trim($c->title)); return $c; }, (array) json_decode($episode->get_chapters('json'))); $config = [ 'version' => 5, 'show' => [ 'title' => $podcast->title ?? '', 'subtitle' => $podcast->subtitle ?? '', 'summary' => $podcast->summary ?? '', 'poster' => $podcast->cover_art()->setWidth(500)->url() ?? '', 'link' => \Podlove\get_landing_page_url() ?? '', ], 'title' => $post->post_title ?? '', 'subtitle' => trim($episode->subtitle ?? ''), 'summary' => trim($episode->summary ?? ''), 'publicationDate' => mysql2date('c', $post->post_date), 'duration' => $episode->get_duration('full'), 'poster' => $episode->cover_art_with_fallback()->setWidth(500)->url(), 'link' => get_permalink($episode->post_id), 'chapters' => $chapters ? $chapters : [], 'audio' => podlove_pwp5_audio_files($episode, null), 'files' => podlove_pwp5_files($episode, null), ]; if (\Podlove\Modules\Base::is_active('contributors')) { $config['contributors'] = array_values(array_filter(array_map(function ($c) { $contributor = $c->getContributor(); if (!$contributor || !$contributor->visibility) { return []; } return [ 'id' => $contributor->id, 'name' => $contributor->getName(), 'avatar' => $contributor->avatar()->setWidth(150)->setHeight(150)->url(), 'role' => $c->hasRole() ? $c->getRole()->to_array() : null, 'group' => $c->hasGroup() ? $c->getGroup()->to_array() : null, 'comment' => $c->comment, ]; }, EpisodeContribution::find_all_by_episode_id($episode->id)))); } return apply_filters('podlove_player5_config', $config, $episode); } function podlove_pwp5_audio_files($episode, $context) { $player_media_files = new PlayerMediaFiles($episode); if ($media_files = $player_media_files->get($context)) { $media_file_urls = array_map(function ($file) { return [ 'url' => $file['publicUrl'], 'size' => $file['size'], 'title' => $file['assetTitle'], 'mimeType' => $file['mime_type'], ]; }, $media_files); } elseif (is_admin()) { $media_file_urls = [ 'src' => \Podlove\PLUGIN_URL.'/bin/podlove.mp3', 'size' => 486839, 'title' => 'Podlove Example Audio', 'mimeType' => 'audio/mp3', ]; } else { $media_file_urls = []; } return $media_file_urls; } function podlove_pwp5_files($episode, $context) { global $wpdb; $sql = 'SELECT mf.id media_file_id, mf.size file_size, a.title asset_tile, a.downloadable, a.`position`, ft.mime_type, ft.`extension` FROM '.Episode::table_name().' e LEFT JOIN '.MediaFile::table_name().' mf ON mf.episode_id = e.id AND mf.active = 1 LEFT JOIN '.EpisodeAsset::table_name().' a ON a.id = mf.episode_asset_id LEFT JOIN '.FileType::table_name().' ft ON ft.id = a.file_type_id WHERE e.id = %d AND a.downloadable AND mf.active ORDER BY position ASC '; $files = $wpdb->get_results($wpdb->prepare($sql, $episode->id), ARRAY_A); return array_map(function ($row) use ($context) { $media_file = MediaFile::find_by_id($row['media_file_id']); return [ 'url' => $media_file->get_public_file_url('webplayer', $context), 'size' => $row['file_size'], 'title' => $row['asset_tile'], 'mimeType' => $row['mime_type'], ]; }, $files); } podlove_pwp5_init(); ================================================ FILE: includes/podlove_data_js_adapter.php ================================================ 'module values']; * return $data; * }); */ add_action('admin_head', 'podlove_init_js_adapter', 3); add_filter('podlove_data_js', 'podlove_js_adapter_inject_settings'); function podlove_init_js_adapter() { ?> window.PODLOVE_DATA = window.PODLOVE_DATA || { baseUrl: '' }; $value) { ?> window.PODLOVE_DATA[''] = ; is_development_environment()) { return; } if (isset($_GET['hook']) && $_GET['hook'] === 'podlove-js-hook') { // add CORS headers to allow anything header('Access-Control-Allow-Origin: *'); header('Access-Control-Allow-Methods: GET, POST, OPTIONS'); header('Access-Control-Allow-Headers: Content-Type'); podlove_init_js_content(); exit; } }); function podlove_js_adapter_inject_settings($data) { $defaults = \Podlove\get_setting_defaults(); $podcast = \Podlove\Model\Podcast::get(); $settings_tab_names = ['website', 'metadata', 'tracking']; $data['expert_settings'] = array_reduce($settings_tab_names, function ($tabs, $tab_name) use ($defaults) { $tabs[$tab_name] = array_reduce(array_keys($defaults[$tab_name]), function ($settings, $setting_name) use ($tab_name) { $settings[$setting_name] = \Podlove\get_setting($tab_name, $setting_name); return $settings; }, []); return $tabs; }, []); $data['media'] = ['base_uri' => $podcast->get_media_file_base_uri()]; $data['modules'] = \Podlove\Modules\Base::get_active_module_names(); return $data; } ================================================ FILE: includes/recording_date.php ================================================ 'string', 'key' => 'recording_date', 'options' => [ 'label' => __('Recording Date', 'podlove-podcasting-plugin-for-wordpress'), 'description' => '', 'html' => ['class' => 'regular-text podlove-check-input'], ], 'position' => 750, ]; return $form_data; }); add_filter('podlove_episode_data_filter', function ($filter) { return array_merge($filter, [ 'recording_date' => FILTER_UNSAFE_RAW, ]); }); ================================================ FILE: includes/redirects.php ================================================ $redirect) { if (!isset($redirect['active'])) { continue; } if (!strlen(trim($redirect['from'])) || !strlen(trim($redirect['to']))) { continue; } $parsed_url = wp_parse_url($redirect['from']); $parsed_redirect_url = $parsed_url['path']; if (isset($parsed_url['query'])) { $parsed_redirect_url .= '?'.$parsed_url['query']; } if (untrailingslashit($parsed_redirect_url) === untrailingslashit($parsed_request_url)) { if ($redirect['code']) { $http_code = (int) $redirect['code']; } else { $http_code = 301; // default to permanent } // fallback for HTTP/1.0 clients if ($http_code == 307 && $_SERVER['SERVER_PROTOCOL'] == 'HTTP/1.0') { $http_code = 302; } // increment redirection counter ++$redirects[$index]['count']; \Podlove\save_setting('redirects', 'podlove_setting_redirect', $redirects); // redirect status_header($http_code); $wp_query->is_404 = false; wp_redirect($redirect['to'], $http_code); exit; } } } /** * Simple method to allow support for multiple urls per post. * * Add custom post meta 'podlove_alternate_url' with old url part to match. * * @param mixed $value */ function podlove_handle_episode_redirects($value = '') { global $wpdb, $wp_query; if (is_admin()) { return; } if (!$wp_query->is_404) { return; } // check for episode redirects $rows = $wpdb->get_results(' SELECT post_id, meta_value url FROM '.$wpdb->postmeta." WHERE meta_key = 'podlove_alternate_url' ", ARRAY_A); $request_uri = untrailingslashit($_SERVER['REQUEST_URI']); foreach ($rows as $row) { if (false !== stripos($row['url'], $request_uri)) { status_header(301); $wp_query->is_404 = false; wp_redirect(get_permalink($row['post_id']), 301); exit; } } } ================================================ FILE: includes/request_id_rehash.php ================================================

display(); $counter = 0; foreach ($request_ids as $request_id) { ++$counter; $bar->progress(); podlove_rehash_replace_request_id($table, $request_id); } $bar->end(); if ($blog_id) { restore_current_blog(); } } /** * Rehashes a request id. * * Until v2.7.3 it was possible with reasonable effort to brute force the IP * address from a request_id. To anonymize them, the following is done: * * - each unique request id is rehashed with a random salt * - rehashed request IDs are prefixed with "DSGVO" to mark them a "ok" * * @param mixed $table * @param mixed $request_id */ function podlove_rehash_replace_request_id($table, $request_id) { global $wpdb; $salt = podlove_rehash_get_random_string(); $rehash = podlove_rehash_func($request_id, $salt); $prepared = $wpdb->prepare( "UPDATE {$table} SET request_id = %s WHERE request_id = %s AND accessed_at < \"%s\"", [ $rehash, $request_id, podlove_rehash_unsalted_time(), ] ); $wpdb->query($prepared); } function podlove_rehash_fetch_some_request_ids($table, $limit = null) { global $wpdb; if ($limit) { $limit_component = 'LIMIT '.(int) $limit; } else { $limit_component = ''; } $sql = sprintf( 'SELECT DISTINCT request_id FROM `%s` WHERE request_id NOT LIKE "%s" AND accessed_at < "%s" %s', $table, podlove_rehash_prefix().'%', podlove_rehash_unsalted_time(), $limit_component ); return $wpdb->get_col($sql); } /** * Returns upper time point for salting download intents. * * @return string DateTime in mysql format */ function podlove_rehash_unsalted_time() { $duration = strtotime('-1 day'); $duration = apply_filters('podlove_rehash_unsalted_duration', $duration); return date('Y-m-d H:i:s', $duration); } function podlove_rehash_total_remaining($table) { global $wpdb; $sql = sprintf( 'select COUNT(distinct request_id) from %s WHERE request_id NOT LIKE "%s"', $table, podlove_rehash_prefix().'%' ); return (int) $wpdb->get_var($sql); } function podlove_rehash_progress_class() { if (php_sapi_name() == 'cli') { return '\Dariuszp\CliProgressBar'; } return '\PodloveSilentProgressBar'; } function podlove_rehash_log($message) { if (php_sapi_name() == 'cli') { print_r($message); } } function podlove_rehash_get_random_string() { if (function_exists('random_bytes')) { return random_bytes(12); } if (function_exists('openssl_random_pseudo_bytes')) { return bin2hex(openssl_random_pseudo_bytes(12)); } return dechex(wp_rand()).dechex(wp_rand()); } function podlove_rehash_func($old_hash, $salt) { if (function_exists('openssl_digest')) { return podlove_rehash_prefix().openssl_digest($old_hash.$salt, 'sha256'); } if (function_exists('crypt')) { return podlove_rehash_prefix().crypt($old_hash, $salt); } return podlove_rehash_prefix().sha1($old_hash.$salt); } function podlove_rehash_prefix() { return 'DSGVO'; } ================================================ FILE: includes/require_curl.php ================================================


sudo apt-get install php-curl
community.podlove.org' ); ?>


__('Episodes per page', 'podlove-podcasting-plugin-for-wordpress'), 'default' => 10, 'option' => podlove_episodes_per_page_option_name(), ]); }); }, 20); ================================================ FILE: includes/scripts_and_styles.php ================================================ '; } // admin styles & scripts add_action('admin_enqueue_scripts', function () { $screen = get_current_screen(); $is_episode_edit_screen = \Podlove\is_episode_edit_screen(); $version = \Podlove\get_plugin_header('Version'); $vue_screens = [ 'podlove_page_podlove_slackshownotes_settings', 'podlove_page_podlove_tools_settings_handle', 'podlove_page_podlove_analytics', 'podlove-setup-wizard', 'podlove_page_publisher_plus_settings', ]; // vue job dashboard if ($is_episode_edit_screen || in_array($screen->base, $vue_screens)) { wp_register_script('podlove-episode-vue-apps', \Podlove\PLUGIN_URL.'/js/dist/app.js', ['underscore', 'jquery'], $version, true); wp_register_script('podlove-vue-app-client', \Podlove\PLUGIN_URL.'/client/dist/client.js', ['wp-i18n'], $version, false); add_filter('script_loader_tag', 'add_type_attribute', 10, 3); wp_enqueue_style('podlove-vue-app-client-css', \Podlove\PLUGIN_URL.'/client/dist/style.css', [], $version); $episode = Podlove\Model\Episode::find_or_create_by_post_id(get_the_ID()); if (!$episode) { wp_localize_script( 'podlove-episode-vue-apps', 'podlove_vue', [ 'rest_url' => esc_url_raw(rest_url()), 'nonce' => wp_create_nonce('wp_rest'), 'post_id' => get_the_ID(), 'episode_id' => 0, 'osf_active' => is_plugin_active('shownotes/shownotes.php'), ] ); } else { wp_localize_script( 'podlove-episode-vue-apps', 'podlove_vue', [ 'rest_url' => esc_url_raw(rest_url()), 'nonce' => wp_create_nonce('wp_rest'), 'post_id' => get_the_ID(), 'episode_id' => $episode->id, 'osf_active' => is_plugin_active('shownotes/shownotes.php'), ] ); add_filter('podlove_data_js', function ($data) use ($episode) { $data['episode'] = [ 'duration' => $episode->duration, 'id' => $episode->id ]; $data['post'] = [ 'id' => get_the_ID() ]; $data['api'] = [ 'base' => esc_url_raw(rest_url('podlove')), 'nonce' => wp_create_nonce('wp_rest'), ]; $assignments = \Podlove\Model\AssetAssignment::get_instance(); $data['assignments'] = [ 'image' => $assignments->image, 'chapters' => $assignments->chapters, 'transcript' => $assignments->transcript ]; return $data; }); } wp_set_script_translations('podlove-vue-app-client', 'podlove-podcasting-plugin-for-wordpress'); wp_enqueue_script('podlove-episode-vue-apps'); wp_enqueue_script('podlove-vue-app-client'); } if (\Podlove\is_podlove_settings_screen() || $is_episode_edit_screen) { wp_enqueue_style('podlove-admin', \Podlove\PLUGIN_URL.'/css/admin.css', [], $version); wp_enqueue_style('podlove-admin-font', \Podlove\PLUGIN_URL.'/css/admin-font.css', [], $version); // chosen.js scripts & styles wp_enqueue_style('podlove-admin-chosen', \Podlove\PLUGIN_URL.'/js/admin/chosen/chosen.min.css', [], $version); wp_enqueue_style('podlove-admin-image-chosen', \Podlove\PLUGIN_URL.'/js/admin/chosen/chosenImage.css', [], $version); wp_enqueue_script('podlove_admin', \Podlove\PLUGIN_URL.'/js/dist/podlove-admin.js', [ 'jquery', 'jquery-ui-sortable', 'jquery-ui-datepicker', ], $version); wp_enqueue_style('jquery-ui-style', \Podlove\PLUGIN_URL.'/js/admin/jquery-ui/css/smoothness/jquery-ui.css'); wp_localize_script( 'podlove_admin', 'podlove_admin_global', [ 'rest_url' => esc_url_raw(rest_url()), 'nonce' => wp_create_nonce('wp_rest'), 'nonce_ajax' => wp_create_nonce('podlove_ajax'), 'post_id' => get_the_ID(), ] ); } }); ================================================ FILE: includes/search.php ================================================ query_vars['search_terms'])) { return false; } if (isset($query->query_vars['suppress_filters']) && true == $query->query_vars['suppress_filters']) { return false; } if ($query->is_feed()) { return false; } return $query->is_search(); } /* * Extend/Replace WordPress core search logic to include episode fields. * * The way I do it here is not well-behaving. If other plugins modify the query * before me, their changes will be overridden. However, there is no better * place to hook into and I refuse to modify the filterable query string with * regular expressions. * * If you found this piece of code and are now cursing at me, please get in * touch. */ add_filter('posts_search', function ($search, $query) { global $wpdb; if (!podlove_is_search_query($query)) { return $search; } $episodesTable = \Podlove\Model\Episode::table_name(); $search = ''; $searchand = ''; $n = !empty($query->query_vars['exact']) ? '' : '%'; foreach ((array) $query->query_vars['search_terms'] as $term) { $term = esc_sql(\Podlove\esc_like($term)); $search .= " {$searchand} ( ({$wpdb->posts}.post_title LIKE '{$n}{$term}{$n}') OR ({$wpdb->posts}.post_content LIKE '{$n}{$term}{$n}') OR ({$episodesTable}.subtitle LIKE '{$n}{$term}{$n}') OR ({$episodesTable}.summary LIKE '{$n}{$term}{$n}') OR ({$episodesTable}.chapters LIKE '{$n}{$term}{$n}') )"; $searchand = ' AND '; } if (!empty($search)) { $search = " AND ({$search}) "; if (!is_user_logged_in()) { $search .= " AND ({$wpdb->posts}.post_password = '') "; } } return $search; }, 10, 2); // join into episode table in WordPress searches so we can access episode fields add_filter('posts_join', function ($join, $query) { global $wpdb; if (!podlove_is_search_query($query)) { return $join; } $episodesTable = \Podlove\Model\Episode::table_name(); $join .= " LEFT JOIN {$episodesTable} ON {$wpdb->posts}.ID = {$episodesTable}.post_id "; return $join; }, 10, 2); ================================================ FILE: includes/setup.php ================================================ 'MP3 Audio', 'type' => 'audio', 'mime_type' => 'audio/mpeg', 'extension' => 'mp3'], ['name' => 'BitTorrent (MP3 Audio)', 'type' => 'audio', 'mime_type' => 'application/x-bittorrent', 'extension' => 'mp3.torrent'], ['name' => 'MPEG-1 Video', 'type' => 'video', 'mime_type' => 'video/mpeg', 'extension' => 'mpg'], ['name' => 'MPEG-4 AAC Audio', 'type' => 'audio', 'mime_type' => 'audio/mp4', 'extension' => 'm4a'], ['name' => 'MPEG-4 ALAC Audio', 'type' => 'audio', 'mime_type' => 'audio/mp4', 'extension' => 'm4a'], ['name' => 'MPEG-4 Video', 'type' => 'video', 'mime_type' => 'video/mp4', 'extension' => 'mp4'], ['name' => 'M4V Video (Apple)', 'type' => 'video', 'mime_type' => 'video/x-m4v', 'extension' => 'm4v'], ['name' => 'Ogg Vorbis Audio', 'type' => 'audio', 'mime_type' => 'audio/ogg', 'extension' => 'oga'], ['name' => 'Ogg Vorbis Audio', 'type' => 'audio', 'mime_type' => 'audio/ogg', 'extension' => 'ogg'], ['name' => 'Ogg Theora Video', 'type' => 'video', 'mime_type' => 'video/ogg', 'extension' => 'ogv'], ['name' => 'WebM Audio', 'type' => 'audio', 'mime_type' => 'audio/webm', 'extension' => 'webm'], ['name' => 'WebM Video', 'type' => 'video', 'mime_type' => 'video/webm', 'extension' => 'webm'], ['name' => 'FLAC Audio', 'type' => 'audio', 'mime_type' => 'audio/flac', 'extension' => 'flac'], ['name' => 'Opus Audio', 'type' => 'audio', 'mime_type' => 'audio/ogg;codecs=opus', 'extension' => 'opus'], ['name' => 'Matroska Audio', 'type' => 'audio', 'mime_type' => 'audio/x-matroska', 'extension' => 'mka'], ['name' => 'Matroska Video', 'type' => 'video', 'mime_type' => 'video/x-matroska', 'extension' => 'mkv'], ['name' => 'PDF Document', 'type' => 'ebook', 'mime_type' => 'application/pdf', 'extension' => 'pdf'], ['name' => 'ePub Document', 'type' => 'ebook', 'mime_type' => 'application/epub+zip', 'extension' => 'epub'], ['name' => 'PNG Image', 'type' => 'image', 'mime_type' => 'image/png', 'extension' => 'png'], ['name' => 'JPEG Image', 'type' => 'image', 'mime_type' => 'image/jpeg', 'extension' => 'jpg'], ['name' => 'mp4chaps Chapter File', 'type' => 'chapters', 'mime_type' => 'text/plain', 'extension' => 'chapters.txt'], ['name' => 'Podlove Simple Chapters', 'type' => 'chapters', 'mime_type' => 'application/xml', 'extension' => 'psc'], ['name' => 'Subrip Captions', 'type' => 'captions', 'mime_type' => 'application/x-subrip', 'extension' => 'srt'], ['name' => 'WebVTT Captions', 'type' => 'captions', 'mime_type' => 'text/vtt', 'extension' => 'vtt'], ['name' => 'WebVTT Captions', 'type' => 'transcript', 'mime_type' => 'text/vtt', 'extension' => 'vtt'], ['name' => 'Auphonic Production Description', 'type' => 'metadata', 'mime_type' => 'application/json', 'extension' => 'json'], ['name' => 'Podigee Transcript', 'type' => 'transcript', 'mime_type' => 'plain/text', 'extension' => 'txt'], ]; foreach ($default_types as $file_type) { $f = new Model\FileType(); foreach ($file_type as $key => $value) { $f->{$key} = $value; } $f->save(); } } function podlove_setup_podcast() { $podcast = Model\Podcast::get(); if (!$podcast->limit_items) { $podcast->limit_items = Model\Feed::ITEMS_NO_LIMIT; $podcast->feed_transcripts = 'generated'; } if (!$podcast->guid) { $podcast->guid = UUID::uuid4(); } $podcast->save(); } function podlove_setup_modules() { // required for all module hooks to fire correctly add_option('podlove_active_modules', []); // set default modules $default_modules = [ 'logging', 'podlove_web_player', 'open_graph', 'plus', // 'asset_validation', 'oembed', // 'feed_validation', 'import_export', 'subscribe_button', 'automatic_numbering', 'onboarding' ]; foreach ($default_modules as $module) { \Podlove\Modules\Base::activate($module); } } function podlove_setup_expert_settings() { if (get_option('podlove', []) !== []) { return; } update_option('podlove', [ 'merge_episodes' => 'on', 'hide_wp_feed_discovery' => 'off', 'use_post_permastruct' => 'on', 'episode_archive' => 'on', 'episode_archive_slug' => '/podcast/', 'custom_episode_slug' => '/podcast/%podcast%/', ]); } function podlove_setup_default_template() { $template = Model\Template::find_one_by_property('title', 'default'); if ($template) { return; } // set default template $template = new Model\Template(); $template->title = 'default'; $template->content = <<<'EOT' {% if not is_feed() %} {# display web player for episode #} {{ episode.player }} {% endif %} {# display contributors if module is active #} {% if shortcode_exists("podlove-episode-contributor-list") %} {# see http://docs.podlove.org/podlove-publisher/reference/shortcodes.html#contributors for parameters #} [podlove-episode-contributor-list] {% endif %} EOT; $template->save(); $assignment = Model\TemplateAssignment::get_instance(); $assignment->top = $template->title; $assignment->save(); } function podlove_setup_default_media() { if (Model\EpisodeAsset::has_entries()) { return; } $asset = new Model\EpisodeAsset(); $asset->file_type_id = Model\FileType::find_one_by_property('extension', 'mp3')->id; $asset->title = 'MP3 Audio'; $asset->downloadable = 1; $asset->save(); $feed = new Model\Feed(); $feed->episode_asset_id = $asset->id; $feed->name = 'MP3 Feed'; $feed->title = 'MP3 Feed'; $feed->slug = 'mp3'; $feed->enable = 1; $feed->discoverable = 1; $feed->limit_items = Model\Feed::ITEMS_GLOBAL_LIMIT; $feed->embed_content_encoded = 1; $feed->save(); } function podlove_setup_default_asset_assignments() { $assignment = AssetAssignment::get_instance(); if (!$assignment->image) { $assignment->image = 'post-thumbnail'; $assignment->chapters = 'manual'; $assignment->save(); } } ================================================ FILE: includes/setup_wizard.php ================================================ Podlove Publisher | Setup Wizard
run(); } add_action('update_option_permalink_structure', 'podlove_run_system_report'); add_action('update_option_podlove', 'podlove_run_system_report'); ================================================ FILE: includes/template_pages.php ================================================ $template->title]); exit; } ================================================ FILE: includes/templates.php ================================================ top) { if ($template = Template::find_one_by_title_with_fallback($template_assignments->top)) { $shortcode = '[podlove-template template="'.$template->title.'"]'; if (stripos($content, $shortcode) === false) { $content = $shortcode.$content; } } } if ($template_assignments->bottom) { if ($template = Template::find_one_by_title_with_fallback($template_assignments->bottom)) { $shortcode = '[podlove-template template="'.$template->title.'"]'; if (stripos($content, $shortcode) === false) { $content = $content.$shortcode; } } } return $content; } function podlove_autoinsert_templates_head() { $template_assignments = TemplateAssignment::get_instance(); if ($template_assignments->head) { if ($template = Template::find_one_by_title_with_fallback($template_assignments->head)) { echo \Podlove\template_shortcode([ 'template' => $template->title, ]); } } } function podlove_autoinsert_templates_footer() { $template_assignments = TemplateAssignment::get_instance(); if ($template_assignments->footer) { if ($template = Template::find_one_by_title_with_fallback($template_assignments->footer)) { echo \Podlove\template_shortcode([ 'template' => $template->title, ]); } } } function podlove_autoinsert_templates_header() { $template_assignments = TemplateAssignment::get_instance(); if ($template_assignments->header) { if ($template = Template::find_one_by_title_with_fallback($template_assignments->header)) { echo \Podlove\template_shortcode([ 'template' => $template->title, ]); } } } ================================================ FILE: includes/theme_helper.php ================================================ ID); if (!$episode) { return null; } return new Template\Episode($episode); } /** * Get Podlove podcast template object. * * @param int $blog_id Optional. Blog ID. Defaults to global $blog_id. * * @return \Podlove\Template\Podcast */ function get_podcast($blog_id = null) { return new Template\Podcast(Model\Podcast::get($blog_id)); } /** * Get Podlove network template object. * * Only available in WordPress Multisite environments. * * @return \Podlove\Modules\Networks\Template\Network */ function get_network() { return new \Podlove\Modules\Networks\Template\Network(); } ================================================ FILE: includes/trash.php ================================================ is_admin && $wp_the_query == $wp_query) { return $posts; } // No post request if (isset($wp_query->query['preview']) || false === (isset($wp_query->query['name']) || isset($wp_query->query['p']))) { return $posts; } // Only check if we found more than 2 posts if (2 > count($posts)) { return $posts; } // Remove trashed posts foreach ($posts as $index => $post) { if ('trash' == $post->post_status) { unset($posts[$index]); } } // Resets array keys $posts = array_values($posts); return $posts; } ================================================ FILE: includes/verify_itunes_category.php ================================================ category_1; if (!$category) { return; } if (array_key_exists($category, \Podlove\Itunes\categories(false))) { return; } ?> 'http://localhost:10003/?webhook_debugger=1', * ]); * * And uncomment this or put it somewhere where it get executed: * * add_action('init', function () { * if (isset($_REQUEST['webhook_debugger'])) { * error_log(print_r($_REQUEST, true)); * } * }); */ use Podlove\Model; use Podlove\Webhook\Webhook; add_action('podlove_fire_webhook', 'podlove_fire_webhook', 10, 4); function podlove_fire_webhook($event, $method, $payload, $url) { $webhook = new Webhook($event); $webhook ->method($method) ->payload($payload) ->send($url) ; } function podlove_init_webhooks($config) { if (empty($config)) { return; } if (isset($config['episode_updated'])) { add_action('podlove_episode_content_has_changed', function ($episode_id) use ($config) { $event = 'episode_updated'; if ($episode = Model\Episode::find_by_id($episode_id)) { wp_schedule_single_event(time() + 1, 'podlove_fire_webhook', [ 'event' => $event, 'method' => 'POST', 'payload' => ['episode' => $episode->to_array()], 'url' => $config[$event] ]); } }); } } if (defined('PODLOVE_WEBHOOKS')) { podlove_init_webhooks(PODLOVE_WEBHOOKS); } ================================================ FILE: includes/wp_rocket.php ================================================ 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;et.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+ta)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>>0;if(a(t)!="[object Function]")throw new TypeError;while(++s>>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>>0,s=[],o,u=arguments[1];if(a(t)!="[object Function]")throw new TypeError(t+" is not a function");for(var f=0;f>>0,s=arguments[1];if(a(t)!="[object Function]")throw new TypeError(t+" is not a function");for(var o=0;o>>0,s=arguments[1];if(a(t)!="[object Function]")throw new TypeError(t+" is not a function");for(var o=0;o>>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>>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>>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=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;n1),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("
"),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=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;o1&&(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?tthis.end.column?1:0:ethis.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.rowt)var r={row:t+1,column:0};else if(this.start.rowt.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.row0&&(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(),e0&&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(e0&&/^\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;a1?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;il){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;yi){c>2*e.length&&this.reportError("infinite loop with in ace tokenizer",{startState:t,line:e});while(l1&&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=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;i2?r%f!=f-1:r%f==0}}var E=Infinity;w(function(e,t){var n=e.search(/\S/);n!==-1?(ne.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=0&&t.row=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.columnthis.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;e0,r=t=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=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;i20){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.rowthis.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;f0)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=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.rowr)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&&al.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=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=e)return i}return null},this.getFoldedRowCount=function(e,t){var n=this.$foldData,r=t-e+1;for(var i=0;i=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(u0&&(this.removeFolds(p),p.forEach(function(e){o.addSubFold(e)}));for(var d=0;d0&&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=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=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=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()):" "},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;t0&&(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(oe&&(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;ao){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;re.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;o0){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=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=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;fr-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>2)),a-1);while(w>E&&e[w]E&&e[w]E&&e[w]==l)w--}else while(w>E&&e[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;o39&&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=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]=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=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;ro&&(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;hv)continue;o.push(l=new s(c,v,c+a-1,m)),a>2&&(c=c+a-2)}}else for(var g=0;gE&&o[h].end.row==n.end.row)h--;o=o.slice(g,h+1);for(g=0,h=o.length;g=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;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;sr)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+10?(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;o0&&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==="=0)}else{do i=u,u=r.stepBackward(),i&&i.value===s&&i.type.indexOf("tag-name")!==-1&&(u.value==="<"?o++:u.value==="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==" "&&(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;tt.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 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&&np+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=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.topwindow.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;r0},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;to&&(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&&vn.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+1p,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("
"),u=this.$getTop(t.end.row,r);var f=t.end.column*r.characterWidth;e.push("
"),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("
")},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("
")},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("
")},this.drawScreenLineMarker=function(e,t,n,r,i){var s=this.$getTop(t.start.row,r),o=r.lineHeight;e.push("
")}}).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"+s.stringRepeat(this.TAB_CHAR,n)+""):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[" "]=""+u+"",this.$tabStrings[" "]=""+a+""}},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;uf&&(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.lastRow0;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.firstRowt.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("
"),this.$renderLine(t,i,!1,i==o?s:!1),this.$useLineGroups()&&t.push("
"),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?""+s.stringRepeat(i.SPACE_CHAR,e.length)+"":e;if(e=="&")return"&";if(e=="<")return"<";if(e==">")return">";if(e==" "){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,""+l+""}return r?""+i.SPACE_CHAR+"":(t+=1,""+e+"")},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("",a,"")}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]==" "?(e.push(s.stringRepeat(this.$tabStrings[" "],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=u)a=this.$renderToken(e,a,l,c.substring(0,u-i)),c=c.substring(u-i),i=u,r||e.push("
","
"),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");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("",t==this.session.getLength()-1?this.EOF_CHAR:this.EOL_CHAR,"")),n||e.push("
")},this.$getFoldLineTokens=function(e,t){function i(e,t,n){var i=0,s=0;while(s+e[i].value.lengthn-t&&(o=o.substring(0,n-t)),r.push({type:e[i].type,value:o}),s=t+o.length,i+=1}while(sn?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;ne.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.lastRowthis.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(ts?(t&&(s-=t*this.$size.scrollerHeight),s===0&&(s=-this.scrollMargin.top),this.session.setScrollTop(s)):a+this.$size.scrollerHeight-ui?(i=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.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=0;s--){var o=this.others[s],u={row:o.row,column:o.column+i};o.row===t.start.row&&t.start.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;e1&&!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;c1){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.column0)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;r0);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),io?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),st[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(++tf){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;s0&&!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("
"),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}); (function() { window.require(["ace/ace"], function(a) { a && a.config.init(true); if (!window.ace) window.ace = a; for (var key in a) if (a.hasOwnProperty(key)) window.ace[key] = a[key]; }); })(); ================================================ FILE: js/admin/ace/mode-twig.js ================================================ 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(++tf)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(++no)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:"",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:"(?:(<)|(",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:"(|$))",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:"(",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:">",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==="-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";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",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-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:""},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}) ================================================ FILE: js/admin/ace/theme-chrome.js ================================================ 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)}) ================================================ FILE: js/admin/ace/theme-github.js ================================================ 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)}) ================================================ FILE: js/admin/chosen/chosenImage.css ================================================ /* Give the image 3px of space to breathe. */ .chosenImage-container .chosen-results li, .chosenImage-container .chosen-single span { background: none 3px center / 19px 19px no-repeat; padding-left: 28px; } /* Make the image fit nicely to the left of the dropdown. */ .chosenImage-container .chosen-single { padding-left: 2px; } .chosenImage-container .chosen-single span { background-position: left 2px; } /* Let the "No results match" text fill the whole width. */ .chosenImage-container .chosen-results .no-results { padding-left: inherit; } ================================================ FILE: js/admin/chosen/chosenImage.jquery.js ================================================ /* * Chosen jQuery plugin to add an image to the dropdown items. */ (function($) { $.fn.chosenImage = function(options) { return this.each(function() { var $select = $(this); var imgMap = {}; // 1. Retrieve img-src from data attribute and build object of image sources for each list item. $select.find('option').filter(function(){ return $(this).text(); }).each(function(i) { imgMap[i] = $(this).attr('data-img-src'); }); // 2. Execute chosen plugin and get the newly created chosen container. $select.chosen(options); var $chosen = $select.next('.chosen-container').addClass('chosenImage-container'); // 3. Style lis with image sources. $chosen.on('mousedown.chosen, keyup.chosen', function(event){ $chosen.find('.chosen-results li').each(function() { var imgIndex = $(this).attr('data-option-array-index'); $(this).css(cssObj(imgMap[imgIndex])); }); }); // 4. Change image on chosen selected element when form changes. $select.change(function() { var imgSrc = $select.find('option:selected').attr('data-img-src') || ''; $chosen.find('.chosen-single span').css(cssObj(imgSrc)); }); $select.trigger('change'); // Utilties function cssObj(imgSrc) { var bgImg = (imgSrc) ? 'url(' + imgSrc + ')' : 'none'; return { 'background-image' : bgImg }; } }); }; })(jQuery); ================================================ FILE: js/admin/cornify.js ================================================ var cornify_count = 0; cornify_add = function() { cornify_count += 1; var cornify_url = 'http://www.cornify.com/'; var div = document.createElement('div'); div.style.position = 'fixed'; var numType = 'px'; var heightRandom = Math.random()*.75; var windowHeight = 768; var windowWidth = 1024; var height = 0; var width = 0; var de = document.documentElement; if (typeof(window.innerHeight) == 'number') { windowHeight = window.innerHeight; windowWidth = window.innerWidth; } else if(de && de.clientHeight) { windowHeight = de.clientHeight; windowWidth = de.clientWidth; } else { numType = '%'; height = Math.round( height*100 )+'%'; } div.onclick = cornify_add; div.style.zIndex = 10; div.style.outline = 0; if( cornify_count==15 ) { div.style.top = Math.max( 0, Math.round( (windowHeight-530)/2 ) ) + 'px'; div.style.left = Math.round( (windowWidth-530)/2 ) + 'px'; div.style.zIndex = 1000; } else { if( numType=='px' ) div.style.top = Math.round( windowHeight*heightRandom ) + numType; else div.style.top = height; div.style.left = Math.round( Math.random()*90 ) + '%'; } var img = document.createElement('img'); var currentTime = new Date(); var submitTime = currentTime.getTime(); if( cornify_count==15 ) submitTime = 0; img.setAttribute('src',cornify_url+'getacorn.php?r=' + submitTime + '&url='+document.location.href); var ease = "all .1s linear"; //div.style['-webkit-transition'] = ease; //div.style.webkitTransition = ease; div.style.WebkitTransition = ease; div.style.WebkitTransform = "rotate(1deg) scale(1.01,1.01)"; //div.style.MozTransition = "all .1s linear"; div.style.transition = "all .1s linear"; div.onmouseover = function() { var size = 1+Math.round(Math.random()*10)/100; var angle = Math.round(Math.random()*20-10); var result = "rotate("+angle+"deg) scale("+size+","+size+")"; this.style.transform = result; //this.style['-webkit-transform'] = result; //this.style.webkitTransform = result; this.style.WebkitTransform = result; //this.style.MozTransform = result; //alert(this + ' | ' + result); } div.onmouseout = function() { var size = .9+Math.round(Math.random()*10)/100; var angle = Math.round(Math.random()*6-3); var result = "rotate("+angle+"deg) scale("+size+","+size+")"; this.style.transform = result; //this.style['-webkit-transform'] = result; //this.style.webkitTransform = result; this.style.WebkitTransform = result; //this.style.MozTransform = result; } var body = document.getElementsByTagName('body')[0]; body.appendChild(div); div.appendChild(img); // Add stylesheet. if (cornify_count == 5) { var cssExisting = document.getElementById('__cornify_css'); if (!cssExisting) { var head = document.getElementsByTagName("head")[0]; var css = document.createElement('link'); css.id = '__cornify_css'; css.type = 'text/css'; css.rel = 'stylesheet'; css.href = 'http://www.cornify.com/css/cornify.css'; css.media = 'screen'; head.appendChild(css); } cornify_replace(); } } cornify_replace = function() { // Replace text. var hc = 6; var hs; var h; var k; var words = ['Happy','Sparkly','Glittery','Fun','Magical','Lovely','Cute','Charming','Amazing','Wonderful']; while(hc >= 1) { hs = document.getElementsByTagName('h' + hc); for (k = 0; k < hs.length; k++) { h = hs[k]; h.innerHTML = words[Math.floor(Math.random()*words.length)] + ' ' + h.innerHTML; } hc-=1; } } /* * Adapted from http://www.snaptortoise.com/konami-js/ */ var cornami = { input:"", pattern:"38384040373937396665", clear:setTimeout('cornami.clear_input()',5000), load: function() { window.document.onkeydown = function(e) { if (cornami.input == cornami.pattern) { cornify_add(); clearTimeout(cornami.clear); return; } else { cornami.input += e ? e.keyCode : event.keyCode; if (cornami.input == cornami.pattern) cornify_add(); clearTimeout(cornami.clear); cornami.clear = setTimeout("cornami.clear_input()", 5000); } } }, clear_input: function() { cornami.input=""; clearTimeout(cornami.clear); } } cornami.load(); ================================================ FILE: js/admin/dc.js ================================================ /*! * dc 3.0.2 * http://dc-js.github.io/dc.js/ * Copyright 2012-2016 Nick Zhu & the dc.js Developers * https://github.com/dc-js/dc.js/blob/master/AUTHORS * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ (function() { function _dc(d3, crossfilter) { 'use strict'; /** * The entire dc.js library is scoped under the **dc** name space. It does not introduce * anything else into the global name space. * * Most `dc` functions are designed to allow function chaining, meaning they return the current chart * instance whenever it is appropriate. The getter forms of functions do not participate in function * chaining because they return values that are not the chart, although some, * such as {@link dc.baseMixin#svg .svg} and {@link dc.coordinateGridMixin#xAxis .xAxis}, * return values that are themselves chainable d3 objects. * @namespace dc * @version 3.0.2 * @example * // Example chaining * chart.width(300) * .height(300) * .filter('sunday'); */ /*jshint -W079*/ var dc = { version: '3.0.2', constants: { CHART_CLASS: 'dc-chart', DEBUG_GROUP_CLASS: 'debug', STACK_CLASS: 'stack', DESELECTED_CLASS: 'deselected', SELECTED_CLASS: 'selected', NODE_INDEX_NAME: '__index__', GROUP_INDEX_NAME: '__group_index__', DEFAULT_CHART_GROUP: '__default_chart_group__', EVENT_DELAY: 40, NEGLIGIBLE_NUMBER: 1e-10 }, _renderlet: null }; /*jshint +W079*/ /** * The dc.chartRegistry object maintains sets of all instantiated dc.js charts under named groups * and the default group. * * A chart group often corresponds to a crossfilter instance. It specifies * the set of charts which should be updated when a filter changes on one of the charts or when the * global functions {@link dc.filterAll dc.filterAll}, {@link dc.refocusAll dc.refocusAll}, * {@link dc.renderAll dc.renderAll}, {@link dc.redrawAll dc.redrawAll}, or chart functions * {@link dc.baseMixin#renderGroup baseMixin.renderGroup}, * {@link dc.baseMixin#redrawGroup baseMixin.redrawGroup} are called. * * @namespace chartRegistry * @memberof dc * @type {{has, register, deregister, clear, list}} */ dc.chartRegistry = (function () { // chartGroup:string => charts:array var _chartMap = {}; function initializeChartGroup (group) { if (!group) { group = dc.constants.DEFAULT_CHART_GROUP; } if (!_chartMap[group]) { _chartMap[group] = []; } return group; } return { /** * Determine if a given chart instance resides in any group in the registry. * @method has * @memberof dc.chartRegistry * @param {Object} chart dc.js chart instance * @returns {Boolean} */ has: function (chart) { for (var e in _chartMap) { if (_chartMap[e].indexOf(chart) >= 0) { return true; } } return false; }, /** * Add given chart instance to the given group, creating the group if necessary. * If no group is provided, the default group `dc.constants.DEFAULT_CHART_GROUP` will be used. * @method register * @memberof dc.chartRegistry * @param {Object} chart dc.js chart instance * @param {String} [group] Group name */ register: function (chart, group) { group = initializeChartGroup(group); _chartMap[group].push(chart); }, /** * Remove given chart instance from the given group, creating the group if necessary. * If no group is provided, the default group `dc.constants.DEFAULT_CHART_GROUP` will be used. * @method deregister * @memberof dc.chartRegistry * @param {Object} chart dc.js chart instance * @param {String} [group] Group name */ deregister: function (chart, group) { group = initializeChartGroup(group); for (var i = 0; i < _chartMap[group].length; i++) { if (_chartMap[group][i].anchorName() === chart.anchorName()) { _chartMap[group].splice(i, 1); break; } } }, /** * Clear given group if one is provided, otherwise clears all groups. * @method clear * @memberof dc.chartRegistry * @param {String} group Group name */ clear: function (group) { if (group) { delete _chartMap[group]; } else { _chartMap = {}; } }, /** * Get an array of each chart instance in the given group. * If no group is provided, the charts in the default group are returned. * @method list * @memberof dc.chartRegistry * @param {String} [group] Group name * @returns {Array} */ list: function (group) { group = initializeChartGroup(group); return _chartMap[group]; } }; })(); /** * Add given chart instance to the given group, creating the group if necessary. * If no group is provided, the default group `dc.constants.DEFAULT_CHART_GROUP` will be used. * @memberof dc * @method registerChart * @param {Object} chart dc.js chart instance * @param {String} [group] Group name */ dc.registerChart = function (chart, group) { dc.chartRegistry.register(chart, group); }; /** * Remove given chart instance from the given group, creating the group if necessary. * If no group is provided, the default group `dc.constants.DEFAULT_CHART_GROUP` will be used. * @memberof dc * @method deregisterChart * @param {Object} chart dc.js chart instance * @param {String} [group] Group name */ dc.deregisterChart = function (chart, group) { dc.chartRegistry.deregister(chart, group); }; /** * Determine if a given chart instance resides in any group in the registry. * @memberof dc * @method hasChart * @param {Object} chart dc.js chart instance * @returns {Boolean} */ dc.hasChart = function (chart) { return dc.chartRegistry.has(chart); }; /** * Clear given group if one is provided, otherwise clears all groups. * @memberof dc * @method deregisterAllCharts * @param {String} group Group name */ dc.deregisterAllCharts = function (group) { dc.chartRegistry.clear(group); }; /** * Clear all filters on all charts within the given chart group. If the chart group is not given then * only charts that belong to the default chart group will be reset. * @memberof dc * @method filterAll * @param {String} [group] */ dc.filterAll = function (group) { var charts = dc.chartRegistry.list(group); for (var i = 0; i < charts.length; ++i) { charts[i].filterAll(); } }; /** * Reset zoom level / focus on all charts that belong to the given chart group. If the chart group is * not given then only charts that belong to the default chart group will be reset. * @memberof dc * @method refocusAll * @param {String} [group] */ dc.refocusAll = function (group) { var charts = dc.chartRegistry.list(group); for (var i = 0; i < charts.length; ++i) { if (charts[i].focus) { charts[i].focus(); } } }; /** * Re-render all charts belong to the given chart group. If the chart group is not given then only * charts that belong to the default chart group will be re-rendered. * @memberof dc * @method renderAll * @param {String} [group] */ dc.renderAll = function (group) { var charts = dc.chartRegistry.list(group); for (var i = 0; i < charts.length; ++i) { charts[i].render(); } if (dc._renderlet !== null) { dc._renderlet(group); } }; /** * Redraw all charts belong to the given chart group. If the chart group is not given then only charts * that belong to the default chart group will be re-drawn. Redraw is different from re-render since * when redrawing dc tries to update the graphic incrementally, using transitions, instead of starting * from scratch. * @memberof dc * @method redrawAll * @param {String} [group] */ dc.redrawAll = function (group) { var charts = dc.chartRegistry.list(group); for (var i = 0; i < charts.length; ++i) { charts[i].redraw(); } if (dc._renderlet !== null) { dc._renderlet(group); } }; /** * If this boolean is set truthy, all transitions will be disabled, and changes to the charts will happen * immediately. * @memberof dc * @member disableTransitions * @type {Boolean} * @default false */ dc.disableTransitions = false; /** * Start a transition on a selection if transitions are globally enabled * ({@link dc.disableTransitions} is false) and the duration is greater than zero; otherwise return * the selection. Since most operations are the same on a d3 selection and a d3 transition, this * allows a common code path for both cases. * @memberof dc * @method transition * @param {d3.selection} selection - the selection to be transitioned * @param {Number|Function} [duration=250] - the duration of the transition in milliseconds, a * function returning the duration, or 0 for no transition * @param {Number|Function} [delay] - the delay of the transition in milliseconds, or a function * returning the delay, or 0 for no delay * @param {String} [name] - the name of the transition (if concurrent transitions on the same * elements are needed) * @returns {d3.transition|d3.selection} */ dc.transition = function (selection, duration, delay, name) { if (dc.disableTransitions || duration <= 0) { return selection; } var s = selection.transition(name); if (duration >= 0 || duration !== undefined) { s = s.duration(duration); } if (delay >= 0 || delay !== undefined) { s = s.delay(delay); } return s; }; /* somewhat silly, but to avoid duplicating logic */ dc.optionalTransition = function (enable, duration, delay, name) { if (enable) { return function (selection) { return dc.transition(selection, duration, delay, name); }; } else { return function (selection) { return selection; }; } }; // See http://stackoverflow.com/a/20773846 dc.afterTransition = function (transition, callback) { if (transition.empty() || !transition.duration) { callback.call(transition); } else { var n = 0; transition .each(function () { ++n; }) .on('end', function () { if (!--n) { callback.call(transition); } }); } }; /** * @namespace units * @memberof dc * @type {{}} */ dc.units = {}; /** * The default value for {@link dc.coordinateGridMixin#xUnits .xUnits} for the * {@link dc.coordinateGridMixin Coordinate Grid Chart} and should * be used when the x values are a sequence of integers. * It is a function that counts the number of integers in the range supplied in its start and end parameters. * @method integers * @memberof dc.units * @see {@link dc.coordinateGridMixin#xUnits coordinateGridMixin.xUnits} * @example * chart.xUnits(dc.units.integers) // already the default * @param {Number} start * @param {Number} end * @returns {Number} */ dc.units.integers = function (start, end) { return Math.abs(end - start); }; /** * This argument can be passed to the {@link dc.coordinateGridMixin#xUnits .xUnits} function of a * coordinate grid chart to specify ordinal units for the x axis. Usually this parameter is used in * combination with passing * {@link https://github.com/d3/d3-scale/blob/master/README.md#ordinal-scales d3.scaleOrdinal} * to {@link dc.coordinateGridMixin#x .x}. * * As of dc.js 3.0, this is purely a placeholder or magic value which causes the chart to go into ordinal mode; the * function is not called. * @method ordinal * @memberof dc.units * @see {@link https://github.com/d3/d3-scale/blob/master/README.md#ordinal-scales d3.scaleOrdinal} * @see {@link dc.coordinateGridMixin#xUnits coordinateGridMixin.xUnits} * @see {@link dc.coordinateGridMixin#x coordinateGridMixin.x} * @example * chart.xUnits(dc.units.ordinal) * .x(d3.scaleOrdinal()) */ dc.units.ordinal = function () { throw new Error('dc.units.ordinal should not be called - it is a placeholder'); }; /** * @namespace fp * @memberof dc.units * @type {{}} */ dc.units.fp = {}; /** * This function generates an argument for the {@link dc.coordinateGridMixin Coordinate Grid Chart} * {@link dc.coordinateGridMixin#xUnits .xUnits} function specifying that the x values are floating-point * numbers with the given precision. * The returned function determines how many values at the given precision will fit into the range * supplied in its start and end parameters. * @method precision * @memberof dc.units.fp * @see {@link dc.coordinateGridMixin#xUnits coordinateGridMixin.xUnits} * @example * // specify values (and ticks) every 0.1 units * chart.xUnits(dc.units.fp.precision(0.1) * // there are 500 units between 0.5 and 1 if the precision is 0.001 * var thousandths = dc.units.fp.precision(0.001); * thousandths(0.5, 1.0) // returns 500 * @param {Number} precision * @returns {Function} start-end unit function */ dc.units.fp.precision = function (precision) { var _f = function (s, e) { var d = Math.abs((e - s) / _f.resolution); if (dc.utils.isNegligible(d - Math.floor(d))) { return Math.floor(d); } else { return Math.ceil(d); } }; _f.resolution = precision; return _f; }; dc.round = {}; dc.round.floor = function (n) { return Math.floor(n); }; dc.round.ceil = function (n) { return Math.ceil(n); }; dc.round.round = function (n) { return Math.round(n); }; dc.override = function (obj, functionName, newFunction) { var existingFunction = obj[functionName]; obj['_' + functionName] = existingFunction; obj[functionName] = newFunction; }; dc.renderlet = function (_) { if (!arguments.length) { return dc._renderlet; } dc._renderlet = _; return dc; }; dc.instanceOfChart = function (o) { return o instanceof Object && o.__dcFlag__ && true; }; dc.errors = {}; dc.errors.Exception = function (msg) { var _msg = msg || 'Unexpected internal error'; this.message = _msg; this.toString = function () { return _msg; }; this.stack = (new Error()).stack; }; dc.errors.Exception.prototype = Object.create(Error.prototype); dc.errors.Exception.prototype.constructor = dc.errors.Exception; dc.errors.InvalidStateException = function () { dc.errors.Exception.apply(this, arguments); }; dc.errors.InvalidStateException.prototype = Object.create(dc.errors.Exception.prototype); dc.errors.InvalidStateException.prototype.constructor = dc.errors.InvalidStateException; dc.errors.BadArgumentException = function () { dc.errors.Exception.apply(this, arguments); }; dc.errors.BadArgumentException.prototype = Object.create(dc.errors.Exception.prototype); dc.errors.BadArgumentException.prototype.constructor = dc.errors.BadArgumentException; /** * The default date format for dc.js * @name dateFormat * @memberof dc * @type {Function} * @default d3.timeFormat('%m/%d/%Y') */ dc.dateFormat = d3.timeFormat('%m/%d/%Y'); /** * @namespace printers * @memberof dc * @type {{}} */ dc.printers = {}; /** * Converts a list of filters into a readable string. * @method filters * @memberof dc.printers * @param {Array} filters * @returns {String} */ dc.printers.filters = function (filters) { var s = ''; for (var i = 0; i < filters.length; ++i) { if (i > 0) { s += ', '; } s += dc.printers.filter(filters[i]); } return s; }; /** * Converts a filter into a readable string. * @method filter * @memberof dc.printers * @param {dc.filters|any|Array} filter * @returns {String} */ dc.printers.filter = function (filter) { var s = ''; if (typeof filter !== 'undefined' && filter !== null) { if (filter instanceof Array) { if (filter.length >= 2) { s = '[' + dc.utils.printSingleValue(filter[0]) + ' -> ' + dc.utils.printSingleValue(filter[1]) + ']'; } else if (filter.length >= 1) { s = dc.utils.printSingleValue(filter[0]); } } else { s = dc.utils.printSingleValue(filter); } } return s; }; /** * Returns a function that given a string property name, can be used to pluck the property off an object. A function * can be passed as the second argument to also alter the data being returned. * * This can be a useful shorthand method to create accessor functions. * @method pluck * @memberof dc * @example * var xPluck = dc.pluck('x'); * var objA = {x: 1}; * xPluck(objA) // 1 * @example * var xPosition = dc.pluck('x', function (x, i) { * // `this` is the original datum, * // `x` is the x property of the datum, * // `i` is the position in the array * return this.radius + x; * }); * dc.selectAll('.circle').data(...).x(xPosition); * @param {String} n * @param {Function} [f] * @returns {Function} */ dc.pluck = function (n, f) { if (!f) { return function (d) { return d[n]; }; } return function (d, i) { return f.call(d, d[n], i); }; }; /** * @namespace utils * @memberof dc * @type {{}} */ dc.utils = {}; /** * Print a single value filter. * @method printSingleValue * @memberof dc.utils * @param {any} filter * @returns {String} */ dc.utils.printSingleValue = function (filter) { var s = '' + filter; if (filter instanceof Date) { s = dc.dateFormat(filter); } else if (typeof(filter) === 'string') { s = filter; } else if (dc.utils.isFloat(filter)) { s = dc.utils.printSingleValue.fformat(filter); } else if (dc.utils.isInteger(filter)) { s = Math.round(filter); } return s; }; dc.utils.printSingleValue.fformat = d3.format('.2f'); // convert 'day' to 'timeDay' and similar dc.utils.toTimeFunc = function (t) { return 'time' + t.charAt(0).toUpperCase() + t.slice(1); }; /** * Arbitrary add one value to another. * * If the value l is of type Date, adds r units to it. t becomes the unit. * For example dc.utils.add(dt, 3, 'week') will add 3 (r = 3) weeks (t= 'week') to dt. * * If l is of type numeric, t is ignored. In this case if r is of type string, * it is assumed to be percentage (whether or not it includes %). For example * dc.utils.add(30, 10) will give 40 and dc.utils.add(30, '10') will give 33. * * They also generate strange results if l is a string. * @method add * @memberof dc.utils * @param {Date|Number} l the value to modify * @param {String|Number} r the amount by which to modify the value * @param {Function|String} [t=d3.timeDay] if `l` is a `Date`, then this should be a * [d3 time interval](https://github.com/d3/d3-time/blob/master/README.md#_interval). * For backward compatibility with dc.js 2.0, it can also be the name of an interval, i.e. * 'millis', 'second', 'minute', 'hour', 'day', 'week', 'month', or 'year' * @returns {Date|Number} */ dc.utils.add = function (l, r, t) { if (typeof r === 'string') { r = r.replace('%', ''); } if (l instanceof Date) { if (typeof r === 'string') { r = +r; } if (t === 'millis') { return new Date(l.getTime() + r); } t = t || d3.timeDay; if (typeof t !== 'function') { t = d3[dc.utils.toTimeFunc(t)]; } return t.offset(l, r); } else if (typeof r === 'string') { var percentage = (+r / 100); return l > 0 ? l * (1 + percentage) : l * (1 - percentage); } else { return l + r; } }; /** * Arbitrary subtract one value from another. * * If the value l is of type Date, subtracts r units from it. t becomes the unit. * For example dc.utils.subtract(dt, 3, 'week') will subtract 3 (r = 3) weeks (t= 'week') from dt. * * If l is of type numeric, t is ignored. In this case if r is of type string, * it is assumed to be percentage (whether or not it includes %). For example * dc.utils.subtract(30, 10) will give 20 and dc.utils.subtract(30, '10') will give 27. * * They also generate strange results if l is a string. * @method subtract * @memberof dc.utils * @param {Date|Number} l the value to modify * @param {String|Number} r the amount by which to modify the value * @param {Function|String} [t=d3.timeDay] if `l` is a `Date`, then this should be a * [d3 time interval](https://github.com/d3/d3-time/blob/master/README.md#_interval). * For backward compatibility with dc.js 2.0, it can also be the name of an interval, i.e. * 'millis', 'second', 'minute', 'hour', 'day', 'week', 'month', or 'year' * @returns {Date|Number} */ dc.utils.subtract = function (l, r, t) { if (typeof r === 'string') { r = r.replace('%', ''); } if (l instanceof Date) { if (typeof r === 'string') { r = +r; } if (t === 'millis') { return new Date(l.getTime() - r); } t = t || d3.timeDay; if (typeof t !== 'function') { t = d3[dc.utils.toTimeFunc(t)]; } return t.offset(l, -r); } else if (typeof r === 'string') { var percentage = (+r / 100); return l < 0 ? l * (1 + percentage) : l * (1 - percentage); } else { return l - r; } }; /** * Is the value a number? * @method isNumber * @memberof dc.utils * @param {any} n * @returns {Boolean} */ dc.utils.isNumber = function (n) { return n === +n; }; /** * Is the value a float? * @method isFloat * @memberof dc.utils * @param {any} n * @returns {Boolean} */ dc.utils.isFloat = function (n) { return n === +n && n !== (n | 0); }; /** * Is the value an integer? * @method isInteger * @memberof dc.utils * @param {any} n * @returns {Boolean} */ dc.utils.isInteger = function (n) { return n === +n && n === (n | 0); }; /** * Is the value very close to zero? * @method isNegligible * @memberof dc.utils * @param {any} n * @returns {Boolean} */ dc.utils.isNegligible = function (n) { return !dc.utils.isNumber(n) || (n < dc.constants.NEGLIGIBLE_NUMBER && n > -dc.constants.NEGLIGIBLE_NUMBER); }; /** * Ensure the value is no greater or less than the min/max values. If it is return the boundary value. * @method clamp * @memberof dc.utils * @param {any} val * @param {any} min * @param {any} max * @returns {any} */ dc.utils.clamp = function (val, min, max) { return val < min ? min : (val > max ? max : val); }; /** * Given `x`, return a function that always returns `x`. * * {@link https://github.com/d3/d3/blob/master/CHANGES.md#internals `d3.functor` was removed in d3 version 4}. * This function helps to implement the replacement, * `typeof x === "function" ? x : dc.utils.constant(x)` * @method constant * @memberof dc.utils * @param {any} x * @returns {Function} */ dc.utils.constant = function (x) { return function () { return x; }; }; /** * Using a simple static counter, provide a unique integer id. * @method uniqueId * @memberof dc.utils * @returns {Number} */ var _idCounter = 0; dc.utils.uniqueId = function () { return ++_idCounter; }; /** * Convert a name to an ID. * @method nameToId * @memberof dc.utils * @param {String} name * @returns {String} */ dc.utils.nameToId = function (name) { return name.toLowerCase().replace(/[\s]/g, '_').replace(/[\.']/g, ''); }; /** * Append or select an item on a parent element. * @method appendOrSelect * @memberof dc.utils * @param {d3.selection} parent * @param {String} selector * @param {String} tag * @returns {d3.selection} */ dc.utils.appendOrSelect = function (parent, selector, tag) { tag = tag || selector; var element = parent.select(selector); if (element.empty()) { element = parent.append(tag); } return element; }; /** * Return the number if the value is a number; else 0. * @method safeNumber * @memberof dc.utils * @param {Number|any} n * @returns {Number} */ dc.utils.safeNumber = function (n) { return dc.utils.isNumber(+n) ? +n : 0;}; /** * Return true if both arrays are equal, if both array are null these are considered equal * @method arraysEqual * @memberof dc.utils * @param {Array|null} a1 * @param {Array|null} a2 * @returns {Boolean} */ dc.utils.arraysEqual = function (a1, a2) { if (!a1 || !a2) { return a1 === a2; } return a1.length === a2.length && // If elements are not integers/strings, we hope that it will match because of toString // Test cases cover dates as well. a1.every(function (elem, i) { return elem === a2[i] || elem.toString() === a2[i].toString(); }); }; // ******** Sunburst Chart ******** dc.utils.allChildren = function (node) { var paths = []; paths.push(node.path); console.log('currentNode', node); if (node.children) { for (var i = 0; i < node.children.length; i++) { paths = paths.concat(dc.utils.allChildren(node.children[i])); } } return paths; }; // builds a d3 Hierarchy from a collection // TODO: turn this monster method something better. dc.utils.toHierarchy = function (list, accessor) { var root = {'key': 'root', 'children': []}; for (var i = 0; i < list.length; i++) { var data = list[i]; var parts = data.key; var value = accessor(data); var currentNode = root; for (var j = 0; j < parts.length; j++) { var currentPath = parts.slice(0, j + 1); var children = currentNode.children; var nodeName = parts[j]; var childNode; if (j + 1 < parts.length) { // Not yet at the end of the sequence; move down the tree. childNode = findChild(children, nodeName); // If we don't already have a child node for this branch, create it. if (childNode === void 0) { childNode = {'key': nodeName, 'children': [], 'path': currentPath}; children.push(childNode); } currentNode = childNode; } else { // Reached the end of the sequence; create a leaf node. childNode = {'key': nodeName, 'value': value, 'data': data, 'path': currentPath}; children.push(childNode); } } } return root; }; function findChild (children, nodeName) { for (var k = 0; k < children.length; k++) { if (children[k].key === nodeName) { return children[k]; } } } dc.utils.getAncestors = function (node) { var path = []; var current = node; while (current.parent) { path.unshift(current.name); current = current.parent; } return path; }; dc.utils.arraysIdentical = function (a, b) { var i = a.length; if (i !== b.length) { return false; } while (i--) { if (a[i] !== b[i]) { return false; } } return true; }; /** * Provides basis logging and deprecation utilities * @class logger * @memberof dc * @returns {dc.logger} */ dc.logger = (function () { var _logger = {}; /** * Enable debug level logging. Set to `false` by default. * @name enableDebugLog * @memberof dc.logger * @instance */ _logger.enableDebugLog = false; /** * Put a warning message to console * @method warn * @memberof dc.logger * @instance * @example * dc.logger.warn('Invalid use of .tension on CurveLinear'); * @param {String} [msg] * @returns {dc.logger} */ _logger.warn = function (msg) { if (console) { if (console.warn) { console.warn(msg); } else if (console.log) { console.log(msg); } } return _logger; }; var _alreadyWarned = {}; /** * Put a warning message to console. It will warn only on unique messages. * @method warnOnce * @memberof dc.logger * @instance * @example * dc.logger.warnOnce('Invalid use of .tension on CurveLinear'); * @param {String} [msg] * @returns {dc.logger} */ _logger.warnOnce = function (msg) { if (!_alreadyWarned[msg]) { _alreadyWarned[msg] = true; dc.logger.warn(msg); } return _logger; }; /** * Put a debug message to console. It is controlled by `dc.logger.enableDebugLog` * @method debug * @memberof dc.logger * @instance * @example * dc.logger.debug('Total number of slices: ' + numSlices); * @param {String} [msg] * @returns {dc.logger} */ _logger.debug = function (msg) { if (_logger.enableDebugLog && console) { if (console.debug) { console.debug(msg); } else if (console.log) { console.log(msg); } } return _logger; }; /** * Use it to deprecate a function. It will return a wrapped version of the function, which will * will issue a warning when invoked. For each function, warning will be issued only once. * * @method deprecate * @memberof dc.logger * @instance * @example * _chart.interpolate = dc.logger.deprecate(function (interpolate) { * if (!arguments.length) { * return _interpolate; * } * _interpolate = interpolate; * return _chart; * }, 'dc.lineChart.interpolate has been deprecated since version 3.0 use dc.lineChart.curve instead'); * @param {Function} [fn] * @param {String} [msg] * @returns {Function} */ _logger.deprecate = function (fn, msg) { // Allow logging of deprecation var warned = false; function deprecated () { if (!warned) { _logger.warn(msg); warned = true; } return fn.apply(this, arguments); } return deprecated; }; return _logger; })(); /** * General configuration * * @class config * @memberof dc * @returns {dc.config} */ dc.config = (function () { var _config = {}; // D3v5 has removed schemeCategory20c, copied here for backward compatibility var _schemeCategory20c = [ '#3182bd', '#6baed6', '#9ecae1', '#c6dbef', '#e6550d', '#fd8d3c', '#fdae6b', '#fdd0a2', '#31a354', '#74c476', '#a1d99b', '#c7e9c0', '#756bb1', '#9e9ac8', '#bcbddc', '#dadaeb', '#636363', '#969696', '#bdbdbd', '#d9d9d9']; var _defaultColors = _schemeCategory20c; /** * Set the default color scheme for ordinal charts. Changing it will impact all ordinal charts. * * By default it is set to a copy of * `d3.schemeCategory20c` for backward compatibility. This color scheme has been * [removed from D3v5](https://github.com/d3/d3/blob/master/CHANGES.md#changes-in-d3-50). * In DC 3.1 release it will change to a more appropriate default. * * @example * dc.config.defaultColors(d3.schemeSet1) * @method defaultColors * @memberof dc.config * @instance * @param {Array} [colors] * @returns {Array|dc.config} */ _config.defaultColors = function (colors) { if (!arguments.length) { // Issue warning if it uses _schemeCategory20c if (_defaultColors === _schemeCategory20c) { dc.logger.warnOnce('You are using d3.schemeCategory20c, which has been removed in D3v5. ' + 'See the explanation at https://github.com/d3/d3/blob/master/CHANGES.md#changes-in-d3-50. ' + 'DC is using it for backward compatibility, however it will be changed in DCv3.1. ' + 'You can change it by calling dc.config.defaultColors(newScheme). ' + 'See https://github.com/d3/d3-scale-chromatic for some alternatives.'); } return _defaultColors; } _defaultColors = colors; return _config; }; return _config; })(); dc.events = { current: null }; /** * This function triggers a throttled event function with a specified delay (in milli-seconds). Events * that are triggered repetitively due to user interaction such brush dragging might flood the library * and invoke more renders than can be executed in time. Using this function to wrap your event * function allows the library to smooth out the rendering by throttling events and only responding to * the most recent event. * @name events.trigger * @memberof dc * @example * chart.on('renderlet', function(chart) { * // smooth the rendering through event throttling * dc.events.trigger(function(){ * // focus some other chart to the range selected by user on this chart * someOtherChart.focus(chart.filter()); * }); * }) * @param {Function} closure * @param {Number} [delay] */ dc.events.trigger = function (closure, delay) { if (!delay) { closure(); return; } dc.events.current = closure; setTimeout(function () { if (closure === dc.events.current) { closure(); } }, delay); }; /** * The dc.js filters are functions which are passed into crossfilter to chose which records will be * accumulated to produce values for the charts. In the crossfilter model, any filters applied on one * dimension will affect all the other dimensions but not that one. dc always applies a filter * function to the dimension; the function combines multiple filters and if any of them accept a * record, it is filtered in. * * These filter constructors are used as appropriate by the various charts to implement brushing. We * mention below which chart uses which filter. In some cases, many instances of a filter will be added. * * Each of the dc.js filters is an object with the following properties: * * `isFiltered` - a function that returns true if a value is within the filter * * `filterType` - a string identifying the filter, here the name of the constructor * * Currently these filter objects are also arrays, but this is not a requirement. Custom filters * can be used as long as they have the properties above. * @namespace filters * @memberof dc * @type {{}} */ dc.filters = {}; /** * RangedFilter is a filter which accepts keys between `low` and `high`. It is used to implement X * axis brushing for the {@link dc.coordinateGridMixin coordinate grid charts}. * * Its `filterType` is 'RangedFilter' * @name RangedFilter * @memberof dc.filters * @param {Number} low * @param {Number} high * @returns {Array} * @constructor */ dc.filters.RangedFilter = function (low, high) { var range = new Array(low, high); range.isFiltered = function (value) { return value >= this[0] && value < this[1]; }; range.filterType = 'RangedFilter'; return range; }; /** * TwoDimensionalFilter is a filter which accepts a single two-dimensional value. It is used by the * {@link dc.heatMap heat map chart} to include particular cells as they are clicked. (Rows and columns are * filtered by filtering all the cells in the row or column.) * * Its `filterType` is 'TwoDimensionalFilter' * @name TwoDimensionalFilter * @memberof dc.filters * @param {Array} filter * @returns {Array} * @constructor */ dc.filters.TwoDimensionalFilter = function (filter) { if (filter === null) { return null; } var f = filter; f.isFiltered = function (value) { return value.length && value.length === f.length && value[0] === f[0] && value[1] === f[1]; }; f.filterType = 'TwoDimensionalFilter'; return f; }; /** * The RangedTwoDimensionalFilter allows filtering all values which fit within a rectangular * region. It is used by the {@link dc.scatterPlot scatter plot} to implement rectangular brushing. * * It takes two two-dimensional points in the form `[[x1,y1],[x2,y2]]`, and normalizes them so that * `x1 <= x2` and `y1 <= y2`. It then returns a filter which accepts any points which are in the * rectangular range including the lower values but excluding the higher values. * * If an array of two values are given to the RangedTwoDimensionalFilter, it interprets the values as * two x coordinates `x1` and `x2` and returns a filter which accepts any points for which `x1 <= x < * x2`. * * Its `filterType` is 'RangedTwoDimensionalFilter' * @name RangedTwoDimensionalFilter * @memberof dc.filters * @param {Array>} filter * @returns {Array>} * @constructor */ dc.filters.RangedTwoDimensionalFilter = function (filter) { if (filter === null) { return null; } var f = filter; var fromBottomLeft; if (f[0] instanceof Array) { fromBottomLeft = [ [Math.min(filter[0][0], filter[1][0]), Math.min(filter[0][1], filter[1][1])], [Math.max(filter[0][0], filter[1][0]), Math.max(filter[0][1], filter[1][1])] ]; } else { fromBottomLeft = [[filter[0], -Infinity], [filter[1], Infinity]]; } f.isFiltered = function (value) { var x, y; if (value instanceof Array) { x = value[0]; y = value[1]; } else { x = value; y = fromBottomLeft[0][1]; } return x >= fromBottomLeft[0][0] && x < fromBottomLeft[1][0] && y >= fromBottomLeft[0][1] && y < fromBottomLeft[1][1]; }; f.filterType = 'RangedTwoDimensionalFilter'; return f; }; // ******** Sunburst Chart ******** /** * HierarchyFilter is a filter which accepts a key path as an array. It matches any node at, or * child of, the given path. It is used by the {@link dc.sunburstChart sunburst chart} to include particular cells and all * their children as they are clicked. * * @name HierarchyFilter * @memberof dc.filters * @param {String} path * @returns {Array} * @constructor */ dc.filters.HierarchyFilter = function (path) { if (path === null) { return null; } var filter = path.slice(0); filter.isFiltered = function (value) { if (!(filter.length && value && value.length && value.length >= filter.length)) { return false; } for (var i = 0; i < filter.length; i++) { if (value[i] !== filter[i]) { return false; } } return true; }; return filter; }; /** * `dc.baseMixin` is an abstract functional object representing a basic `dc` chart object * for all chart and widget implementations. Methods from the {@link #dc.baseMixin dc.baseMixin} are inherited * and available on all chart implementations in the `dc` library. * @name baseMixin * @memberof dc * @mixin * @param {Object} _chart * @returns {dc.baseMixin} */ dc.baseMixin = function (_chart) { _chart.__dcFlag__ = dc.utils.uniqueId(); var _dimension; var _group; var _anchor; var _root; var _svg; var _isChild; var _minWidth = 200; var _defaultWidthCalc = function (element) { var width = element && element.getBoundingClientRect && element.getBoundingClientRect().width; return (width && width > _minWidth) ? width : _minWidth; }; var _widthCalc = _defaultWidthCalc; var _minHeight = 200; var _defaultHeightCalc = function (element) { var height = element && element.getBoundingClientRect && element.getBoundingClientRect().height; return (height && height > _minHeight) ? height : _minHeight; }; var _heightCalc = _defaultHeightCalc; var _width, _height; var _useViewBoxResizing = false; var _keyAccessor = dc.pluck('key'); var _valueAccessor = dc.pluck('value'); var _label = dc.pluck('key'); var _ordering = dc.pluck('key'); var _orderSort; var _renderLabel = false; var _title = function (d) { return _chart.keyAccessor()(d) + ': ' + _chart.valueAccessor()(d); }; var _renderTitle = true; var _controlsUseVisibility = false; var _transitionDuration = 750; var _transitionDelay = 0; var _filterPrinter = dc.printers.filters; var _mandatoryAttributes = ['dimension', 'group']; var _chartGroup = dc.constants.DEFAULT_CHART_GROUP; var _listeners = d3.dispatch( 'preRender', 'postRender', 'preRedraw', 'postRedraw', 'filtered', 'zoomed', 'renderlet', 'pretransition'); var _legend; var _commitHandler; var _filters = []; var _filterHandler = function (dimension, filters) { if (filters.length === 0) { dimension.filter(null); } else if (filters.length === 1 && !filters[0].isFiltered) { // single value and not a function-based filter dimension.filterExact(filters[0]); } else if (filters.length === 1 && filters[0].filterType === 'RangedFilter') { // single range-based filter dimension.filterRange(filters[0]); } else { dimension.filterFunction(function (d) { for (var i = 0; i < filters.length; i++) { var filter = filters[i]; if (filter.isFiltered && filter.isFiltered(d)) { return true; } else if (filter <= d && filter >= d) { return true; } } return false; }); } return filters; }; var _data = function (group) { return group.all(); }; /** * Set or get the height attribute of a chart. The height is applied to the SVGElement generated by * the chart when rendered (or re-rendered). If a value is given, then it will be used to calculate * the new height and the chart returned for method chaining. The value can either be a numeric, a * function, or falsy. If no value is specified then the value of the current height attribute will * be returned. * * By default, without an explicit height being given, the chart will select the width of its * anchor element. If that isn't possible it defaults to 200 (provided by the * {@link dc.baseMixin#minHeight minHeight} property). Setting the value falsy will return * the chart to the default behavior. * @method height * @memberof dc.baseMixin * @instance * @see {@link dc.baseMixin#minHeight minHeight} * @example * // Default height * chart.height(function (element) { * var height = element && element.getBoundingClientRect && element.getBoundingClientRect().height; * return (height && height > chart.minHeight()) ? height : chart.minHeight(); * }); * * chart.height(250); // Set the chart's height to 250px; * chart.height(function(anchor) { return doSomethingWith(anchor); }); // set the chart's height with a function * chart.height(null); // reset the height to the default auto calculation * @param {Number|Function} [height] * @returns {Number|dc.baseMixin} */ _chart.height = function (height) { if (!arguments.length) { if (!dc.utils.isNumber(_height)) { // only calculate once _height = _heightCalc(_root.node()); } return _height; } _heightCalc = height ? (typeof height === 'function' ? height : dc.utils.constant(height)) : _defaultHeightCalc; _height = undefined; return _chart; }; /** * Set or get the width attribute of a chart. * @method width * @memberof dc.baseMixin * @instance * @see {@link dc.baseMixin#height height} * @see {@link dc.baseMixin#minWidth minWidth} * @example * // Default width * chart.width(function (element) { * var width = element && element.getBoundingClientRect && element.getBoundingClientRect().width; * return (width && width > chart.minWidth()) ? width : chart.minWidth(); * }); * @param {Number|Function} [width] * @returns {Number|dc.baseMixin} */ _chart.width = function (width) { if (!arguments.length) { if (!dc.utils.isNumber(_width)) { // only calculate once _width = _widthCalc(_root.node()); } return _width; } _widthCalc = width ? (typeof width === 'function' ? width : dc.utils.constant(width)) : _defaultWidthCalc; _width = undefined; return _chart; }; /** * Set or get the minimum width attribute of a chart. This only has effect when used with the default * {@link dc.baseMixin#width width} function. * @method minWidth * @memberof dc.baseMixin * @instance * @see {@link dc.baseMixin#width width} * @param {Number} [minWidth=200] * @returns {Number|dc.baseMixin} */ _chart.minWidth = function (minWidth) { if (!arguments.length) { return _minWidth; } _minWidth = minWidth; return _chart; }; /** * Set or get the minimum height attribute of a chart. This only has effect when used with the default * {@link dc.baseMixin#height height} function. * @method minHeight * @memberof dc.baseMixin * @instance * @see {@link dc.baseMixin#height height} * @param {Number} [minHeight=200] * @returns {Number|dc.baseMixin} */ _chart.minHeight = function (minHeight) { if (!arguments.length) { return _minHeight; } _minHeight = minHeight; return _chart; }; /** * Turn on/off using the SVG * {@link https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/viewBox `viewBox` attribute}. * When enabled, `viewBox` will be set on the svg root element instead of `width` and `height`. * Requires that the chart aspect ratio be defined using chart.width(w) and chart.height(h). * * This will maintain the aspect ratio while enabling the chart to resize responsively to the * space given to the chart using CSS. For example, the chart can use `width: 100%; height: * 100%` or absolute positioning to resize to its parent div. * * Since the text will be sized as if the chart is drawn according to the width and height, and * will be resized if the chart is any other size, you need to set the chart width and height so * that the text looks good. In practice, 600x400 seems to work pretty well for most charts. * * You can see examples of this resizing strategy in the [Chart Resizing * Examples](http://dc-js.github.io/dc.js/resizing/); just add `?resize=viewbox` to any of the * one-chart examples to enable `useViewBoxResizing`. * @method useViewBoxResizing * @memberof dc.baseMixin * @instance * @param {Boolean} [useViewBoxResizing=false] * @returns {Boolean|dc.baseMixin} */ _chart.useViewBoxResizing = function (useViewBoxResizing) { if (!arguments.length) { return _useViewBoxResizing; } _useViewBoxResizing = useViewBoxResizing; return _chart; }; /** * **mandatory** * * Set or get the dimension attribute of a chart. In `dc`, a dimension can be any valid * {@link https://github.com/crossfilter/crossfilter/wiki/API-Reference#dimension crossfilter dimension} * * If a value is given, then it will be used as the new dimension. If no value is specified then * the current dimension will be returned. * @method dimension * @memberof dc.baseMixin * @instance * @see {@link https://github.com/crossfilter/crossfilter/wiki/API-Reference#dimension crossfilter.dimension} * @example * var index = crossfilter([]); * var dimension = index.dimension(dc.pluck('key')); * chart.dimension(dimension); * @param {crossfilter.dimension} [dimension] * @returns {crossfilter.dimension|dc.baseMixin} */ _chart.dimension = function (dimension) { if (!arguments.length) { return _dimension; } _dimension = dimension; _chart.expireCache(); return _chart; }; /** * Set the data callback or retrieve the chart's data set. The data callback is passed the chart's * group and by default will return * {@link https://github.com/crossfilter/crossfilter/wiki/API-Reference#group_all group.all}. * This behavior may be modified to, for instance, return only the top 5 groups. * @method data * @memberof dc.baseMixin * @instance * @example * // Default data function * chart.data(function (group) { return group.all(); }); * * chart.data(function (group) { return group.top(5); }); * @param {Function} [callback] * @returns {*|dc.baseMixin} */ _chart.data = function (callback) { if (!arguments.length) { return _data.call(_chart, _group); } _data = typeof callback === 'function' ? callback : dc.utils.constant(callback); _chart.expireCache(); return _chart; }; /** * **mandatory** * * Set or get the group attribute of a chart. In `dc` a group is a * {@link https://github.com/crossfilter/crossfilter/wiki/API-Reference#group-map-reduce crossfilter group}. * Usually the group should be created from the particular dimension associated with the same chart. If a value is * given, then it will be used as the new group. * * If no value specified then the current group will be returned. * If `name` is specified then it will be used to generate legend label. * @method group * @memberof dc.baseMixin * @instance * @see {@link https://github.com/crossfilter/crossfilter/wiki/API-Reference#group-map-reduce crossfilter.group} * @example * var index = crossfilter([]); * var dimension = index.dimension(dc.pluck('key')); * chart.dimension(dimension); * chart.group(dimension.group(crossfilter.reduceSum())); * @param {crossfilter.group} [group] * @param {String} [name] * @returns {crossfilter.group|dc.baseMixin} */ _chart.group = function (group, name) { if (!arguments.length) { return _group; } _group = group; _chart._groupName = name; _chart.expireCache(); return _chart; }; /** * Get or set an accessor to order ordinal dimensions. The chart uses * {@link https://github.com/crossfilter/crossfilter/wiki/API-Reference#quicksort_by crossfilter.quicksort.by} * to sort elements; this accessor returns the value to order on. * @method ordering * @memberof dc.baseMixin * @instance * @see {@link https://github.com/crossfilter/crossfilter/wiki/API-Reference#quicksort_by crossfilter.quicksort.by} * @example * // Default ordering accessor * _chart.ordering(dc.pluck('key')); * @param {Function} [orderFunction] * @returns {Function|dc.baseMixin} */ _chart.ordering = function (orderFunction) { if (!arguments.length) { return _ordering; } _ordering = orderFunction; _orderSort = crossfilter.quicksort.by(_ordering); _chart.expireCache(); return _chart; }; _chart._computeOrderedGroups = function (data) { var dataCopy = data.slice(0); if (dataCopy.length <= 1) { return dataCopy; } if (!_orderSort) { _orderSort = crossfilter.quicksort.by(_ordering); } return _orderSort(dataCopy, 0, dataCopy.length); }; /** * Clear all filters associated with this chart. The same effect can be achieved by calling * {@link dc.baseMixin#filter chart.filter(null)}. * @method filterAll * @memberof dc.baseMixin * @instance * @returns {dc.baseMixin} */ _chart.filterAll = function () { return _chart.filter(null); }; /** * Execute d3 single selection in the chart's scope using the given selector and return the d3 * selection. * * This function is **not chainable** since it does not return a chart instance; however the d3 * selection result can be chained to d3 function calls. * @method select * @memberof dc.baseMixin * @instance * @see {@link https://github.com/d3/d3-selection/blob/master/README.md#select d3.select} * @example * // Has the same effect as d3.select('#chart-id').select(selector) * chart.select(selector) * @returns {d3.selection} */ _chart.select = function (s) { return _root.select(s); }; /** * Execute in scope d3 selectAll using the given selector and return d3 selection result. * * This function is **not chainable** since it does not return a chart instance; however the d3 * selection result can be chained to d3 function calls. * @method selectAll * @memberof dc.baseMixin * @instance * @see {@link https://github.com/d3/d3-selection/blob/master/README.md#selectAll d3.selectAll} * @example * // Has the same effect as d3.select('#chart-id').selectAll(selector) * chart.selectAll(selector) * @returns {d3.selection} */ _chart.selectAll = function (s) { return _root ? _root.selectAll(s) : null; }; /** * Set the root SVGElement to either be an existing chart's root; or any valid [d3 single * selector](https://github.com/d3/d3-selection/blob/master/README.md#selecting-elements) specifying a dom * block element such as a div; or a dom element or d3 selection. Optionally registers the chart * within the chartGroup. This class is called internally on chart initialization, but be called * again to relocate the chart. However, it will orphan any previously created SVGElements. * @method anchor * @memberof dc.baseMixin * @instance * @param {anchorChart|anchorSelector|anchorNode} [parent] * @param {String} [chartGroup] * @returns {String|node|d3.selection|dc.baseMixin} */ _chart.anchor = function (parent, chartGroup) { if (!arguments.length) { return _anchor; } if (dc.instanceOfChart(parent)) { _anchor = parent.anchor(); _root = parent.root(); _isChild = true; } else if (parent) { if (parent.select && parent.classed) { // detect d3 selection _anchor = parent.node(); } else { _anchor = parent; } _root = d3.select(_anchor); _root.classed(dc.constants.CHART_CLASS, true); dc.registerChart(_chart, chartGroup); _isChild = false; } else { throw new dc.errors.BadArgumentException('parent must be defined'); } _chartGroup = chartGroup; return _chart; }; /** * Returns the DOM id for the chart's anchored location. * @method anchorName * @memberof dc.baseMixin * @instance * @returns {String} */ _chart.anchorName = function () { var a = _chart.anchor(); if (a && a.id) { return a.id; } if (a && a.replace) { return a.replace('#', ''); } return 'dc-chart' + _chart.chartID(); }; /** * Returns the root element where a chart resides. Usually it will be the parent div element where * the SVGElement was created. You can also pass in a new root element however this is usually handled by * dc internally. Resetting the root element on a chart outside of dc internals may have * unexpected consequences. * @method root * @memberof dc.baseMixin * @instance * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement HTMLElement} * @param {HTMLElement} [rootElement] * @returns {HTMLElement|dc.baseMixin} */ _chart.root = function (rootElement) { if (!arguments.length) { return _root; } _root = rootElement; return _chart; }; /** * Returns the top SVGElement for this specific chart. You can also pass in a new SVGElement, * however this is usually handled by dc internally. Resetting the SVGElement on a chart outside * of dc internals may have unexpected consequences. * @method svg * @memberof dc.baseMixin * @instance * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/SVGElement SVGElement} * @param {SVGElement|d3.selection} [svgElement] * @returns {SVGElement|d3.selection|dc.baseMixin} */ _chart.svg = function (svgElement) { if (!arguments.length) { return _svg; } _svg = svgElement; return _chart; }; /** * Remove the chart's SVGElements from the dom and recreate the container SVGElement. * @method resetSvg * @memberof dc.baseMixin * @instance * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/SVGElement SVGElement} * @returns {SVGElement} */ _chart.resetSvg = function () { _chart.select('svg').remove(); return generateSvg(); }; function sizeSvg () { if (_svg) { if (!_useViewBoxResizing) { _svg .attr('width', _chart.width()) .attr('height', _chart.height()); } else if (!_svg.attr('viewBox')) { _svg .attr('viewBox', '0 0 ' + _chart.width() + ' ' + _chart.height()); } } } function generateSvg () { _svg = _chart.root().append('svg'); sizeSvg(); return _svg; } /** * Set or get the filter printer function. The filter printer function is used to generate human * friendly text for filter value(s) associated with the chart instance. The text will get shown * in the `.filter element; see {@link dc.baseMixin#turnOnControls turnOnControls}. * * By default dc charts use a default filter printer {@link dc.printers.filters dc.printers.filters} * that provides simple printing support for both single value and ranged filters. * @method filterPrinter * @memberof dc.baseMixin * @instance * @example * // for a chart with an ordinal brush, print the filters in upper case * chart.filterPrinter(function(filters) { * return filters.map(function(f) { return f.toUpperCase(); }).join(', '); * }); * // for a chart with a range brush, print the filter as start and extent * chart.filterPrinter(function(filters) { * return 'start ' + dc.utils.printSingleValue(filters[0][0]) + * ' extent ' + dc.utils.printSingleValue(filters[0][1] - filters[0][0]); * }); * @param {Function} [filterPrinterFunction=dc.printers.filters] * @returns {Function|dc.baseMixin} */ _chart.filterPrinter = function (filterPrinterFunction) { if (!arguments.length) { return _filterPrinter; } _filterPrinter = filterPrinterFunction; return _chart; }; /** * If set, use the `visibility` attribute instead of the `display` attribute for showing/hiding * chart reset and filter controls, for less disruption to the layout. * @method controlsUseVisibility * @memberof dc.baseMixin * @instance * @param {Boolean} [controlsUseVisibility=false] * @returns {Boolean|dc.baseMixin} **/ _chart.controlsUseVisibility = function (useVisibility) { if (!arguments.length) { return _controlsUseVisibility; } _controlsUseVisibility = useVisibility; return _chart; }; /** * Turn on optional control elements within the root element. dc currently supports the * following html control elements. * * root.selectAll('.reset') - elements are turned on if the chart has an active filter. This type * of control element is usually used to store a reset link to allow user to reset filter on a * certain chart. This element will be turned off automatically if the filter is cleared. * * root.selectAll('.filter') elements are turned on if the chart has an active filter. The text * content of this element is then replaced with the current filter value using the filter printer * function. This type of element will be turned off automatically if the filter is cleared. * @method turnOnControls * @memberof dc.baseMixin * @instance * @returns {dc.baseMixin} */ _chart.turnOnControls = function () { if (_root) { var attribute = _chart.controlsUseVisibility() ? 'visibility' : 'display'; _chart.selectAll('.reset').style(attribute, null); _chart.selectAll('.filter').text(_filterPrinter(_chart.filters())).style(attribute, null); } return _chart; }; /** * Turn off optional control elements within the root element. * @method turnOffControls * @memberof dc.baseMixin * @see {@link dc.baseMixin#turnOnControls turnOnControls} * @instance * @returns {dc.baseMixin} */ _chart.turnOffControls = function () { if (_root) { var attribute = _chart.controlsUseVisibility() ? 'visibility' : 'display'; var value = _chart.controlsUseVisibility() ? 'hidden' : 'none'; _chart.selectAll('.reset').style(attribute, value); _chart.selectAll('.filter').style(attribute, value).text(_chart.filter()); } return _chart; }; /** * Set or get the animation transition duration (in milliseconds) for this chart instance. * @method transitionDuration * @memberof dc.baseMixin * @instance * @param {Number} [duration=750] * @returns {Number|dc.baseMixin} */ _chart.transitionDuration = function (duration) { if (!arguments.length) { return _transitionDuration; } _transitionDuration = duration; return _chart; }; /** * Set or get the animation transition delay (in milliseconds) for this chart instance. * @method transitionDelay * @memberof dc.baseMixin * @instance * @param {Number} [delay=0] * @returns {Number|dc.baseMixin} */ _chart.transitionDelay = function (delay) { if (!arguments.length) { return _transitionDelay; } _transitionDelay = delay; return _chart; }; _chart._mandatoryAttributes = function (_) { if (!arguments.length) { return _mandatoryAttributes; } _mandatoryAttributes = _; return _chart; }; function checkForMandatoryAttributes (a) { if (!_chart[a] || !_chart[a]()) { throw new dc.errors.InvalidStateException('Mandatory attribute chart.' + a + ' is missing on chart[#' + _chart.anchorName() + ']'); } } /** * Invoking this method will force the chart to re-render everything from scratch. Generally it * should only be used to render the chart for the first time on the page or if you want to make * sure everything is redrawn from scratch instead of relying on the default incremental redrawing * behaviour. * @method render * @memberof dc.baseMixin * @instance * @returns {dc.baseMixin} */ _chart.render = function () { _height = _width = undefined; // force recalculate _listeners.call('preRender', _chart, _chart); if (_mandatoryAttributes) { _mandatoryAttributes.forEach(checkForMandatoryAttributes); } var result = _chart._doRender(); if (_legend) { _legend.render(); } _chart._activateRenderlets('postRender'); return result; }; _chart._activateRenderlets = function (event) { _listeners.call('pretransition', _chart, _chart); if (_chart.transitionDuration() > 0 && _svg) { _svg.transition().duration(_chart.transitionDuration()).delay(_chart.transitionDelay()) .on('end', function () { _listeners.call('renderlet', _chart, _chart); if (event) { _listeners.call(event, _chart, _chart); } }); } else { _listeners.call('renderlet', _chart, _chart); if (event) { _listeners.call(event, _chart, _chart); } } }; /** * Calling redraw will cause the chart to re-render data changes incrementally. If there is no * change in the underlying data dimension then calling this method will have no effect on the * chart. Most chart interaction in dc will automatically trigger this method through internal * events (in particular {@link dc.redrawAll dc.redrawAll}); therefore, you only need to * manually invoke this function if data is manipulated outside of dc's control (for example if * data is loaded in the background using * {@link https://github.com/crossfilter/crossfilter/wiki/API-Reference#crossfilter_add crossfilter.add}). * @method redraw * @memberof dc.baseMixin * @instance * @returns {dc.baseMixin} */ _chart.redraw = function () { sizeSvg(); _listeners.call('preRedraw', _chart, _chart); var result = _chart._doRedraw(); if (_legend) { _legend.render(); } _chart._activateRenderlets('postRedraw'); return result; }; /** * Gets/sets the commit handler. If the chart has a commit handler, the handler will be called when * the chart's filters have changed, in order to send the filter data asynchronously to a server. * * Unlike other functions in dc.js, the commit handler is asynchronous. It takes two arguments: * a flag indicating whether this is a render (true) or a redraw (false), and a callback to be * triggered once the commit is filtered. The callback has the standard node.js continuation signature * with error first and result second. * @method commitHandler * @memberof dc.baseMixin * @instance * @returns {dc.baseMixin} */ _chart.commitHandler = function (commitHandler) { if (!arguments.length) { return _commitHandler; } _commitHandler = commitHandler; return _chart; }; /** * Redraws all charts in the same group as this chart, typically in reaction to a filter * change. If the chart has a {@link dc.baseMixin.commitFilter commitHandler}, it will * be executed and waited for. * @method redrawGroup * @memberof dc.baseMixin * @instance * @returns {dc.baseMixin} */ _chart.redrawGroup = function () { if (_commitHandler) { _commitHandler(false, function (error, result) { if (error) { console.log(error); } else { dc.redrawAll(_chart.chartGroup()); } }); } else { dc.redrawAll(_chart.chartGroup()); } return _chart; }; /** * Renders all charts in the same group as this chart. If the chart has a * {@link dc.baseMixin.commitFilter commitHandler}, it will be executed and waited for * @method renderGroup * @memberof dc.baseMixin * @instance * @returns {dc.baseMixin} */ _chart.renderGroup = function () { if (_commitHandler) { _commitHandler(false, function (error, result) { if (error) { console.log(error); } else { dc.renderAll(_chart.chartGroup()); } }); } else { dc.renderAll(_chart.chartGroup()); } return _chart; }; _chart._invokeFilteredListener = function (f) { if (f !== undefined) { _listeners.call('filtered', _chart, _chart, f); } }; _chart._invokeZoomedListener = function () { _listeners.call('zoomed', _chart, _chart); }; var _hasFilterHandler = function (filters, filter) { if (filter === null || typeof(filter) === 'undefined') { return filters.length > 0; } return filters.some(function (f) { return filter <= f && filter >= f; }); }; /** * Set or get the has-filter handler. The has-filter handler is a function that checks to see if * the chart's current filters (first argument) include a specific filter (second argument). Using a custom has-filter handler allows * you to change the way filters are checked for and replaced. * @method hasFilterHandler * @memberof dc.baseMixin * @instance * @example * // default has-filter handler * chart.hasFilterHandler(function (filters, filter) { * if (filter === null || typeof(filter) === 'undefined') { * return filters.length > 0; * } * return filters.some(function (f) { * return filter <= f && filter >= f; * }); * }); * * // custom filter handler (no-op) * chart.hasFilterHandler(function(filters, filter) { * return false; * }); * @param {Function} [hasFilterHandler] * @returns {Function|dc.baseMixin} */ _chart.hasFilterHandler = function (hasFilterHandler) { if (!arguments.length) { return _hasFilterHandler; } _hasFilterHandler = hasFilterHandler; return _chart; }; /** * Check whether any active filter or a specific filter is associated with particular chart instance. * This function is **not chainable**. * @method hasFilter * @memberof dc.baseMixin * @instance * @see {@link dc.baseMixin#hasFilterHandler hasFilterHandler} * @param {*} [filter] * @returns {Boolean} */ _chart.hasFilter = function (filter) { return _hasFilterHandler(_filters, filter); }; var _removeFilterHandler = function (filters, filter) { for (var i = 0; i < filters.length; i++) { if (filters[i] <= filter && filters[i] >= filter) { filters.splice(i, 1); break; } } return filters; }; /** * Set or get the remove filter handler. The remove filter handler is a function that removes a * filter from the chart's current filters. Using a custom remove filter handler allows you to * change how filters are removed or perform additional work when removing a filter, e.g. when * using a filter server other than crossfilter. * * The handler should return a new or modified array as the result. * @method removeFilterHandler * @memberof dc.baseMixin * @instance * @example * // default remove filter handler * chart.removeFilterHandler(function (filters, filter) { * for (var i = 0; i < filters.length; i++) { * if (filters[i] <= filter && filters[i] >= filter) { * filters.splice(i, 1); * break; * } * } * return filters; * }); * * // custom filter handler (no-op) * chart.removeFilterHandler(function(filters, filter) { * return filters; * }); * @param {Function} [removeFilterHandler] * @returns {Function|dc.baseMixin} */ _chart.removeFilterHandler = function (removeFilterHandler) { if (!arguments.length) { return _removeFilterHandler; } _removeFilterHandler = removeFilterHandler; return _chart; }; var _addFilterHandler = function (filters, filter) { filters.push(filter); return filters; }; /** * Set or get the add filter handler. The add filter handler is a function that adds a filter to * the chart's filter list. Using a custom add filter handler allows you to change the way filters * are added or perform additional work when adding a filter, e.g. when using a filter server other * than crossfilter. * * The handler should return a new or modified array as the result. * @method addFilterHandler * @memberof dc.baseMixin * @instance * @example * // default add filter handler * chart.addFilterHandler(function (filters, filter) { * filters.push(filter); * return filters; * }); * * // custom filter handler (no-op) * chart.addFilterHandler(function(filters, filter) { * return filters; * }); * @param {Function} [addFilterHandler] * @returns {Function|dc.baseMixin} */ _chart.addFilterHandler = function (addFilterHandler) { if (!arguments.length) { return _addFilterHandler; } _addFilterHandler = addFilterHandler; return _chart; }; var _resetFilterHandler = function (filters) { return []; }; /** * Set or get the reset filter handler. The reset filter handler is a function that resets the * chart's filter list by returning a new list. Using a custom reset filter handler allows you to * change the way filters are reset, or perform additional work when resetting the filters, * e.g. when using a filter server other than crossfilter. * * The handler should return a new or modified array as the result. * @method resetFilterHandler * @memberof dc.baseMixin * @instance * @example * // default remove filter handler * function (filters) { * return []; * } * * // custom filter handler (no-op) * chart.resetFilterHandler(function(filters) { * return filters; * }); * @param {Function} [resetFilterHandler] * @returns {dc.baseMixin} */ _chart.resetFilterHandler = function (resetFilterHandler) { if (!arguments.length) { return _resetFilterHandler; } _resetFilterHandler = resetFilterHandler; return _chart; }; function applyFilters (filters) { if (_chart.dimension() && _chart.dimension().filter) { var fs = _filterHandler(_chart.dimension(), filters); if (fs) { filters = fs; } } return filters; } /** * Replace the chart filter. This is equivalent to calling `chart.filter(null).filter(filter)` * but more efficient because the filter is only applied once. * * @method replaceFilter * @memberof dc.baseMixin * @instance * @param {*} [filter] * @returns {dc.baseMixin} **/ _chart.replaceFilter = function (filter) { _filters = _resetFilterHandler(_filters); _chart.filter(filter); return _chart; }; /** * Filter the chart by the given parameter, or return the current filter if no input parameter * is given. * * The filter parameter can take one of these forms: * * A single value: the value will be toggled (added if it is not present in the current * filters, removed if it is present) * * An array containing a single array of values (`[[value,value,value]]`): each value is * toggled * * When appropriate for the chart, a {@link dc.filters dc filter object} such as * * {@link dc.filters.RangedFilter `dc.filters.RangedFilter`} for the * {@link dc.coordinateGridMixin dc.coordinateGridMixin} charts * * {@link dc.filters.TwoDimensionalFilter `dc.filters.TwoDimensionalFilter`} for the * {@link dc.heatMap heat map} * * {@link dc.filters.RangedTwoDimensionalFilter `dc.filters.RangedTwoDimensionalFilter`} * for the {@link dc.scatterPlot scatter plot} * * `null`: the filter will be reset using the * {@link dc.baseMixin#resetFilterHandler resetFilterHandler} * * Note that this is always a toggle (even when it doesn't make sense for the filter type). If * you wish to replace the current filter, either call `chart.filter(null)` first - or it's more * efficient to call {@link dc.baseMixin#replaceFilter `chart.replaceFilter(filter)`} instead. * * Each toggle is executed by checking if the value is already present using the * {@link dc.baseMixin#hasFilterHandler hasFilterHandler}; if it is not present, it is added * using the {@link dc.baseMixin#addFilterHandler addFilterHandler}; if it is already present, * it is removed using the {@link dc.baseMixin#removeFilterHandler removeFilterHandler}. * * Once the filters array has been updated, the filters are applied to the * crossfilter dimension, using the {@link dc.baseMixin#filterHandler filterHandler}. * * Once you have set the filters, call {@link dc.baseMixin#redrawGroup `chart.redrawGroup()`} * (or {@link dc.redrawAll `dc.redrawAll()`}) to redraw the chart's group. * @method filter * @memberof dc.baseMixin * @instance * @see {@link dc.baseMixin#addFilterHandler addFilterHandler} * @see {@link dc.baseMixin#removeFilterHandler removeFilterHandler} * @see {@link dc.baseMixin#resetFilterHandler resetFilterHandler} * @see {@link dc.baseMixin#filterHandler filterHandler} * @example * // filter by a single string * chart.filter('Sunday'); * // filter by a single age * chart.filter(18); * // filter by a set of states * chart.filter([['MA', 'TX', 'ND', 'WA']]); * // filter by range -- note the use of dc.filters.RangedFilter, which is different * // from the syntax for filtering a crossfilter dimension directly, dimension.filter([15,20]) * chart.filter(dc.filters.RangedFilter(15,20)); * @param {*} [filter] * @returns {dc.baseMixin} */ _chart.filter = function (filter) { if (!arguments.length) { return _filters.length > 0 ? _filters[0] : null; } var filters = _filters; if (filter instanceof Array && filter[0] instanceof Array && !filter.isFiltered) { // toggle each filter filter[0].forEach(function (f) { if (_hasFilterHandler(filters, f)) { filters = _removeFilterHandler(filters, f); } else { filters = _addFilterHandler(filters, f); } }); } else if (filter === null) { filters = _resetFilterHandler(filters); } else { if (_hasFilterHandler(filters, filter)) { filters = _removeFilterHandler(filters, filter); } else { filters = _addFilterHandler(filters, filter); } } _filters = applyFilters(filters); _chart._invokeFilteredListener(filter); if (_root !== null && _chart.hasFilter()) { _chart.turnOnControls(); } else { _chart.turnOffControls(); } return _chart; }; /** * Returns all current filters. This method does not perform defensive cloning of the internal * filter array before returning, therefore any modification of the returned array will effect the * chart's internal filter storage. * @method filters * @memberof dc.baseMixin * @instance * @returns {Array<*>} */ _chart.filters = function () { return _filters; }; _chart.highlightSelected = function (e) { d3.select(e).classed(dc.constants.SELECTED_CLASS, true); d3.select(e).classed(dc.constants.DESELECTED_CLASS, false); }; _chart.fadeDeselected = function (e) { d3.select(e).classed(dc.constants.SELECTED_CLASS, false); d3.select(e).classed(dc.constants.DESELECTED_CLASS, true); }; _chart.resetHighlight = function (e) { d3.select(e).classed(dc.constants.SELECTED_CLASS, false); d3.select(e).classed(dc.constants.DESELECTED_CLASS, false); }; /** * This function is passed to d3 as the onClick handler for each chart. The default behavior is to * filter on the clicked datum (passed to the callback) and redraw the chart group. * @method onClick * @memberof dc.baseMixin * @instance * @param {*} datum */ _chart.onClick = function (datum) { var filter = _chart.keyAccessor()(datum); dc.events.trigger(function () { _chart.filter(filter); _chart.redrawGroup(); }); }; /** * Set or get the filter handler. The filter handler is a function that performs the filter action * on a specific dimension. Using a custom filter handler allows you to perform additional logic * before or after filtering. * @method filterHandler * @memberof dc.baseMixin * @instance * @see {@link https://github.com/crossfilter/crossfilter/wiki/API-Reference#dimension_filter crossfilter.dimension.filter} * @example * // the default filter handler handles all possible cases for the charts in dc.js * // you can replace it with something more specialized for your own chart * chart.filterHandler(function (dimension, filters) { * if (filters.length === 0) { * // the empty case (no filtering) * dimension.filter(null); * } else if (filters.length === 1 && !filters[0].isFiltered) { * // single value and not a function-based filter * dimension.filterExact(filters[0]); * } else if (filters.length === 1 && filters[0].filterType === 'RangedFilter') { * // single range-based filter * dimension.filterRange(filters[0]); * } else { * // an array of values, or an array of filter objects * dimension.filterFunction(function (d) { * for (var i = 0; i < filters.length; i++) { * var filter = filters[i]; * if (filter.isFiltered && filter.isFiltered(d)) { * return true; * } else if (filter <= d && filter >= d) { * return true; * } * } * return false; * }); * } * return filters; * }); * * // custom filter handler * chart.filterHandler(function(dimension, filter){ * var newFilter = filter + 10; * dimension.filter(newFilter); * return newFilter; // set the actual filter value to the new value * }); * @param {Function} [filterHandler] * @returns {Function|dc.baseMixin} */ _chart.filterHandler = function (filterHandler) { if (!arguments.length) { return _filterHandler; } _filterHandler = filterHandler; return _chart; }; // abstract function stub _chart._doRender = function () { // do nothing in base, should be overridden by sub-function return _chart; }; _chart._doRedraw = function () { // do nothing in base, should be overridden by sub-function return _chart; }; _chart.legendables = function () { // do nothing in base, should be overridden by sub-function return []; }; _chart.legendHighlight = function () { // do nothing in base, should be overridden by sub-function }; _chart.legendReset = function () { // do nothing in base, should be overridden by sub-function }; _chart.legendToggle = function () { // do nothing in base, should be overriden by sub-function }; _chart.isLegendableHidden = function () { // do nothing in base, should be overridden by sub-function return false; }; /** * Set or get the key accessor function. The key accessor function is used to retrieve the key * value from the crossfilter group. Key values are used differently in different charts, for * example keys correspond to slices in a pie chart and x axis positions in a grid coordinate chart. * @method keyAccessor * @memberof dc.baseMixin * @instance * @example * // default key accessor * chart.keyAccessor(function(d) { return d.key; }); * // custom key accessor for a multi-value crossfilter reduction * chart.keyAccessor(function(p) { return p.value.absGain; }); * @param {Function} [keyAccessor] * @returns {Function|dc.baseMixin} */ _chart.keyAccessor = function (keyAccessor) { if (!arguments.length) { return _keyAccessor; } _keyAccessor = keyAccessor; return _chart; }; /** * Set or get the value accessor function. The value accessor function is used to retrieve the * value from the crossfilter group. Group values are used differently in different charts, for * example values correspond to slice sizes in a pie chart and y axis positions in a grid * coordinate chart. * @method valueAccessor * @memberof dc.baseMixin * @instance * @example * // default value accessor * chart.valueAccessor(function(d) { return d.value; }); * // custom value accessor for a multi-value crossfilter reduction * chart.valueAccessor(function(p) { return p.value.percentageGain; }); * @param {Function} [valueAccessor] * @returns {Function|dc.baseMixin} */ _chart.valueAccessor = function (valueAccessor) { if (!arguments.length) { return _valueAccessor; } _valueAccessor = valueAccessor; return _chart; }; /** * Set or get the label function. The chart class will use this function to render labels for each * child element in the chart, e.g. slices in a pie chart or bubbles in a bubble chart. Not every * chart supports the label function, for example line chart does not use this function * at all. By default, enables labels; pass false for the second parameter if this is not desired. * @method label * @memberof dc.baseMixin * @instance * @example * // default label function just return the key * chart.label(function(d) { return d.key; }); * // label function has access to the standard d3 data binding and can get quite complicated * chart.label(function(d) { return d.data.key + '(' + Math.floor(d.data.value / all.value() * 100) + '%)'; }); * @param {Function} [labelFunction] * @param {Boolean} [enableLabels=true] * @returns {Function|dc.baseMixin} */ _chart.label = function (labelFunction, enableLabels) { if (!arguments.length) { return _label; } _label = labelFunction; if ((enableLabels === undefined) || enableLabels) { _renderLabel = true; } return _chart; }; /** * Turn on/off label rendering * @method renderLabel * @memberof dc.baseMixin * @instance * @param {Boolean} [renderLabel=false] * @returns {Boolean|dc.baseMixin} */ _chart.renderLabel = function (renderLabel) { if (!arguments.length) { return _renderLabel; } _renderLabel = renderLabel; return _chart; }; /** * Set or get the title function. The chart class will use this function to render the SVGElement title * (usually interpreted by browser as tooltips) for each child element in the chart, e.g. a slice * in a pie chart or a bubble in a bubble chart. Almost every chart supports the title function; * however in grid coordinate charts you need to turn off the brush in order to see titles, because * otherwise the brush layer will block tooltip triggering. * @method title * @memberof dc.baseMixin * @instance * @example * // default title function shows "key: value" * chart.title(function(d) { return d.key + ': ' + d.value; }); * // title function has access to the standard d3 data binding and can get quite complicated * chart.title(function(p) { * return p.key.getFullYear() * + '\n' * + 'Index Gain: ' + numberFormat(p.value.absGain) + '\n' * + 'Index Gain in Percentage: ' + numberFormat(p.value.percentageGain) + '%\n' * + 'Fluctuation / Index Ratio: ' + numberFormat(p.value.fluctuationPercentage) + '%'; * }); * @param {Function} [titleFunction] * @returns {Function|dc.baseMixin} */ _chart.title = function (titleFunction) { if (!arguments.length) { return _title; } _title = titleFunction; return _chart; }; /** * Turn on/off title rendering, or return the state of the render title flag if no arguments are * given. * @method renderTitle * @memberof dc.baseMixin * @instance * @param {Boolean} [renderTitle=true] * @returns {Boolean|dc.baseMixin} */ _chart.renderTitle = function (renderTitle) { if (!arguments.length) { return _renderTitle; } _renderTitle = renderTitle; return _chart; }; /** * A renderlet is similar to an event listener on rendering event. Multiple renderlets can be added * to an individual chart. Each time a chart is rerendered or redrawn the renderlets are invoked * right after the chart finishes its transitions, giving you a way to modify the SVGElements. * Renderlet functions take the chart instance as the only input parameter and you can * use the dc API or use raw d3 to achieve pretty much any effect. * * Use {@link dc.baseMixin#on on} with a 'renderlet' prefix. * Generates a random key for the renderlet, which makes it hard to remove. * @method renderlet * @memberof dc.baseMixin * @instance * @deprecated * @example * // do this instead of .renderlet(function(chart) { ... }) * chart.on("renderlet", function(chart){ * // mix of dc API and d3 manipulation * chart.select('g.y').style('display', 'none'); * // its a closure so you can also access other chart variable available in the closure scope * moveChart.filter(chart.filter()); * }); * @param {Function} renderletFunction * @returns {dc.baseMixin} */ _chart.renderlet = dc.logger.deprecate(function (renderletFunction) { _chart.on('renderlet.' + dc.utils.uniqueId(), renderletFunction); return _chart; }, 'chart.renderlet has been deprecated. Please use chart.on("renderlet.", renderletFunction)'); /** * Get or set the chart group to which this chart belongs. Chart groups are rendered or redrawn * together since it is expected they share the same underlying crossfilter data set. * @method chartGroup * @memberof dc.baseMixin * @instance * @param {String} [chartGroup] * @returns {String|dc.baseMixin} */ _chart.chartGroup = function (chartGroup) { if (!arguments.length) { return _chartGroup; } if (!_isChild) { dc.deregisterChart(_chart, _chartGroup); } _chartGroup = chartGroup; if (!_isChild) { dc.registerChart(_chart, _chartGroup); } return _chart; }; /** * Expire the internal chart cache. dc charts cache some data internally on a per chart basis to * speed up rendering and avoid unnecessary calculation; however it might be useful to clear the * cache if you have changed state which will affect rendering. For example, if you invoke * {@link https://github.com/crossfilter/crossfilter/wiki/API-Reference#crossfilter_add crossfilter.add} * function or reset group or dimension after rendering, it is a good idea to * clear the cache to make sure charts are rendered properly. * @method expireCache * @memberof dc.baseMixin * @instance * @returns {dc.baseMixin} */ _chart.expireCache = function () { // do nothing in base, should be overridden by sub-function return _chart; }; /** * Attach a dc.legend widget to this chart. The legend widget will automatically draw legend labels * based on the color setting and names associated with each group. * @method legend * @memberof dc.baseMixin * @instance * @example * chart.legend(dc.legend().x(400).y(10).itemHeight(13).gap(5)) * @param {dc.legend} [legend] * @returns {dc.legend|dc.baseMixin} */ _chart.legend = function (legend) { if (!arguments.length) { return _legend; } _legend = legend; _legend.parent(_chart); return _chart; }; /** * Returns the internal numeric ID of the chart. * @method chartID * @memberof dc.baseMixin * @instance * @returns {String} */ _chart.chartID = function () { return _chart.__dcFlag__; }; /** * Set chart options using a configuration object. Each key in the object will cause the method of * the same name to be called with the value to set that attribute for the chart. * @method options * @memberof dc.baseMixin * @instance * @example * chart.options({dimension: myDimension, group: myGroup}); * @param {{}} opts * @returns {dc.baseMixin} */ _chart.options = function (opts) { var applyOptions = [ 'anchor', 'group', 'xAxisLabel', 'yAxisLabel', 'stack', 'title', 'point', 'getColor', 'overlayGeoJson' ]; for (var o in opts) { if (typeof(_chart[o]) === 'function') { if (opts[o] instanceof Array && applyOptions.indexOf(o) !== -1) { _chart[o].apply(_chart, opts[o]); } else { _chart[o].call(_chart, opts[o]); } } else { dc.logger.debug('Not a valid option setter name: ' + o); } } return _chart; }; /** * All dc chart instance supports the following listeners. * Supports the following events: * * `renderlet` - This listener function will be invoked after transitions after redraw and render. Replaces the * deprecated {@link dc.baseMixin#renderlet renderlet} method. * * `pretransition` - Like `.on('renderlet', ...)` but the event is fired before transitions start. * * `preRender` - This listener function will be invoked before chart rendering. * * `postRender` - This listener function will be invoked after chart finish rendering including * all renderlets' logic. * * `preRedraw` - This listener function will be invoked before chart redrawing. * * `postRedraw` - This listener function will be invoked after chart finish redrawing * including all renderlets' logic. * * `filtered` - This listener function will be invoked after a filter is applied, added or removed. * * `zoomed` - This listener function will be invoked after a zoom is triggered. * @method on * @memberof dc.baseMixin * @instance * @see {@link https://github.com/d3/d3-dispatch/blob/master/README.md#dispatch_on d3.dispatch.on} * @example * .on('renderlet', function(chart, filter){...}) * .on('pretransition', function(chart, filter){...}) * .on('preRender', function(chart){...}) * .on('postRender', function(chart){...}) * .on('preRedraw', function(chart){...}) * .on('postRedraw', function(chart){...}) * .on('filtered', function(chart, filter){...}) * .on('zoomed', function(chart, filter){...}) * @param {String} event * @param {Function} listener * @returns {dc.baseMixin} */ _chart.on = function (event, listener) { _listeners.on(event, listener); return _chart; }; return _chart; }; /** * Margin is a mixin that provides margin utility functions for both the Row Chart and Coordinate Grid * Charts. * @name marginMixin * @memberof dc * @mixin * @param {Object} _chart * @returns {dc.marginMixin} */ dc.marginMixin = function (_chart) { var _margin = {top: 10, right: 50, bottom: 30, left: 30}; /** * Get or set the margins for a particular coordinate grid chart instance. The margins is stored as * an associative Javascript array. * @method margins * @memberof dc.marginMixin * @instance * @example * var leftMargin = chart.margins().left; // 30 by default * chart.margins().left = 50; * leftMargin = chart.margins().left; // now 50 * @param {{top: Number, right: Number, left: Number, bottom: Number}} [margins={top: 10, right: 50, bottom: 30, left: 30}] * @returns {{top: Number, right: Number, left: Number, bottom: Number}|dc.marginMixin} */ _chart.margins = function (margins) { if (!arguments.length) { return _margin; } _margin = margins; return _chart; }; _chart.effectiveWidth = function () { return _chart.width() - _chart.margins().left - _chart.margins().right; }; _chart.effectiveHeight = function () { return _chart.height() - _chart.margins().top - _chart.margins().bottom; }; return _chart; }; /** * The Color Mixin is an abstract chart functional class providing universal coloring support * as a mix-in for any concrete chart implementation. * @name colorMixin * @memberof dc * @mixin * @param {Object} _chart * @returns {dc.colorMixin} */ dc.colorMixin = function (_chart) { var _colors = d3.scaleOrdinal(dc.config.defaultColors()); var _defaultAccessor = true; var _colorAccessor = function (d) { return _chart.keyAccessor()(d); }; /** * Retrieve current color scale or set a new color scale. This methods accepts any function that * operates like a d3 scale. * @method colors * @memberof dc.colorMixin * @instance * @see {@link https://github.com/d3/d3-scale/blob/master/README.md d3.scale} * @example * // alternate categorical scale * chart.colors(d3.scale.category20b()); * // ordinal scale * chart.colors(d3.scaleOrdinal().range(['red','green','blue'])); * // convenience method, the same as above * chart.ordinalColors(['red','green','blue']); * // set a linear scale * chart.linearColors(["#4575b4", "#ffffbf", "#a50026"]); * @param {d3.scale} [colorScale=d3.scaleOrdinal(d3.schemeCategory20c)] * @returns {d3.scale|dc.colorMixin} */ _chart.colors = function (colorScale) { if (!arguments.length) { return _colors; } if (colorScale instanceof Array) { _colors = d3.scaleQuantize().range(colorScale); // deprecated legacy support, note: this fails for ordinal domains } else { _colors = typeof colorScale === 'function' ? colorScale : dc.utils.constant(colorScale); } return _chart; }; /** * Convenience method to set the color scale to * {@link https://github.com/d3/d3-scale/blob/master/README.md#ordinal-scales d3.scaleOrdinal} with * range `r`. * @method ordinalColors * @memberof dc.colorMixin * @instance * @param {Array} r * @returns {dc.colorMixin} */ _chart.ordinalColors = function (r) { return _chart.colors(d3.scaleOrdinal().range(r)); }; /** * Convenience method to set the color scale to an Hcl interpolated linear scale with range `r`. * @method linearColors * @memberof dc.colorMixin * @instance * @param {Array} r * @returns {dc.colorMixin} */ _chart.linearColors = function (r) { return _chart.colors(d3.scaleLinear() .range(r) .interpolate(d3.interpolateHcl)); }; /** * Set or the get color accessor function. This function will be used to map a data point in a * crossfilter group to a color value on the color scale. The default function uses the key * accessor. * @method colorAccessor * @memberof dc.colorMixin * @instance * @example * // default index based color accessor * .colorAccessor(function (d, i){return i;}) * // color accessor for a multi-value crossfilter reduction * .colorAccessor(function (d){return d.value.absGain;}) * @param {Function} [colorAccessor] * @returns {Function|dc.colorMixin} */ _chart.colorAccessor = function (colorAccessor) { if (!arguments.length) { return _colorAccessor; } _colorAccessor = colorAccessor; _defaultAccessor = false; return _chart; }; // what is this? _chart.defaultColorAccessor = function () { return _defaultAccessor; }; /** * Set or get the current domain for the color mapping function. The domain must be supplied as an * array. * * Note: previously this method accepted a callback function. Instead you may use a custom scale * set by {@link dc.colorMixin#colors .colors}. * @method colorDomain * @memberof dc.colorMixin * @instance * @param {Array} [domain] * @returns {Array|dc.colorMixin} */ _chart.colorDomain = function (domain) { if (!arguments.length) { return _colors.domain(); } _colors.domain(domain); return _chart; }; /** * Set the domain by determining the min and max values as retrieved by * {@link dc.colorMixin#colorAccessor .colorAccessor} over the chart's dataset. * @method calculateColorDomain * @memberof dc.colorMixin * @instance * @returns {dc.colorMixin} */ _chart.calculateColorDomain = function () { var newDomain = [d3.min(_chart.data(), _chart.colorAccessor()), d3.max(_chart.data(), _chart.colorAccessor())]; _colors.domain(newDomain); return _chart; }; /** * Get the color for the datum d and counter i. This is used internally by charts to retrieve a color. * @method getColor * @memberof dc.colorMixin * @instance * @param {*} d * @param {Number} [i] * @returns {String} */ _chart.getColor = function (d, i) { return _colors(_colorAccessor.call(this, d, i)); }; /** * **Deprecated.** Get/set the color calculator. This actually replaces the * {@link dc.colorMixin#getColor getColor} method! * * This is not recommended, since using a {@link dc.colorMixin#colorAccessor colorAccessor} and * color scale ({@link dc.colorMixin#colors .colors}) is more powerful and idiomatic d3. * @method colorCalculator * @memberof dc.colorMixin * @instance * @param {*} [colorCalculator] * @returns {Function|dc.colorMixin} */ _chart.colorCalculator = dc.logger.deprecate(function (colorCalculator) { if (!arguments.length) { return _chart.getColor; } _chart.getColor = colorCalculator; return _chart; }, 'colorMixin.colorCalculator has been deprecated. Please colorMixin.colors and colorMixin.colorAccessor instead'); return _chart; }; /** * Coordinate Grid is an abstract base chart designed to support a number of coordinate grid based * concrete chart types, e.g. bar chart, line chart, and bubble chart. * @name coordinateGridMixin * @memberof dc * @mixin * @mixes dc.colorMixin * @mixes dc.marginMixin * @mixes dc.baseMixin * @param {Object} _chart * @returns {dc.coordinateGridMixin} */ dc.coordinateGridMixin = function (_chart) { var GRID_LINE_CLASS = 'grid-line'; var HORIZONTAL_CLASS = 'horizontal'; var VERTICAL_CLASS = 'vertical'; var Y_AXIS_LABEL_CLASS = 'y-axis-label'; var X_AXIS_LABEL_CLASS = 'x-axis-label'; var CUSTOM_BRUSH_HANDLE_CLASS = 'custom-brush-handle'; var DEFAULT_AXIS_LABEL_PADDING = 12; _chart = dc.colorMixin(dc.marginMixin(dc.baseMixin(_chart))); _chart.colors(d3.scaleOrdinal(d3.schemeCategory10)); _chart._mandatoryAttributes().push('x'); var _parent; var _g; var _chartBodyG; var _x; var _origX; // Will hold orginial scale in case of zoom var _xOriginalDomain; var _xAxis = d3.axisBottom(); var _xUnits = dc.units.integers; var _xAxisPadding = 0; var _xAxisPaddingUnit = d3.timeDay; var _xElasticity = false; var _xAxisLabel; var _xAxisLabelPadding = 0; var _lastXDomain; var _y; var _yAxis = null; var _yAxisPadding = 0; var _yElasticity = false; var _yAxisLabel; var _yAxisLabelPadding = 0; var _brush = d3.brushX(); var _gBrush; var _brushOn = true; var _parentBrushOn = false; var _round; var _renderHorizontalGridLine = false; var _renderVerticalGridLine = false; var _resizing = false; var _unitCount; var _zoomScale = [1, Infinity]; var _zoomOutRestrict = true; var _zoom = d3.zoom().on('zoom', onZoom); var _nullZoom = d3.zoom().on('zoom', null); var _hasBeenMouseZoomable = false; var _rangeChart; var _focusChart; var _mouseZoomable = false; var _clipPadding = 0; var _outerRangeBandPadding = 0.5; var _rangeBandPadding = 0; var _useRightYAxis = false; /** * When changing the domain of the x or y scale, it is necessary to tell the chart to recalculate * and redraw the axes. (`.rescale()` is called automatically when the x or y scale is replaced * with {@link dc.coordinateGridMixin+x .x()} or {@link dc.coordinateGridMixin#y .y()}, and has * no effect on elastic scales.) * @method rescale * @memberof dc.coordinateGridMixin * @instance * @returns {dc.coordinateGridMixin} */ _chart.rescale = function () { _unitCount = undefined; _resizing = true; return _chart; }; _chart.resizing = function () { return _resizing; }; /** * Get or set the range selection chart associated with this instance. Setting the range selection * chart using this function will automatically update its selection brush when the current chart * zooms in. In return the given range chart will also automatically attach this chart as its focus * chart hence zoom in when range brush updates. * * Usually the range and focus charts will share a dimension. The range chart will set the zoom * boundaries for the focus chart, so its dimension values must be compatible with the domain of * the focus chart. * * See the [Nasdaq 100 Index](http://dc-js.github.com/dc.js/) example for this effect in action. * @method rangeChart * @memberof dc.coordinateGridMixin * @instance * @param {dc.coordinateGridMixin} [rangeChart] * @returns {dc.coordinateGridMixin} */ _chart.rangeChart = function (rangeChart) { if (!arguments.length) { return _rangeChart; } _rangeChart = rangeChart; _rangeChart.focusChart(_chart); return _chart; }; /** * Get or set the scale extent for mouse zooms. * @method zoomScale * @memberof dc.coordinateGridMixin * @instance * @param {Array} [extent=[1, Infinity]] * @returns {Array|dc.coordinateGridMixin} */ _chart.zoomScale = function (extent) { if (!arguments.length) { return _zoomScale; } _zoomScale = extent; return _chart; }; /** * Get or set the zoom restriction for the chart. If true limits the zoom to origional domain of the chart. * @method zoomOutRestrict * @memberof dc.coordinateGridMixin * @instance * @param {Boolean} [zoomOutRestrict=true] * @returns {Boolean|dc.coordinateGridMixin} */ _chart.zoomOutRestrict = function (zoomOutRestrict) { if (!arguments.length) { return _zoomOutRestrict; } _zoomOutRestrict = zoomOutRestrict; return _chart; }; _chart._generateG = function (parent) { if (parent === undefined) { _parent = _chart.svg(); } else { _parent = parent; } var href = window.location.href.split('#')[0]; _g = _parent.append('g'); _chartBodyG = _g.append('g').attr('class', 'chart-body') .attr('transform', 'translate(' + _chart.margins().left + ', ' + _chart.margins().top + ')') .attr('clip-path', 'url(' + href + '#' + getClipPathId() + ')'); return _g; }; /** * Get or set the root g element. This method is usually used to retrieve the g element in order to * overlay custom svg drawing programatically. **Caution**: The root g element is usually generated * by dc.js internals, and resetting it might produce unpredictable result. * @method g * @memberof dc.coordinateGridMixin * @instance * @param {SVGElement} [gElement] * @returns {SVGElement|dc.coordinateGridMixin} */ _chart.g = function (gElement) { if (!arguments.length) { return _g; } _g = gElement; return _chart; }; /** * Set or get mouse zoom capability flag (default: false). When turned on the chart will be * zoomable using the mouse wheel. If the range selector chart is attached zooming will also update * the range selection brush on the associated range selector chart. * @method mouseZoomable * @memberof dc.coordinateGridMixin * @instance * @param {Boolean} [mouseZoomable=false] * @returns {Boolean|dc.coordinateGridMixin} */ _chart.mouseZoomable = function (mouseZoomable) { if (!arguments.length) { return _mouseZoomable; } _mouseZoomable = mouseZoomable; return _chart; }; /** * Retrieve the svg group for the chart body. * @method chartBodyG * @memberof dc.coordinateGridMixin * @instance * @param {SVGElement} [chartBodyG] * @returns {SVGElement} */ _chart.chartBodyG = function (chartBodyG) { if (!arguments.length) { return _chartBodyG; } _chartBodyG = chartBodyG; return _chart; }; /** * **mandatory** * * Get or set the x scale. The x scale can be any d3 * {@link https://github.com/d3/d3-scale/blob/master/README.md d3.scale} or * {@link https://github.com/d3/d3-scale/blob/master/README.md#ordinal-scales ordinal scale} * @method x * @memberof dc.coordinateGridMixin * @instance * @see {@link https://github.com/d3/d3-scale/blob/master/README.md d3.scale} * @example * // set x to a linear scale * chart.x(d3.scaleLinear().domain([-2500, 2500])) * // set x to a time scale to generate histogram * chart.x(d3.scaleTime().domain([new Date(1985, 0, 1), new Date(2012, 11, 31)])) * @param {d3.scale} [xScale] * @returns {d3.scale|dc.coordinateGridMixin} */ _chart.x = function (xScale) { if (!arguments.length) { return _x; } _x = xScale; _xOriginalDomain = _x.domain(); _chart.rescale(); return _chart; }; _chart.xOriginalDomain = function () { return _xOriginalDomain; }; /** * Set or get the xUnits function. The coordinate grid chart uses the xUnits function to calculate * the number of data projections on the x axis such as the number of bars for a bar chart or the * number of dots for a line chart. * * This function is expected to return a Javascript array of all data points on the x axis, or * the number of points on the axis. d3 time range functions [d3.timeDays, d3.timeMonths, and * d3.timeYears](https://github.com/d3/d3-time/blob/master/README.md#intervals) are all valid * xUnits functions. * * dc.js also provides a few units function, see the {@link dc.units Units Namespace} for * a list of built-in units functions. * * Note that as of dc.js 3.0, `dc.units.ordinal` is not a real function, because it is not * possible to define this function compliant with the d3 range functions. It was already a * magic value which caused charts to behave differently, and now it is completely so. * @method xUnits * @memberof dc.coordinateGridMixin * @instance * @example * // set x units to count days * chart.xUnits(d3.timeDays); * // set x units to count months * chart.xUnits(d3.timeMonths); * * // A custom xUnits function can be used as long as it follows the following interface: * // units in integer * function(start, end) { * // simply calculates how many integers in the domain * return Math.abs(end - start); * } * * // fixed units * function(start, end) { * // be aware using fixed units will disable the focus/zoom ability on the chart * return 1000; * } * @param {Function} [xUnits=dc.units.integers] * @returns {Function|dc.coordinateGridMixin} */ _chart.xUnits = function (xUnits) { if (!arguments.length) { return _xUnits; } _xUnits = xUnits; return _chart; }; /** * Set or get the x axis used by a particular coordinate grid chart instance. This function is most * useful when x axis customization is required. The x axis in dc.js is an instance of a * {@link https://github.com/d3/d3-axis/blob/master/README.md#axisBottom d3 bottom axis object}; * therefore it supports any valid d3 axisBottom manipulation. * * **Caution**: The x axis is usually generated internally by dc; resetting it may cause * unexpected results. Note also that when used as a getter, this function is not chainable: * it returns the axis, not the chart, * {@link https://github.com/dc-js/dc.js/wiki/FAQ#why-does-everything-break-after-a-call-to-xaxis-or-yaxis * so attempting to call chart functions after calling `.xAxis()` will fail}. * @method xAxis * @memberof dc.coordinateGridMixin * @instance * @see {@link https://github.com/d3/d3-axis/blob/master/README.md#axisBottom d3.axisBottom} * @example * // customize x axis tick format * chart.xAxis().tickFormat(function(v) {return v + '%';}); * // customize x axis tick values * chart.xAxis().tickValues([0, 100, 200, 300]); * @param {d3.axis} [xAxis=d3.axisBottom()] * @returns {d3.axis|dc.coordinateGridMixin} */ _chart.xAxis = function (xAxis) { if (!arguments.length) { return _xAxis; } _xAxis = xAxis; return _chart; }; /** * Turn on/off elastic x axis behavior. If x axis elasticity is turned on, then the grid chart will * attempt to recalculate the x axis range whenever a redraw event is triggered. * @method elasticX * @memberof dc.coordinateGridMixin * @instance * @param {Boolean} [elasticX=false] * @returns {Boolean|dc.coordinateGridMixin} */ _chart.elasticX = function (elasticX) { if (!arguments.length) { return _xElasticity; } _xElasticity = elasticX; return _chart; }; /** * Set or get x axis padding for the elastic x axis. The padding will be added to both end of the x * axis if elasticX is turned on; otherwise it is ignored. * * Padding can be an integer or percentage in string (e.g. '10%'). Padding can be applied to * number or date x axes. When padding a date axis, an integer represents number of units being padded * and a percentage string will be treated the same as an integer. The unit will be determined by the * xAxisPaddingUnit variable. * @method xAxisPadding * @memberof dc.coordinateGridMixin * @instance * @param {Number|String} [padding=0] * @returns {Number|String|dc.coordinateGridMixin} */ _chart.xAxisPadding = function (padding) { if (!arguments.length) { return _xAxisPadding; } _xAxisPadding = padding; return _chart; }; /** * Set or get x axis padding unit for the elastic x axis. The padding unit will determine which unit to * use when applying xAxis padding if elasticX is turned on and if x-axis uses a time dimension; * otherwise it is ignored. * * The padding unit should be a * [d3 time interval](https://github.com/d3/d3-time/blob/master/README.md#_interval). * For backward compatibility with dc.js 2.0, it can also be the name of a d3 time interval * ('day', 'hour', etc). Available arguments are the * [d3 time intervals](https://github.com/d3/d3-time/blob/master/README.md#intervals d3.timeInterval). * @method xAxisPaddingUnit * @memberof dc.coordinateGridMixin * @instance * @param {String} [unit=d3.timeDay] * @returns {String|dc.coordinateGridMixin} */ _chart.xAxisPaddingUnit = function (unit) { if (!arguments.length) { return _xAxisPaddingUnit; } _xAxisPaddingUnit = unit; return _chart; }; /** * Returns the number of units displayed on the x axis. If the x axis is ordinal (`xUnits` is * `dc.units.ordinal`), this is the number of items in the domain of the x scale. Otherwise, the * x unit count is calculated using the {@link dc.coordinateGridMixin#xUnits xUnits} function. * @method xUnitCount * @memberof dc.coordinateGridMixin * @instance * @returns {Number} */ _chart.xUnitCount = function () { if (_unitCount === undefined) { if (_chart.isOrdinal()) { // In this case it number of items in domain _unitCount = _chart.x().domain().length; } else { _unitCount = _chart.xUnits()(_chart.x().domain()[0], _chart.x().domain()[1]); // Sometimes xUnits() may return an array while sometimes directly the count if (_unitCount instanceof Array) { _unitCount = _unitCount.length; } } } return _unitCount; }; /** * Gets or sets whether the chart should be drawn with a right axis instead of a left axis. When * used with a chart in a composite chart, allows both left and right Y axes to be shown on a * chart. * @method useRightYAxis * @memberof dc.coordinateGridMixin * @instance * @param {Boolean} [useRightYAxis=false] * @returns {Boolean|dc.coordinateGridMixin} */ _chart.useRightYAxis = function (useRightYAxis) { if (!arguments.length) { return _useRightYAxis; } // We need to warn if value is changing after _yAxis was created if (_useRightYAxis !== useRightYAxis && _yAxis) { dc.logger.warn('Value of useRightYAxis has been altered, after yAxis was created. ' + 'You might get unexpected yAxis behavior. ' + 'Make calls to useRightYAxis sooner in your chart creation process.'); } _useRightYAxis = useRightYAxis; return _chart; }; /** * Returns true if the chart is using ordinal xUnits ({@link dc.units.ordinal dc.units.ordinal}, or false * otherwise. Most charts behave differently with ordinal data and use the result of this method to * trigger the appropriate logic. * @method isOrdinal * @memberof dc.coordinateGridMixin * @instance * @returns {Boolean} */ _chart.isOrdinal = function () { return _chart.xUnits() === dc.units.ordinal; }; _chart._useOuterPadding = function () { return true; }; _chart._ordinalXDomain = function () { var groups = _chart._computeOrderedGroups(_chart.data()); return groups.map(_chart.keyAccessor()); }; function prepareXAxis (g, render) { if (!_chart.isOrdinal()) { if (_chart.elasticX()) { _x.domain([_chart.xAxisMin(), _chart.xAxisMax()]); } } else { // _chart.isOrdinal() // D3v4 - Ordinal charts would need scaleBand // bandwidth is a method in scaleBand // (https://github.com/d3/d3-scale/blob/master/README.md#scaleBand) if (!_x.bandwidth) { // If _x is not a scaleBand create a new scale and // copy the original domain to the new scale dc.logger.warn('For compatibility with d3v4+, dc.js d3.0 ordinal bar/line/bubble charts need ' + 'd3.scaleBand() for the x scale, instead of d3.scaleOrdinal(). ' + 'Replacing .x() with a d3.scaleBand with the same domain - ' + 'make the same change in your code to avoid this warning!'); _x = d3.scaleBand().domain(_x.domain()); } if (_chart.elasticX() || _x.domain().length === 0) { _x.domain(_chart._ordinalXDomain()); } } // has the domain changed? var xdom = _x.domain(); if (render || !dc.utils.arraysEqual(_lastXDomain, xdom)) { _chart.rescale(); } _lastXDomain = xdom; // please can't we always use rangeBands for bar charts? if (_chart.isOrdinal()) { _x.range([0, _chart.xAxisLength()]) .paddingInner(_rangeBandPadding) .paddingOuter(_chart._useOuterPadding() ? _outerRangeBandPadding : 0); } else { _x.range([0, _chart.xAxisLength()]); } _xAxis = _xAxis.scale(_chart.x()); renderVerticalGridLines(g); } _chart.renderXAxis = function (g) { var axisXG = g.select('g.x'); if (axisXG.empty()) { axisXG = g.append('g') .attr('class', 'axis x') .attr('transform', 'translate(' + _chart.margins().left + ',' + _chart._xAxisY() + ')'); } var axisXLab = g.select('text.' + X_AXIS_LABEL_CLASS); if (axisXLab.empty() && _chart.xAxisLabel()) { axisXLab = g.append('text') .attr('class', X_AXIS_LABEL_CLASS) .attr('transform', 'translate(' + (_chart.margins().left + _chart.xAxisLength() / 2) + ',' + (_chart.height() - _xAxisLabelPadding) + ')') .attr('text-anchor', 'middle'); } if (_chart.xAxisLabel() && axisXLab.text() !== _chart.xAxisLabel()) { axisXLab.text(_chart.xAxisLabel()); } dc.transition(axisXG, _chart.transitionDuration(), _chart.transitionDelay()) .attr('transform', 'translate(' + _chart.margins().left + ',' + _chart._xAxisY() + ')') .call(_xAxis); dc.transition(axisXLab, _chart.transitionDuration(), _chart.transitionDelay()) .attr('transform', 'translate(' + (_chart.margins().left + _chart.xAxisLength() / 2) + ',' + (_chart.height() - _xAxisLabelPadding) + ')'); }; function renderVerticalGridLines (g) { var gridLineG = g.select('g.' + VERTICAL_CLASS); if (_renderVerticalGridLine) { if (gridLineG.empty()) { gridLineG = g.insert('g', ':first-child') .attr('class', GRID_LINE_CLASS + ' ' + VERTICAL_CLASS) .attr('transform', 'translate(' + _chart.margins().left + ',' + _chart.margins().top + ')'); } var ticks = _xAxis.tickValues() ? _xAxis.tickValues() : (typeof _x.ticks === 'function' ? _x.ticks.apply(_x, _xAxis.tickArguments()) : _x.domain()); var lines = gridLineG.selectAll('line') .data(ticks); // enter var linesGEnter = lines.enter() .append('line') .attr('x1', function (d) { return _x(d); }) .attr('y1', _chart._xAxisY() - _chart.margins().top) .attr('x2', function (d) { return _x(d); }) .attr('y2', 0) .attr('opacity', 0); dc.transition(linesGEnter, _chart.transitionDuration(), _chart.transitionDelay()) .attr('opacity', 1); // update var linesGEnterUpdate = linesGEnter.merge(lines); dc.transition(linesGEnterUpdate, _chart.transitionDuration(), _chart.transitionDelay()) .attr('x1', function (d) { return _x(d); }) .attr('y1', _chart._xAxisY() - _chart.margins().top) .attr('x2', function (d) { return _x(d); }) .attr('y2', 0); // exit lines.exit().remove(); } else { gridLineG.selectAll('line').remove(); } } _chart._xAxisY = function () { return (_chart.height() - _chart.margins().bottom); }; _chart.xAxisLength = function () { return _chart.effectiveWidth(); }; /** * Set or get the x axis label. If setting the label, you may optionally include additional padding to * the margin to make room for the label. By default the padded is set to 12 to accomodate the text height. * @method xAxisLabel * @memberof dc.coordinateGridMixin * @instance * @param {String} [labelText] * @param {Number} [padding=12] * @returns {String} */ _chart.xAxisLabel = function (labelText, padding) { if (!arguments.length) { return _xAxisLabel; } _xAxisLabel = labelText; _chart.margins().bottom -= _xAxisLabelPadding; _xAxisLabelPadding = (padding === undefined) ? DEFAULT_AXIS_LABEL_PADDING : padding; _chart.margins().bottom += _xAxisLabelPadding; return _chart; }; function createYAxis () { return _useRightYAxis ? d3.axisRight() : d3.axisLeft(); } _chart._prepareYAxis = function (g) { if (_y === undefined || _chart.elasticY()) { if (_y === undefined) { _y = d3.scaleLinear(); } var min = _chart.yAxisMin() || 0, max = _chart.yAxisMax() || 0; _y.domain([min, max]).rangeRound([_chart.yAxisHeight(), 0]); } _y.range([_chart.yAxisHeight(), 0]); if (!_yAxis) { _yAxis = createYAxis(); } _yAxis.scale(_y); _chart._renderHorizontalGridLinesForAxis(g, _y, _yAxis); }; _chart.renderYAxisLabel = function (axisClass, text, rotation, labelXPosition) { labelXPosition = labelXPosition || _yAxisLabelPadding; var axisYLab = _chart.g().select('text.' + Y_AXIS_LABEL_CLASS + '.' + axisClass + '-label'); var labelYPosition = (_chart.margins().top + _chart.yAxisHeight() / 2); if (axisYLab.empty() && text) { axisYLab = _chart.g().append('text') .attr('transform', 'translate(' + labelXPosition + ',' + labelYPosition + '),rotate(' + rotation + ')') .attr('class', Y_AXIS_LABEL_CLASS + ' ' + axisClass + '-label') .attr('text-anchor', 'middle') .text(text); } if (text && axisYLab.text() !== text) { axisYLab.text(text); } dc.transition(axisYLab, _chart.transitionDuration(), _chart.transitionDelay()) .attr('transform', 'translate(' + labelXPosition + ',' + labelYPosition + '),rotate(' + rotation + ')'); }; _chart.renderYAxisAt = function (axisClass, axis, position) { var axisYG = _chart.g().select('g.' + axisClass); if (axisYG.empty()) { axisYG = _chart.g().append('g') .attr('class', 'axis ' + axisClass) .attr('transform', 'translate(' + position + ',' + _chart.margins().top + ')'); } dc.transition(axisYG, _chart.transitionDuration(), _chart.transitionDelay()) .attr('transform', 'translate(' + position + ',' + _chart.margins().top + ')') .call(axis); }; _chart.renderYAxis = function () { var axisPosition = _useRightYAxis ? (_chart.width() - _chart.margins().right) : _chart._yAxisX(); _chart.renderYAxisAt('y', _yAxis, axisPosition); var labelPosition = _useRightYAxis ? (_chart.width() - _yAxisLabelPadding) : _yAxisLabelPadding; var rotation = _useRightYAxis ? 90 : -90; _chart.renderYAxisLabel('y', _chart.yAxisLabel(), rotation, labelPosition); }; _chart._renderHorizontalGridLinesForAxis = function (g, scale, axis) { var gridLineG = g.select('g.' + HORIZONTAL_CLASS); if (_renderHorizontalGridLine) { // Last part copied from https://github.com/d3/d3-axis/blob/master/src/axis.js#L48 var ticks = axis.tickValues() ? axis.tickValues() : scale.ticks.apply(scale, axis.tickArguments()); if (gridLineG.empty()) { gridLineG = g.insert('g', ':first-child') .attr('class', GRID_LINE_CLASS + ' ' + HORIZONTAL_CLASS) .attr('transform', 'translate(' + _chart.margins().left + ',' + _chart.margins().top + ')'); } var lines = gridLineG.selectAll('line') .data(ticks); // enter var linesGEnter = lines.enter() .append('line') .attr('x1', 1) .attr('y1', function (d) { return scale(d); }) .attr('x2', _chart.xAxisLength()) .attr('y2', function (d) { return scale(d); }) .attr('opacity', 0); dc.transition(linesGEnter, _chart.transitionDuration(), _chart.transitionDelay()) .attr('opacity', 1); // update var linesGEnterUpdate = linesGEnter.merge(lines); dc.transition(linesGEnterUpdate, _chart.transitionDuration(), _chart.transitionDelay()) .attr('x1', 1) .attr('y1', function (d) { return scale(d); }) .attr('x2', _chart.xAxisLength()) .attr('y2', function (d) { return scale(d); }); // exit lines.exit().remove(); } else { gridLineG.selectAll('line').remove(); } }; _chart._yAxisX = function () { return _chart.useRightYAxis() ? _chart.width() - _chart.margins().right : _chart.margins().left; }; /** * Set or get the y axis label. If setting the label, you may optionally include additional padding * to the margin to make room for the label. By default the padding is set to 12 to accommodate the * text height. * @method yAxisLabel * @memberof dc.coordinateGridMixin * @instance * @param {String} [labelText] * @param {Number} [padding=12] * @returns {String|dc.coordinateGridMixin} */ _chart.yAxisLabel = function (labelText, padding) { if (!arguments.length) { return _yAxisLabel; } _yAxisLabel = labelText; _chart.margins().left -= _yAxisLabelPadding; _yAxisLabelPadding = (padding === undefined) ? DEFAULT_AXIS_LABEL_PADDING : padding; _chart.margins().left += _yAxisLabelPadding; return _chart; }; /** * Get or set the y scale. The y scale is typically automatically determined by the chart implementation. * @method y * @memberof dc.coordinateGridMixin * @instance * @see {@link https://github.com/d3/d3-scale/blob/master/README.md d3.scale} * @param {d3.scale} [yScale] * @returns {d3.scale|dc.coordinateGridMixin} */ _chart.y = function (yScale) { if (!arguments.length) { return _y; } _y = yScale; _chart.rescale(); return _chart; }; /** * Set or get the y axis used by the coordinate grid chart instance. This function is most useful * when y axis customization is required. Depending on `useRightYAxis` the y axis in dc.js is an instance of * either [d3.axisLeft](https://github.com/d3/d3-axis/blob/master/README.md#axisLeft) or * [d3.axisRight](https://github.com/d3/d3-axis/blob/master/README.md#axisRight); therefore it supports any * valid d3 axis manipulation. * * **Caution**: The y axis is usually generated internally by dc; resetting it may cause * unexpected results. Note also that when used as a getter, this function is not chainable: it * returns the axis, not the chart, * {@link https://github.com/dc-js/dc.js/wiki/FAQ#why-does-everything-break-after-a-call-to-xaxis-or-yaxis * so attempting to call chart functions after calling `.yAxis()` will fail}. * In addition, depending on whether you are going to use the axis on left or right * you need to appropriately pass [d3.axisLeft](https://github.com/d3/d3-axis/blob/master/README.md#axisLeft) * or [d3.axisRight](https://github.com/d3/d3-axis/blob/master/README.md#axisRight) * @method yAxis * @memberof dc.coordinateGridMixin * @instance * @see {@link https://github.com/d3/d3-axis/blob/master/README.md d3.axis} * @example * // customize y axis tick format * chart.yAxis().tickFormat(function(v) {return v + '%';}); * // customize y axis tick values * chart.yAxis().tickValues([0, 100, 200, 300]); * @param {d3.axisLeft|d3.axisRight} [yAxis] * @returns {d3.axisLeft|d3.axisRight|dc.coordinateGridMixin} */ _chart.yAxis = function (yAxis) { if (!arguments.length) { if (!_yAxis) { _yAxis = createYAxis(); } return _yAxis; } _yAxis = yAxis; return _chart; }; /** * Turn on/off elastic y axis behavior. If y axis elasticity is turned on, then the grid chart will * attempt to recalculate the y axis range whenever a redraw event is triggered. * @method elasticY * @memberof dc.coordinateGridMixin * @instance * @param {Boolean} [elasticY=false] * @returns {Boolean|dc.coordinateGridMixin} */ _chart.elasticY = function (elasticY) { if (!arguments.length) { return _yElasticity; } _yElasticity = elasticY; return _chart; }; /** * Turn on/off horizontal grid lines. * @method renderHorizontalGridLines * @memberof dc.coordinateGridMixin * @instance * @param {Boolean} [renderHorizontalGridLines=false] * @returns {Boolean|dc.coordinateGridMixin} */ _chart.renderHorizontalGridLines = function (renderHorizontalGridLines) { if (!arguments.length) { return _renderHorizontalGridLine; } _renderHorizontalGridLine = renderHorizontalGridLines; return _chart; }; /** * Turn on/off vertical grid lines. * @method renderVerticalGridLines * @memberof dc.coordinateGridMixin * @instance * @param {Boolean} [renderVerticalGridLines=false] * @returns {Boolean|dc.coordinateGridMixin} */ _chart.renderVerticalGridLines = function (renderVerticalGridLines) { if (!arguments.length) { return _renderVerticalGridLine; } _renderVerticalGridLine = renderVerticalGridLines; return _chart; }; /** * Calculates the minimum x value to display in the chart. Includes xAxisPadding if set. * @method xAxisMin * @memberof dc.coordinateGridMixin * @instance * @returns {*} */ _chart.xAxisMin = function () { var min = d3.min(_chart.data(), function (e) { return _chart.keyAccessor()(e); }); return dc.utils.subtract(min, _xAxisPadding, _xAxisPaddingUnit); }; /** * Calculates the maximum x value to display in the chart. Includes xAxisPadding if set. * @method xAxisMax * @memberof dc.coordinateGridMixin * @instance * @returns {*} */ _chart.xAxisMax = function () { var max = d3.max(_chart.data(), function (e) { return _chart.keyAccessor()(e); }); return dc.utils.add(max, _xAxisPadding, _xAxisPaddingUnit); }; /** * Calculates the minimum y value to display in the chart. Includes yAxisPadding if set. * @method yAxisMin * @memberof dc.coordinateGridMixin * @instance * @returns {*} */ _chart.yAxisMin = function () { var min = d3.min(_chart.data(), function (e) { return _chart.valueAccessor()(e); }); return dc.utils.subtract(min, _yAxisPadding); }; /** * Calculates the maximum y value to display in the chart. Includes yAxisPadding if set. * @method yAxisMax * @memberof dc.coordinateGridMixin * @instance * @returns {*} */ _chart.yAxisMax = function () { var max = d3.max(_chart.data(), function (e) { return _chart.valueAccessor()(e); }); return dc.utils.add(max, _yAxisPadding); }; /** * Set or get y axis padding for the elastic y axis. The padding will be added to the top and * bottom of the y axis if elasticY is turned on; otherwise it is ignored. * * Padding can be an integer or percentage in string (e.g. '10%'). Padding can be applied to * number or date axes. When padding a date axis, an integer represents number of days being padded * and a percentage string will be treated the same as an integer. * @method yAxisPadding * @memberof dc.coordinateGridMixin * @instance * @param {Number|String} [padding=0] * @returns {Number|dc.coordinateGridMixin} */ _chart.yAxisPadding = function (padding) { if (!arguments.length) { return _yAxisPadding; } _yAxisPadding = padding; return _chart; }; _chart.yAxisHeight = function () { return _chart.effectiveHeight(); }; /** * Set or get the rounding function used to quantize the selection when brushing is enabled. * @method round * @memberof dc.coordinateGridMixin * @instance * @example * // set x unit round to by month, this will make sure range selection brush will * // select whole months * chart.round(d3.timeMonth.round); * @param {Function} [round] * @returns {Function|dc.coordinateGridMixin} */ _chart.round = function (round) { if (!arguments.length) { return _round; } _round = round; return _chart; }; _chart._rangeBandPadding = function (_) { if (!arguments.length) { return _rangeBandPadding; } _rangeBandPadding = _; return _chart; }; _chart._outerRangeBandPadding = function (_) { if (!arguments.length) { return _outerRangeBandPadding; } _outerRangeBandPadding = _; return _chart; }; dc.override(_chart, 'filter', function (_) { if (!arguments.length) { return _chart._filter(); } _chart._filter(_); _chart.redrawBrush(_, false); return _chart; }); /** * Get or set the brush. Brush must be an instance of d3 brushes * https://github.com/d3/d3-brush/blob/master/README.md * You will use this only if you are writing a new chart type that supports brushing. * * **Caution**: dc creates and manages brushes internally. Go through and understand the source code * if you want to pass a new brush object. Even if you are only using the getter, * the brush object may not behave the way you expect. * * @method brush * @memberof dc.coordinateGridMixin * @instance * @param {d3.brush} [_] * @returns {d3.brush|dc.coordinateGridMixin} */ _chart.brush = function (_) { if (!arguments.length) { return _brush; } _brush = _; return _chart; }; _chart.renderBrush = function (g, doTransition) { if (_brushOn) { _brush.on('start brush end', _chart._brushing); // To retrieve selection we need _gBrush _gBrush = g.append('g') .attr('class', 'brush') .attr('transform', 'translate(' + _chart.margins().left + ',' + _chart.margins().top + ')'); _chart.setBrushExtents(); _chart.createBrushHandlePaths(_gBrush, doTransition); _chart.redrawBrush(_chart.filter(), doTransition); } }; _chart.createBrushHandlePaths = function (gBrush) { var brushHandles = gBrush.selectAll('path.' + CUSTOM_BRUSH_HANDLE_CLASS).data([{type: 'w'}, {type: 'e'}]); brushHandles = brushHandles .enter() .append('path') .attr('class', CUSTOM_BRUSH_HANDLE_CLASS) .merge(brushHandles); brushHandles .attr('d', _chart.resizeHandlePath); }; _chart.extendBrush = function (brushSelection) { if (brushSelection && _chart.round()) { brushSelection[0] = _chart.round()(brushSelection[0]); brushSelection[1] = _chart.round()(brushSelection[1]); } return brushSelection; }; _chart.brushIsEmpty = function (brushSelection) { return !brushSelection || brushSelection[1] <= brushSelection[0]; }; _chart._brushing = function () { // Avoids infinite recursion (mutual recursion between range and focus operations) // Source Event will be null when brush.move is called programmatically (see below as well). if (!d3.event.sourceEvent) { return; } // Ignore event if recursive event - i.e. not directly generated by user action (like mouse/touch etc.) // In this case we are more worried about this handler causing brush move programmatically which will // cause this handler to be invoked again with a new d3.event (and current event set as sourceEvent) // This check avoids recursive calls if (d3.event.sourceEvent.type && ['start', 'brush', 'end'].indexOf(d3.event.sourceEvent.type) !== -1) { return; } var brushSelection = d3.event.selection; if (brushSelection) { brushSelection = brushSelection.map(_chart.x().invert); } brushSelection = _chart.extendBrush(brushSelection); _chart.redrawBrush(brushSelection, false); var rangedFilter = _chart.brushIsEmpty(brushSelection) ? null : dc.filters.RangedFilter(brushSelection[0], brushSelection[1]); dc.events.trigger(function () { _chart.applyBrushSelection(rangedFilter); }, dc.constants.EVENT_DELAY); }; // This can be overridden in a derived chart. For example Composite chart overrides it _chart.applyBrushSelection = function (rangedFilter) { _chart.replaceFilter(rangedFilter); _chart.redrawGroup(); }; _chart.setBrushExtents = function (doTransition) { // Set boundaries of the brush, must set it before applying to _gBrush _brush.extent([[0, 0], [_chart.effectiveWidth(), _chart.effectiveHeight()]]); _gBrush .call(_brush); }; _chart.redrawBrush = function (brushSelection, doTransition) { if (_brushOn && _gBrush) { if (_resizing) { _chart.setBrushExtents(doTransition); } if (!brushSelection) { _gBrush .call(_brush.move, null); _gBrush.selectAll('path.' + CUSTOM_BRUSH_HANDLE_CLASS) .attr('display', 'none'); } else { var scaledSelection = [_x(brushSelection[0]), _x(brushSelection[1])]; var gBrush = dc.optionalTransition(doTransition, _chart.transitionDuration(), _chart.transitionDelay())(_gBrush); gBrush .call(_brush.move, scaledSelection); gBrush.selectAll('path.' + CUSTOM_BRUSH_HANDLE_CLASS) .attr('display', null) .attr('transform', function (d, i) { return 'translate(' + _x(brushSelection[i]) + ', 0)'; }) .attr('d', _chart.resizeHandlePath); } } _chart.fadeDeselectedArea(brushSelection); }; _chart.fadeDeselectedArea = function (brushSelection) { // do nothing, sub-chart should override this function }; // borrowed from Crossfilter example _chart.resizeHandlePath = function (d) { d = d.type; var e = +(d === 'e'), x = e ? 1 : -1, y = _chart.effectiveHeight() / 3; return 'M' + (0.5 * x) + ',' + y + 'A6,6 0 0 ' + e + ' ' + (6.5 * x) + ',' + (y + 6) + 'V' + (2 * y - 6) + 'A6,6 0 0 ' + e + ' ' + (0.5 * x) + ',' + (2 * y) + 'Z' + 'M' + (2.5 * x) + ',' + (y + 8) + 'V' + (2 * y - 8) + 'M' + (4.5 * x) + ',' + (y + 8) + 'V' + (2 * y - 8); }; function getClipPathId () { return _chart.anchorName().replace(/[ .#=\[\]"]/g, '-') + '-clip'; } /** * Get or set the padding in pixels for the clip path. Once set padding will be applied evenly to * the top, left, right, and bottom when the clip path is generated. If set to zero, the clip area * will be exactly the chart body area minus the margins. * @method clipPadding * @memberof dc.coordinateGridMixin * @instance * @param {Number} [padding=5] * @returns {Number|dc.coordinateGridMixin} */ _chart.clipPadding = function (padding) { if (!arguments.length) { return _clipPadding; } _clipPadding = padding; return _chart; }; function generateClipPath () { var defs = dc.utils.appendOrSelect(_parent, 'defs'); // cannot select elements; bug in WebKit, must select by id // https://groups.google.com/forum/#!topic/d3-js/6EpAzQ2gU9I var id = getClipPathId(); var chartBodyClip = dc.utils.appendOrSelect(defs, '#' + id, 'clipPath').attr('id', id); var padding = _clipPadding * 2; dc.utils.appendOrSelect(chartBodyClip, 'rect') .attr('width', _chart.xAxisLength() + padding) .attr('height', _chart.yAxisHeight() + padding) .attr('transform', 'translate(-' + _clipPadding + ', -' + _clipPadding + ')'); } _chart._preprocessData = function () {}; _chart._doRender = function () { _chart.resetSvg(); _chart._preprocessData(); _chart._generateG(); generateClipPath(); drawChart(true); configureMouseZoom(); return _chart; }; _chart._doRedraw = function () { _chart._preprocessData(); drawChart(false); generateClipPath(); return _chart; }; function drawChart (render) { if (_chart.isOrdinal()) { _brushOn = false; } prepareXAxis(_chart.g(), render); _chart._prepareYAxis(_chart.g()); _chart.plotData(); if (_chart.elasticX() || _resizing || render) { _chart.renderXAxis(_chart.g()); } if (_chart.elasticY() || _resizing || render) { _chart.renderYAxis(_chart.g()); } if (render) { _chart.renderBrush(_chart.g(), false); } else { // Animate the brush only while resizing _chart.redrawBrush(_chart.filter(), _resizing); } _chart.fadeDeselectedArea(_chart.filter()); _resizing = false; } function configureMouseZoom () { // Save a copy of original x scale _origX = _x.copy(); if (_mouseZoomable) { _chart._enableMouseZoom(); } else if (_hasBeenMouseZoomable) { _chart._disableMouseZoom(); } } _chart._enableMouseZoom = function () { _hasBeenMouseZoomable = true; var extent = [[0, 0],[_chart.effectiveWidth(), _chart.effectiveHeight()]]; _zoom .scaleExtent(_zoomScale) .extent(extent) .duration(_chart.transitionDuration()); if (_zoomOutRestrict) { // Ensure minimum zoomScale is at least 1 var zoomScaleMin = Math.max(_zoomScale[0], 1); _zoom .translateExtent(extent) .scaleExtent([zoomScaleMin, _zoomScale[1]]); } _chart.root().call(_zoom); // Tell D3 zoom our current zoom/pan status updateD3zoomTransform(); }; _chart._disableMouseZoom = function () { _chart.root().call(_nullZoom); }; function zoomHandler (newDomain, noRaiseEvents) { var domFilter; if (hasRangeSelected(newDomain)) { _chart.x().domain(newDomain); domFilter = dc.filters.RangedFilter(newDomain[0], newDomain[1]); } else { _chart.x().domain(_xOriginalDomain); domFilter = null; } _chart.replaceFilter(domFilter); _chart.rescale(); _chart.redraw(); if (!noRaiseEvents) { if (_rangeChart && !rangesEqual(_chart.filter(), _rangeChart.filter())) { dc.events.trigger(function () { _rangeChart.replaceFilter(domFilter); _rangeChart.redraw(); }); } _chart._invokeZoomedListener(); dc.events.trigger(function () { _chart.redrawGroup(); }, dc.constants.EVENT_DELAY); } } // event.transform.rescaleX(_origX).domain() should give back newDomain function domainToZoomTransform (newDomain, origDomain, xScale) { var k = (origDomain[1] - origDomain[0]) / (newDomain[1] - newDomain[0]); var xt = -1 * xScale(newDomain[0]); return d3.zoomIdentity.scale(k).translate(xt, 0); } // If we changing zoom status (for example by calling focus), tell D3 zoom about it function updateD3zoomTransform () { if (_zoom) { _zoom.transform(_chart.root(), domainToZoomTransform(_chart.x().domain(), _xOriginalDomain, _origX)); } } function onZoom () { // Avoids infinite recursion (mutual recursion between range and focus operations) // Source Event will be null when zoom is called programmatically (see below as well). if (!d3.event.sourceEvent) { return; } // Ignore event if recursive event - i.e. not directly generated by user action (like mouse/touch etc.) // In this case we are more worried about this handler causing zoom programmatically which will // cause this handler to be invoked again with a new d3.event (and current event set as sourceEvent) // This check avoids recursive calls if (d3.event.sourceEvent.type && ['start', 'zoom', 'end'].indexOf(d3.event.sourceEvent.type) !== -1) { return; } var newDomain = d3.event.transform.rescaleX(_origX).domain(); _chart.focus(newDomain, false); } function checkExtents (ext, outerLimits) { if (!ext || ext.length !== 2 || !outerLimits || outerLimits.length !== 2) { return ext; } if (ext[0] > outerLimits[1] || ext[1] < outerLimits[0]) { console.warn('Could not intersect extents, will reset'); } // Math.max does not work (as the values may be dates as well) return [ext[0] > outerLimits[0] ? ext[0] : outerLimits[0], ext[1] < outerLimits[1] ? ext[1] : outerLimits[1]]; } /** * Zoom this chart to focus on the given range. The given range should be an array containing only * 2 elements (`[start, end]`) defining a range in the x domain. If the range is not given or set * to null, then the zoom will be reset. _For focus to work elasticX has to be turned off; * otherwise focus will be ignored. * * To avoid ping-pong volley of events between a pair of range and focus charts please set * `noRaiseEvents` to `true`. In that case it will update this chart but will not fire `zoom` event * and not try to update back the associated range chart. * If you are calling it manually - typically you will leave it to `false` (the default). * * @method focus * @memberof dc.coordinateGridMixin * @instance * @example * chart.on('renderlet', function(chart) { * // smooth the rendering through event throttling * dc.events.trigger(function(){ * // focus some other chart to the range selected by user on this chart * someOtherChart.focus(chart.filter()); * }); * }) * @param {Array} [range] * @param {Boolean} [noRaiseEvents = false] */ _chart.focus = function (range, noRaiseEvents) { if (_zoomOutRestrict) { // ensure range is within _xOriginalDomain range = checkExtents(range, _xOriginalDomain); // If it has an associated range chart ensure range is within domain of that rangeChart if (_rangeChart) { range = checkExtents(range, _rangeChart.x().domain()); } } zoomHandler(range, noRaiseEvents); updateD3zoomTransform(); }; _chart.refocused = function () { return !rangesEqual(_chart.x().domain(), _xOriginalDomain); }; _chart.focusChart = function (c) { if (!arguments.length) { return _focusChart; } _focusChart = c; _chart.on('filtered', function (chart) { if (!chart.filter()) { dc.events.trigger(function () { _focusChart.x().domain(_focusChart.xOriginalDomain(), true); }); } else if (!rangesEqual(chart.filter(), _focusChart.filter())) { dc.events.trigger(function () { _focusChart.focus(chart.filter(), true); }); } }); return _chart; }; function rangesEqual (range1, range2) { if (!range1 && !range2) { return true; } else if (!range1 || !range2) { return false; } else if (range1.length === 0 && range2.length === 0) { return true; } else if (range1[0].valueOf() === range2[0].valueOf() && range1[1].valueOf() === range2[1].valueOf()) { return true; } return false; } /** * Turn on/off the brush-based range filter. When brushing is on then user can drag the mouse * across a chart with a quantitative scale to perform range filtering based on the extent of the * brush, or click on the bars of an ordinal bar chart or slices of a pie chart to filter and * un-filter them. However turning on the brush filter will disable other interactive elements on * the chart such as highlighting, tool tips, and reference lines. Zooming will still be possible * if enabled, but only via scrolling (panning will be disabled.) * @method brushOn * @memberof dc.coordinateGridMixin * @instance * @param {Boolean} [brushOn=true] * @returns {Boolean|dc.coordinateGridMixin} */ _chart.brushOn = function (brushOn) { if (!arguments.length) { return _brushOn; } _brushOn = brushOn; return _chart; }; /** * This will be internally used by composite chart onto children. Please go not invoke directly. * * @method parentBrushOn * @memberof dc.coordinateGridMixin * @protected * @instance * @param {Boolean} [brushOn=false] * @returns {Boolean|dc.coordinateGridMixin} */ _chart.parentBrushOn = function (brushOn) { if (!arguments.length) { return _parentBrushOn; } _parentBrushOn = brushOn; return _chart; }; // Get the SVG rendered brush _chart.gBrush = function () { return _gBrush; }; function hasRangeSelected (range) { return range instanceof Array && range.length > 1; } return _chart; }; /** * Stack Mixin is an mixin that provides cross-chart support of stackability using d3.stackD3v3. * @name stackMixin * @memberof dc * @mixin * @param {Object} _chart * @returns {dc.stackMixin} */ dc.stackMixin = function (_chart) { function prepareValues (layer, layerIdx) { var valAccessor = layer.accessor || _chart.valueAccessor(); layer.name = String(layer.name || layerIdx); var allValues = layer.group.all().map(function (d, i) { return { x: _chart.keyAccessor()(d, i), y: layer.hidden ? null : valAccessor(d, i), data: d, layer: layer.name, hidden: layer.hidden }; }); layer.domainValues = allValues.filter(domainFilter()); layer.values = _chart.evadeDomainFilter() ? allValues : layer.domainValues; } var _stackLayout = d3.stack(); var _stack = []; var _titles = {}; var _hidableStacks = false; var _evadeDomainFilter = false; function domainFilter () { if (!_chart.x()) { return dc.utils.constant(true); } var xDomain = _chart.x().domain(); if (_chart.isOrdinal()) { // TODO #416 //var domainSet = d3.set(xDomain); return function () { return true; //domainSet.has(p.x); }; } if (_chart.elasticX()) { return function () { return true; }; } return function (p) { //return true; return p.x >= xDomain[0] && p.x <= xDomain[xDomain.length - 1]; }; } /** * Stack a new crossfilter group onto this chart with an optional custom value accessor. All stacks * in the same chart will share the same key accessor and therefore the same set of keys. * * For example, in a stacked bar chart, the bars of each stack will be positioned using the same set * of keys on the x axis, while stacked vertically. If name is specified then it will be used to * generate the legend label. * @method stack * @memberof dc.stackMixin * @instance * @see {@link https://github.com/crossfilter/crossfilter/wiki/API-Reference#group-map-reduce crossfilter.group} * @example * // stack group using default accessor * chart.stack(valueSumGroup) * // stack group using custom accessor * .stack(avgByDayGroup, function(d){return d.value.avgByDay;}); * @param {crossfilter.group} group * @param {String} [name] * @param {Function} [accessor] * @returns {Array<{group: crossfilter.group, name: String, accessor: Function}>|dc.stackMixin} */ _chart.stack = function (group, name, accessor) { if (!arguments.length) { return _stack; } if (arguments.length <= 2) { accessor = name; } var layer = {group: group}; if (typeof name === 'string') { layer.name = name; } if (typeof accessor === 'function') { layer.accessor = accessor; } _stack.push(layer); return _chart; }; dc.override(_chart, 'group', function (g, n, f) { if (!arguments.length) { return _chart._group(); } _stack = []; _titles = {}; _chart.stack(g, n); if (f) { _chart.valueAccessor(f); } return _chart._group(g, n); }); /** * Allow named stacks to be hidden or shown by clicking on legend items. * This does not affect the behavior of hideStack or showStack. * @method hidableStacks * @memberof dc.stackMixin * @instance * @param {Boolean} [hidableStacks=false] * @returns {Boolean|dc.stackMixin} */ _chart.hidableStacks = function (hidableStacks) { if (!arguments.length) { return _hidableStacks; } _hidableStacks = hidableStacks; return _chart; }; function findLayerByName (n) { var i = _stack.map(dc.pluck('name')).indexOf(n); return _stack[i]; } /** * Hide all stacks on the chart with the given name. * The chart must be re-rendered for this change to appear. * @method hideStack * @memberof dc.stackMixin * @instance * @param {String} stackName * @returns {dc.stackMixin} */ _chart.hideStack = function (stackName) { var layer = findLayerByName(stackName); if (layer) { layer.hidden = true; } return _chart; }; /** * Show all stacks on the chart with the given name. * The chart must be re-rendered for this change to appear. * @method showStack * @memberof dc.stackMixin * @instance * @param {String} stackName * @returns {dc.stackMixin} */ _chart.showStack = function (stackName) { var layer = findLayerByName(stackName); if (layer) { layer.hidden = false; } return _chart; }; _chart.getValueAccessorByIndex = function (index) { return _stack[index].accessor || _chart.valueAccessor(); }; _chart.yAxisMin = function () { var min = d3.min(flattenStack(), function (p) { return (p.y < 0) ? (p.y + p.y0) : p.y0; }); return dc.utils.subtract(min, _chart.yAxisPadding()); }; _chart.yAxisMax = function () { var max = d3.max(flattenStack(), function (p) { return (p.y > 0) ? (p.y + p.y0) : p.y0; }); return dc.utils.add(max, _chart.yAxisPadding()); }; function flattenStack () { var valueses = _chart.data().map(function (layer) { return layer.domainValues; }); return Array.prototype.concat.apply([], valueses); } _chart.xAxisMin = function () { var min = d3.min(flattenStack(), dc.pluck('x')); return dc.utils.subtract(min, _chart.xAxisPadding(), _chart.xAxisPaddingUnit()); }; _chart.xAxisMax = function () { var max = d3.max(flattenStack(), dc.pluck('x')); return dc.utils.add(max, _chart.xAxisPadding(), _chart.xAxisPaddingUnit()); }; /** * Set or get the title function. Chart class will use this function to render svg title (usually interpreted by * 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. * Almost every chart supports title function however in grid coordinate chart you need to turn off brush in order to * use title otherwise the brush layer will block tooltip trigger. * * If the first argument is a stack name, the title function will get or set the title for that stack. If stackName * is not provided, the first stack is implied. * @method title * @memberof dc.stackMixin * @instance * @example * // set a title function on 'first stack' * chart.title('first stack', function(d) { return d.key + ': ' + d.value; }); * // get a title function from 'second stack' * var secondTitleFunction = chart.title('second stack'); * @param {String} [stackName] * @param {Function} [titleAccessor] * @returns {String|dc.stackMixin} */ dc.override(_chart, 'title', function (stackName, titleAccessor) { if (!stackName) { return _chart._title(); } if (typeof stackName === 'function') { return _chart._title(stackName); } if (stackName === _chart._groupName && typeof titleAccessor === 'function') { return _chart._title(titleAccessor); } if (typeof titleAccessor !== 'function') { return _titles[stackName] || _chart._title(); } _titles[stackName] = titleAccessor; return _chart; }); /** * Gets or sets the stack layout algorithm, which computes a baseline for each stack and * propagates it to the next. * @method stackLayout * @memberof dc.stackMixin * @instance * @see {@link https://github.com/d3/d3-3.x-api-reference/blob/master/Stack-Layout.md d3.stackD3v3} * @param {Function} [stack=d3.stackD3v3] * @returns {Function|dc.stackMixin} */ _chart.stackLayout = function (stack) { if (!arguments.length) { return _stackLayout; } _stackLayout = stack; return _chart; }; /** * Since dc.js 2.0, there has been {@link https://github.com/dc-js/dc.js/issues/949 an issue} * where points are filtered to the current domain. While this is a useful optimization, it is * incorrectly implemented: the next point outside the domain is required in order to draw lines * that are clipped to the bounds, as well as bars that are partly clipped. * * A fix will be included in dc.js 2.1.x, but a workaround is needed for dc.js 2.0 and until * that fix is published, so set this flag to skip any filtering of points. * * Once the bug is fixed, this flag will have no effect, and it will be deprecated. * @method evadeDomainFilter * @memberof dc.stackMixin * @instance * @param {Boolean} [evadeDomainFilter=false] * @returns {Boolean|dc.stackMixin} */ _chart.evadeDomainFilter = function (evadeDomainFilter) { if (!arguments.length) { return _evadeDomainFilter; } _evadeDomainFilter = evadeDomainFilter; return _chart; }; function visibility (l) { return !l.hidden; } _chart.data(function () { var layers = _stack.filter(visibility); if (!layers.length) { return []; } layers.forEach(prepareValues); var v4data = layers[0].values.map(function (v, i) { var col = {x: v.x}; layers.forEach(function (layer) { col[layer.name] = layer.values[i].y; }); return col; }); var keys = layers.map(function (layer) { return layer.name; }); var v4result = _chart.stackLayout().keys(keys)(v4data); v4result.forEach(function (series, i) { series.forEach(function (ys, j) { layers[i].values[j].y0 = ys[0]; layers[i].values[j].y1 = ys[1]; }); }); return layers; }); _chart._ordinalXDomain = function () { var flat = flattenStack().map(dc.pluck('data')); var ordered = _chart._computeOrderedGroups(flat); return ordered.map(_chart.keyAccessor()); }; _chart.colorAccessor(function (d) { var layer = this.layer || this.name || d.name || d.layer; return layer; }); _chart.legendables = function () { return _stack.map(function (layer, i) { return { chart: _chart, name: layer.name, hidden: layer.hidden || false, color: _chart.getColor.call(layer, layer.values, i) }; }); }; _chart.isLegendableHidden = function (d) { var layer = findLayerByName(d.name); return layer ? layer.hidden : false; }; _chart.legendToggle = function (d) { if (_hidableStacks) { if (_chart.isLegendableHidden(d)) { _chart.showStack(d.name); } else { _chart.hideStack(d.name); } //_chart.redraw(); _chart.renderGroup(); } }; return _chart; }; /** * Cap is a mixin that groups small data elements below a _cap_ into an *others* grouping for both the * Row and Pie Charts. * * The top ordered elements in the group up to the cap amount will be kept in the chart, and the rest * will be replaced with an *others* element, with value equal to the sum of the replaced values. The * keys of the elements below the cap limit are recorded in order to filter by those keys when the * others* element is clicked. * @name capMixin * @memberof dc * @mixin * @param {Object} _chart * @returns {dc.capMixin} */ dc.capMixin = function (_chart) { var _cap = Infinity, _takeFront = true; var _othersLabel = 'Others'; // emulate old group.top(N) ordering _chart.ordering(function (kv) { return -kv.value; }); var _othersGrouper = function (topItems, restItems) { var restItemsSum = d3.sum(restItems, _chart.valueAccessor()), restKeys = restItems.map(_chart.keyAccessor()); if (restItemsSum > 0) { return topItems.concat([{ others: restKeys, key: _chart.othersLabel(), value: restItemsSum }]); } return topItems; }; _chart.cappedKeyAccessor = function (d, i) { if (d.others) { return d.key; } return _chart.keyAccessor()(d, i); }; _chart.cappedValueAccessor = function (d, i) { if (d.others) { return d.value; } return _chart.valueAccessor()(d, i); }; // return N "top" groups, where N is the cap, sorted by baseMixin.ordering // whether top means front or back depends on takeFront _chart.data(function (group) { if (_cap === Infinity) { return _chart._computeOrderedGroups(group.all()); } else { var items = group.all(), rest; items = _chart._computeOrderedGroups(items); // sort by baseMixin.ordering if (_cap) { if (_takeFront) { rest = items.slice(_cap); items = items.slice(0, _cap); } else { var start = Math.max(0, items.length - _cap); rest = items.slice(0, start); items = items.slice(start); } } if (_othersGrouper) { return _othersGrouper(items, rest); } return items; } }); /** * Get or set the count of elements to that will be included in the cap. If there is an * {@link dc.capMixin#othersGrouper othersGrouper}, any further elements will be combined in an * extra element with its name determined by {@link dc.capMixin#othersLabel othersLabel}. * * As of dc.js 2.1 and onward, the capped charts use * {@link https://github.com/crossfilter/crossfilter/wiki/API-Reference#group_all group.all()} * and {@link dc.baseMixin#ordering baseMixin.ordering()} to determine the order of * elements. Then `cap` and {@link dc.capMixin#takeFront takeFront} determine how many elements * to keep, from which end of the resulting array. * * **Migration note:** Up through dc.js 2.0.*, capping used * {@link https://github.com/crossfilter/crossfilter/wiki/API-Reference#group_top group.top(N)}, * which selects the largest items according to * {@link https://github.com/crossfilter/crossfilter/wiki/API-Reference#group_order group.order()}. * The chart then sorted the items according to {@link dc.baseMixin#ordering baseMixin.ordering()}. * So the two values essentially had to agree, but if the `group.order()` was incorrect (it's * easy to forget about), the wrong rows or slices would be displayed, in the correct order. * * If your chart previously relied on `group.order()`, use `chart.ordering()` instead. As of * 2.1.5, the ordering defaults to sorting from greatest to least like `group.top(N)` did. * * If you want to cap by one ordering but sort by another, please * [file an issue](https://github.com/dc-js/dc.js/issues/new) - it's still possible but we'll * need to work up an example. * @method cap * @memberof dc.capMixin * @instance * @param {Number} [count=Infinity] * @returns {Number|dc.capMixin} */ _chart.cap = function (count) { if (!arguments.length) { return _cap; } _cap = count; return _chart; }; /** * Get or set the direction of capping. If set, the chart takes the first * {@link dc.capMixin#cap cap} elements from the sorted array of elements; otherwise * it takes the last `cap` elements. * @method takeFront * @memberof dc.capMixin * @instance * @param {Boolean} [takeFront=true] * @returns {Boolean|dc.capMixin} */ _chart.takeFront = function (takeFront) { if (!arguments.length) { return _takeFront; } _takeFront = takeFront; return _chart; }; /** * Get or set the label for *Others* slice when slices cap is specified. * @method othersLabel * @memberof dc.capMixin * @instance * @param {String} [label="Others"] * @returns {String|dc.capMixin} */ _chart.othersLabel = function (label) { if (!arguments.length) { return _othersLabel; } _othersLabel = label; return _chart; }; /** * Get or set the grouper function that will perform the insertion of data for the *Others* slice * if the slices cap is specified. If set to a falsy value, no others will be added. * * The grouper function takes an array of included ("top") items, and an array of the rest of * the items. By default the grouper function computes the sum of the rest. * @method othersGrouper * @memberof dc.capMixin * @instance * @example * // Do not show others * chart.othersGrouper(null); * // Default others grouper * chart.othersGrouper(function (topItems, restItems) { * var restItemsSum = d3.sum(restItems, _chart.valueAccessor()), * restKeys = restItems.map(_chart.keyAccessor()); * if (restItemsSum > 0) { * return topItems.concat([{ * others: restKeys, * key: _chart.othersLabel(), * value: restItemsSum * }]); * } * return topItems; * }); * @param {Function} [grouperFunction] * @returns {Function|dc.capMixin} */ _chart.othersGrouper = function (grouperFunction) { if (!arguments.length) { return _othersGrouper; } _othersGrouper = grouperFunction; return _chart; }; dc.override(_chart, 'onClick', function (d) { if (d.others) { _chart.filter([d.others]); } _chart._onClick(d); }); return _chart; }; /** * This Mixin provides reusable functionalities for any chart that needs to visualize data using bubbles. * @name bubbleMixin * @memberof dc * @mixin * @mixes dc.colorMixin * @param {Object} _chart * @returns {dc.bubbleMixin} */ dc.bubbleMixin = function (_chart) { var _maxBubbleRelativeSize = 0.3; var _minRadiusWithLabel = 10; var _sortBubbleSize = false; var _elasticRadius = false; _chart.BUBBLE_NODE_CLASS = 'node'; _chart.BUBBLE_CLASS = 'bubble'; _chart.MIN_RADIUS = 10; _chart = dc.colorMixin(_chart); _chart.renderLabel(true); _chart.data(function (group) { var data = group.all(); if (_sortBubbleSize) { // sort descending so smaller bubbles are on top var radiusAccessor = _chart.radiusValueAccessor(); data.sort(function (a, b) { return d3.descending(radiusAccessor(a), radiusAccessor(b)); }); } return data; }); var _r = d3.scaleLinear().domain([0, 100]); var _rValueAccessor = function (d) { return d.r; }; /** * Get or set the bubble radius scale. By default the bubble chart uses * {@link https://github.com/d3/d3-scale/blob/master/README.md#scaleLinear d3.scaleLinear().domain([0, 100])} * as its radius scale. * @method r * @memberof dc.bubbleMixin * @instance * @see {@link https://github.com/d3/d3-scale/blob/master/README.md d3.scale} * @param {d3.scale} [bubbleRadiusScale=d3.scaleLinear().domain([0, 100])] * @returns {d3.scale|dc.bubbleMixin} */ _chart.r = function (bubbleRadiusScale) { if (!arguments.length) { return _r; } _r = bubbleRadiusScale; return _chart; }; /** * Turn on or off the elastic bubble radius feature, or return the value of the flag. If this * feature is turned on, then bubble radii will be automatically rescaled to fit the chart better. * @method elasticRadius * @memberof dc.bubbleChart * @instance * @param {Boolean} [elasticRadius=false] * @returns {Boolean|dc.bubbleChart} */ _chart.elasticRadius = function (elasticRadius) { if (!arguments.length) { return _elasticRadius; } _elasticRadius = elasticRadius; return _chart; }; _chart.calculateRadiusDomain = function () { if (_elasticRadius) { _chart.r().domain([_chart.rMin(), _chart.rMax()]); } }; /** * Get or set the radius value accessor function. If set, the radius value accessor function will * be used to retrieve a data value for each bubble. The data retrieved then will be mapped using * the r scale to the actual bubble radius. This allows you to encode a data dimension using bubble * size. * @method radiusValueAccessor * @memberof dc.bubbleMixin * @instance * @param {Function} [radiusValueAccessor] * @returns {Function|dc.bubbleMixin} */ _chart.radiusValueAccessor = function (radiusValueAccessor) { if (!arguments.length) { return _rValueAccessor; } _rValueAccessor = radiusValueAccessor; return _chart; }; _chart.rMin = function () { var min = d3.min(_chart.data(), function (e) { return _chart.radiusValueAccessor()(e); }); return min; }; _chart.rMax = function () { var max = d3.max(_chart.data(), function (e) { return _chart.radiusValueAccessor()(e); }); return max; }; _chart.bubbleR = function (d) { var value = _chart.radiusValueAccessor()(d); var r = _chart.r()(value); if (isNaN(r) || value <= 0) { r = 0; } return r; }; var labelFunction = function (d) { return _chart.label()(d); }; var shouldLabel = function (d) { return (_chart.bubbleR(d) > _minRadiusWithLabel); }; var labelOpacity = function (d) { return shouldLabel(d) ? 1 : 0; }; var labelPointerEvent = function (d) { return shouldLabel(d) ? 'all' : 'none'; }; _chart._doRenderLabel = function (bubbleGEnter) { if (_chart.renderLabel()) { var label = bubbleGEnter.select('text'); if (label.empty()) { label = bubbleGEnter.append('text') .attr('text-anchor', 'middle') .attr('dy', '.3em') .on('click', _chart.onClick); } label .attr('opacity', 0) .attr('pointer-events', labelPointerEvent) .text(labelFunction); dc.transition(label, _chart.transitionDuration(), _chart.transitionDelay()) .attr('opacity', labelOpacity); } }; _chart.doUpdateLabels = function (bubbleGEnter) { if (_chart.renderLabel()) { var labels = bubbleGEnter.select('text') .attr('pointer-events', labelPointerEvent) .text(labelFunction); dc.transition(labels, _chart.transitionDuration(), _chart.transitionDelay()) .attr('opacity', labelOpacity); } }; var titleFunction = function (d) { return _chart.title()(d); }; _chart._doRenderTitles = function (g) { if (_chart.renderTitle()) { var title = g.select('title'); if (title.empty()) { g.append('title').text(titleFunction); } } }; _chart.doUpdateTitles = function (g) { if (_chart.renderTitle()) { g.select('title').text(titleFunction); } }; /** * Turn on or off the bubble sorting feature, or return the value of the flag. If enabled, * bubbles will be sorted by their radius, with smaller bubbles in front. * @method sortBubbleSize * @memberof dc.bubbleChart * @instance * @param {Boolean} [sortBubbleSize=false] * @returns {Boolean|dc.bubbleChart} */ _chart.sortBubbleSize = function (sortBubbleSize) { if (!arguments.length) { return _sortBubbleSize; } _sortBubbleSize = sortBubbleSize; return _chart; }; /** * Get or set the minimum radius. This will be used to initialize the radius scale's range. * @method minRadius * @memberof dc.bubbleMixin * @instance * @param {Number} [radius=10] * @returns {Number|dc.bubbleMixin} */ _chart.minRadius = function (radius) { if (!arguments.length) { return _chart.MIN_RADIUS; } _chart.MIN_RADIUS = radius; return _chart; }; /** * Get or set the minimum radius for label rendering. If a bubble's radius is less than this value * then no label will be rendered. * @method minRadiusWithLabel * @memberof dc.bubbleMixin * @instance * @param {Number} [radius=10] * @returns {Number|dc.bubbleMixin} */ _chart.minRadiusWithLabel = function (radius) { if (!arguments.length) { return _minRadiusWithLabel; } _minRadiusWithLabel = radius; return _chart; }; /** * Get or set the maximum relative size of a bubble to the length of x axis. This value is useful * when the difference in radius between bubbles is too great. * @method maxBubbleRelativeSize * @memberof dc.bubbleMixin * @instance * @param {Number} [relativeSize=0.3] * @returns {Number|dc.bubbleMixin} */ _chart.maxBubbleRelativeSize = function (relativeSize) { if (!arguments.length) { return _maxBubbleRelativeSize; } _maxBubbleRelativeSize = relativeSize; return _chart; }; _chart.fadeDeselectedArea = function (selection) { if (_chart.hasFilter()) { _chart.selectAll('g.' + _chart.BUBBLE_NODE_CLASS).each(function (d) { if (_chart.isSelectedNode(d)) { _chart.highlightSelected(this); } else { _chart.fadeDeselected(this); } }); } else { _chart.selectAll('g.' + _chart.BUBBLE_NODE_CLASS).each(function () { _chart.resetHighlight(this); }); } }; _chart.isSelectedNode = function (d) { return _chart.hasFilter(d.key); }; _chart.onClick = function (d) { var filter = d.key; dc.events.trigger(function () { _chart.filter(filter); _chart.redrawGroup(); }); }; return _chart; }; /** * The pie chart implementation is usually used to visualize a small categorical distribution. The pie * chart uses keyAccessor to determine the slices, and valueAccessor to calculate the size of each * slice relative to the sum of all values. Slices are ordered by {@link dc.baseMixin#ordering ordering} * which defaults to sorting by key. * * Examples: * - {@link http://dc-js.github.com/dc.js/ Nasdaq 100 Index} * @class pieChart * @memberof dc * @mixes dc.capMixin * @mixes dc.colorMixin * @mixes dc.baseMixin * @example * // create a pie chart under #chart-container1 element using the default global chart group * var chart1 = dc.pieChart('#chart-container1'); * // create a pie chart under #chart-container2 element using chart group A * var chart2 = dc.pieChart('#chart-container2', 'chartGroupA'); * @param {String|node|d3.selection} parent - Any valid * {@link https://github.com/d3/d3-selection/blob/master/README.md#select d3 single selector} specifying * a dom block element such as a div; or a dom element or d3 selection. * @param {String} [chartGroup] - The name of the chart group this chart instance should be placed in. * Interaction with a chart will only trigger events and redraws within the chart's group. * @returns {dc.pieChart} */ dc.pieChart = function (parent, chartGroup) { var DEFAULT_MIN_ANGLE_FOR_LABEL = 0.5; var _sliceCssClass = 'pie-slice'; var _labelCssClass = 'pie-label'; var _sliceGroupCssClass = 'pie-slice-group'; var _labelGroupCssClass = 'pie-label-group'; var _emptyCssClass = 'empty-chart'; var _emptyTitle = 'empty'; var _radius, _givenRadius, // specified radius, if any _innerRadius = 0, _externalRadiusPadding = 0; var _g; var _cx; var _cy; var _minAngleForLabel = DEFAULT_MIN_ANGLE_FOR_LABEL; var _externalLabelRadius; var _drawPaths = false; var _chart = dc.capMixin(dc.colorMixin(dc.baseMixin({}))); _chart.colorAccessor(_chart.cappedKeyAccessor); _chart.title(function (d) { return _chart.cappedKeyAccessor(d) + ': ' + _chart.cappedValueAccessor(d); }); /** * Get or set the maximum number of slices the pie chart will generate. The top slices are determined by * value from high to low. Other slices exeeding the cap will be rolled up into one single *Others* slice. * @method slicesCap * @memberof dc.pieChart * @instance * @param {Number} [cap] * @returns {Number|dc.pieChart} */ _chart.slicesCap = _chart.cap; _chart.label(_chart.cappedKeyAccessor); _chart.renderLabel(true); _chart.transitionDuration(350); _chart.transitionDelay(0); _chart._doRender = function () { _chart.resetSvg(); _g = _chart.svg() .append('g') .attr('transform', 'translate(' + _chart.cx() + ',' + _chart.cy() + ')'); _g.append('g').attr('class', _sliceGroupCssClass); _g.append('g').attr('class', _labelGroupCssClass); drawChart(); return _chart; }; function drawChart () { // set radius from chart size if none given, or if given radius is too large var maxRadius = d3.min([_chart.width(), _chart.height()]) / 2; _radius = _givenRadius && _givenRadius < maxRadius ? _givenRadius : maxRadius; var arc = buildArcs(); var pie = pieLayout(); var pieData; // if we have data... if (d3.sum(_chart.data(), _chart.valueAccessor())) { pieData = pie(_chart.data()); _g.classed(_emptyCssClass, false); } else { // otherwise we'd be getting NaNs, so override // note: abuse others for its ignoring the value accessor pieData = pie([{key: _emptyTitle, value: 1, others: [_emptyTitle]}]); _g.classed(_emptyCssClass, true); } if (_g) { var slices = _g.select('g.' + _sliceGroupCssClass) .selectAll('g.' + _sliceCssClass) .data(pieData); var labels = _g.select('g.' + _labelGroupCssClass) .selectAll('text.' + _labelCssClass) .data(pieData); removeElements(slices, labels); createElements(slices, labels, arc, pieData); updateElements(pieData, arc); highlightFilter(); dc.transition(_g, _chart.transitionDuration(), _chart.transitionDelay()) .attr('transform', 'translate(' + _chart.cx() + ',' + _chart.cy() + ')'); } } function createElements (slices, labels, arc, pieData) { var slicesEnter = createSliceNodes(slices); createSlicePath(slicesEnter, arc); createTitles(slicesEnter); createLabels(labels, pieData, arc); } function createSliceNodes (slices) { var slicesEnter = slices .enter() .append('g') .attr('class', function (d, i) { return _sliceCssClass + ' _' + i; }); return slicesEnter; } function createSlicePath (slicesEnter, arc) { var slicePath = slicesEnter.append('path') .attr('fill', fill) .on('click', onClick) .attr('d', function (d, i) { return safeArc(d, i, arc); }); var transition = dc.transition(slicePath, _chart.transitionDuration(), _chart.transitionDelay()); if (transition.attrTween) { transition.attrTween('d', tweenPie); } } function createTitles (slicesEnter) { if (_chart.renderTitle()) { slicesEnter.append('title').text(function (d) { return _chart.title()(d.data); }); } } _chart._applyLabelText = function (labels) { labels .text(function (d) { var data = d.data; if ((sliceHasNoData(data) || sliceTooSmall(d)) && !isSelectedSlice(d)) { return ''; } return _chart.label()(d.data); }); }; function positionLabels (labels, arc) { _chart._applyLabelText(labels); dc.transition(labels, _chart.transitionDuration(), _chart.transitionDelay()) .attr('transform', function (d) { return labelPosition(d, arc); }) .attr('text-anchor', 'middle'); } function highlightSlice (i, whether) { _chart.select('g.pie-slice._' + i) .classed('highlight', whether); } function createLabels (labels, pieData, arc) { if (_chart.renderLabel()) { var labelsEnter = labels .enter() .append('text') .attr('class', function (d, i) { var classes = _sliceCssClass + ' ' + _labelCssClass + ' _' + i; if (_externalLabelRadius) { classes += ' external'; } return classes; }) .on('click', onClick) .on('mouseover', function (d, i) { highlightSlice(i, true); }) .on('mouseout', function (d, i) { highlightSlice(i, false); }); positionLabels(labelsEnter, arc); if (_externalLabelRadius && _drawPaths) { updateLabelPaths(pieData, arc); } } } function updateLabelPaths (pieData, arc) { var polyline = _g.selectAll('polyline.' + _sliceCssClass) .data(pieData); polyline.exit().remove(); polyline = polyline .enter() .append('polyline') .attr('class', function (d, i) { return 'pie-path _' + i + ' ' + _sliceCssClass; }) .on('click', onClick) .on('mouseover', function (d, i) { highlightSlice(i, true); }) .on('mouseout', function (d, i) { highlightSlice(i, false); }) .merge(polyline); var arc2 = d3.arc() .outerRadius(_radius - _externalRadiusPadding + _externalLabelRadius) .innerRadius(_radius - _externalRadiusPadding); var transition = dc.transition(polyline, _chart.transitionDuration(), _chart.transitionDelay()); // this is one rare case where d3.selection differs from d3.transition if (transition.attrTween) { transition .attrTween('points', function (d) { var current = this._current || d; current = {startAngle: current.startAngle, endAngle: current.endAngle}; var interpolate = d3.interpolate(current, d); this._current = interpolate(0); return function (t) { var d2 = interpolate(t); return [arc.centroid(d2), arc2.centroid(d2)]; }; }); } else { transition.attr('points', function (d) { return [arc.centroid(d), arc2.centroid(d)]; }); } transition.style('visibility', function (d) { return d.endAngle - d.startAngle < 0.0001 ? 'hidden' : 'visible'; }); } function updateElements (pieData, arc) { updateSlicePaths(pieData, arc); updateLabels(pieData, arc); updateTitles(pieData); } function updateSlicePaths (pieData, arc) { var slicePaths = _g.selectAll('g.' + _sliceCssClass) .data(pieData) .select('path') .attr('d', function (d, i) { return safeArc(d, i, arc); }); var transition = dc.transition(slicePaths, _chart.transitionDuration(), _chart.transitionDelay()); if (transition.attrTween) { transition.attrTween('d', tweenPie); } transition.attr('fill', fill); } function updateLabels (pieData, arc) { if (_chart.renderLabel()) { var labels = _g.selectAll('text.' + _labelCssClass) .data(pieData); positionLabels(labels, arc); if (_externalLabelRadius && _drawPaths) { updateLabelPaths(pieData, arc); } } } function updateTitles (pieData) { if (_chart.renderTitle()) { _g.selectAll('g.' + _sliceCssClass) .data(pieData) .select('title') .text(function (d) { return _chart.title()(d.data); }); } } function removeElements (slices, labels) { slices.exit().remove(); labels.exit().remove(); } function highlightFilter () { if (_chart.hasFilter()) { _chart.selectAll('g.' + _sliceCssClass).each(function (d) { if (isSelectedSlice(d)) { _chart.highlightSelected(this); } else { _chart.fadeDeselected(this); } }); } else { _chart.selectAll('g.' + _sliceCssClass).each(function () { _chart.resetHighlight(this); }); } } /** * Get or set the external radius padding of the pie chart. This will force the radius of the * pie chart to become smaller or larger depending on the value. * @method externalRadiusPadding * @memberof dc.pieChart * @instance * @param {Number} [externalRadiusPadding=0] * @returns {Number|dc.pieChart} */ _chart.externalRadiusPadding = function (externalRadiusPadding) { if (!arguments.length) { return _externalRadiusPadding; } _externalRadiusPadding = externalRadiusPadding; return _chart; }; /** * Get or set the inner radius of the pie chart. If the inner radius is greater than 0px then the * pie chart will be rendered as a doughnut chart. * @method innerRadius * @memberof dc.pieChart * @instance * @param {Number} [innerRadius=0] * @returns {Number|dc.pieChart} */ _chart.innerRadius = function (innerRadius) { if (!arguments.length) { return _innerRadius; } _innerRadius = innerRadius; return _chart; }; /** * Get or set the outer radius. If the radius is not set, it will be half of the minimum of the * chart width and height. * @method radius * @memberof dc.pieChart * @instance * @param {Number} [radius] * @returns {Number|dc.pieChart} */ _chart.radius = function (radius) { if (!arguments.length) { return _givenRadius; } _givenRadius = radius; return _chart; }; /** * Get or set center x coordinate position. Default is center of svg. * @method cx * @memberof dc.pieChart * @instance * @param {Number} [cx] * @returns {Number|dc.pieChart} */ _chart.cx = function (cx) { if (!arguments.length) { return (_cx || _chart.width() / 2); } _cx = cx; return _chart; }; /** * Get or set center y coordinate position. Default is center of svg. * @method cy * @memberof dc.pieChart * @instance * @param {Number} [cy] * @returns {Number|dc.pieChart} */ _chart.cy = function (cy) { if (!arguments.length) { return (_cy || _chart.height() / 2); } _cy = cy; return _chart; }; function buildArcs () { return d3.arc() .outerRadius(_radius - _externalRadiusPadding) .innerRadius(_innerRadius); } function isSelectedSlice (d) { return _chart.hasFilter(_chart.cappedKeyAccessor(d.data)); } _chart._doRedraw = function () { drawChart(); return _chart; }; /** * Get or set the minimal slice angle for label rendering. Any slice with a smaller angle will not * display a slice label. * @method minAngleForLabel * @memberof dc.pieChart * @instance * @param {Number} [minAngleForLabel=0.5] * @returns {Number|dc.pieChart} */ _chart.minAngleForLabel = function (minAngleForLabel) { if (!arguments.length) { return _minAngleForLabel; } _minAngleForLabel = minAngleForLabel; return _chart; }; function pieLayout () { return d3.pie().sort(null).value(_chart.cappedValueAccessor); } function sliceTooSmall (d) { var angle = (d.endAngle - d.startAngle); return isNaN(angle) || angle < _minAngleForLabel; } function sliceHasNoData (d) { return _chart.cappedValueAccessor(d) === 0; } function tweenPie (b) { b.innerRadius = _innerRadius; var current = this._current; if (isOffCanvas(current)) { current = {startAngle: 0, endAngle: 0}; } else { // only interpolate startAngle & endAngle, not the whole data object current = {startAngle: current.startAngle, endAngle: current.endAngle}; } var i = d3.interpolate(current, b); this._current = i(0); return function (t) { return safeArc(i(t), 0, buildArcs()); }; } function isOffCanvas (current) { return !current || isNaN(current.startAngle) || isNaN(current.endAngle); } function fill (d, i) { return _chart.getColor(d.data, i); } function onClick (d, i) { if (_g.attr('class') !== _emptyCssClass) { _chart.onClick(d.data, i); } } function safeArc (d, i, arc) { var path = arc(d, i); if (path.indexOf('NaN') >= 0) { path = 'M0,0'; } return path; } /** * Title to use for the only slice when there is no data. * @method emptyTitle * @memberof dc.pieChart * @instance * @param {String} [title] * @returns {String|dc.pieChart} */ _chart.emptyTitle = function (title) { if (arguments.length === 0) { return _emptyTitle; } _emptyTitle = title; return _chart; }; /** * Position slice labels offset from the outer edge of the chart. * * The argument specifies the extra radius to be added for slice labels. * @method externalLabels * @memberof dc.pieChart * @instance * @param {Number} [externalLabelRadius] * @returns {Number|dc.pieChart} */ _chart.externalLabels = function (externalLabelRadius) { if (arguments.length === 0) { return _externalLabelRadius; } else if (externalLabelRadius) { _externalLabelRadius = externalLabelRadius; } else { _externalLabelRadius = undefined; } return _chart; }; /** * Get or set whether to draw lines from pie slices to their labels. * * @method drawPaths * @memberof dc.pieChart * @instance * @param {Boolean} [drawPaths] * @returns {Boolean|dc.pieChart} */ _chart.drawPaths = function (drawPaths) { if (arguments.length === 0) { return _drawPaths; } _drawPaths = drawPaths; return _chart; }; function labelPosition (d, arc) { var centroid; if (_externalLabelRadius) { centroid = d3.arc() .outerRadius(_radius - _externalRadiusPadding + _externalLabelRadius) .innerRadius(_radius - _externalRadiusPadding + _externalLabelRadius) .centroid(d); } else { centroid = arc.centroid(d); } if (isNaN(centroid[0]) || isNaN(centroid[1])) { return 'translate(0,0)'; } else { return 'translate(' + centroid + ')'; } } _chart.legendables = function () { return _chart.data().map(function (d, i) { var legendable = {name: d.key, data: d.value, others: d.others, chart: _chart}; legendable.color = _chart.getColor(d, i); return legendable; }); }; _chart.legendHighlight = function (d) { highlightSliceFromLegendable(d, true); }; _chart.legendReset = function (d) { highlightSliceFromLegendable(d, false); }; _chart.legendToggle = function (d) { _chart.onClick({key: d.name, others: d.others}); }; function highlightSliceFromLegendable (legendable, highlighted) { _chart.selectAll('g.pie-slice').each(function (d) { if (legendable.name === d.data.key) { d3.select(this).classed('highlight', highlighted); } }); } return _chart.anchor(parent, chartGroup); }; /** * The sunburst chart implementation is usually used to visualize a small tree distribution. The sunburst * chart uses keyAccessor to determine the slices, and valueAccessor to calculate the size of each * slice relative to the sum of all values. Slices are ordered by {@link dc.baseMixin#ordering ordering} which defaults to sorting * by key. * * The keys used in the sunburst chart should be arrays, representing paths in the tree. * * When filtering, the sunburst chart creates instances of {@link dc.filters.HierarchyFilter HierarchyFilter}. * * @class sunburstChart * @memberof dc * @mixes dc.capMixin * @mixes dc.colorMixin * @mixes dc.baseMixin * @example * // create a sunburst chart under #chart-container1 element using the default global chart group * var chart1 = dc.sunburstChart('#chart-container1'); * // create a sunburst chart under #chart-container2 element using chart group A * var chart2 = dc.sunburstChart('#chart-container2', 'chartGroupA'); * * @param {String|node|d3.selection} parent - Any valid * {@link https://github.com/d3/d3-3.x-api-reference/blob/master/Selections.md#selecting-elements d3 single selector} specifying * a dom block element such as a div; or a dom element or d3 selection. * @param {String} [chartGroup] - The name of the chart group this chart instance should be placed in. * Interaction with a chart will only trigger events and redraws within the chart's group. * @returns {dc.sunburstChart} **/ dc.sunburstChart = function (parent, chartGroup) { var DEFAULT_MIN_ANGLE_FOR_LABEL = 0.5; var _sliceCssClass = 'pie-slice'; var _emptyCssClass = 'empty-chart'; var _emptyTitle = 'empty'; var _radius, _innerRadius = 0; var _g; var _cx; var _cy; var _minAngleForLabel = DEFAULT_MIN_ANGLE_FOR_LABEL; var _externalLabelRadius; var _chart = dc.capMixin(dc.colorMixin(dc.baseMixin({}))); _chart.colorAccessor(_chart.cappedKeyAccessor); _chart.title(function (d) { return _chart.cappedKeyAccessor(d) + ': ' + _chart.cappedValueAccessor(d); }); _chart.label(_chart.cappedKeyAccessor); _chart.renderLabel(true); _chart.transitionDuration(350); _chart.filterHandler(function (dimension, filters) { if (filters.length === 0) { dimension.filter(null); } else { dimension.filterFunction(function (d) { for (var i = 0; i < filters.length; i++) { var filter = filters[i]; if (filter.isFiltered && filter.isFiltered(d)) { return true; } } return false; }); } return filters; }); _chart._doRender = function () { _chart.resetSvg(); _g = _chart.svg() .append('g') .attr('transform', 'translate(' + _chart.cx() + ',' + _chart.cy() + ')'); drawChart(); return _chart; }; function drawChart () { // set radius on basis of chart dimension if missing _radius = _radius ? _radius : d3.min([_chart.width(), _chart.height()]) / 2; var arc = buildArcs(); var sunburstData, cdata; // if we have data... if (d3.sum(_chart.data(), _chart.valueAccessor())) { cdata = dc.utils.toHierarchy(_chart.data(), _chart.valueAccessor()); sunburstData = partitionNodes(cdata); // First one is the root, which is not needed sunburstData.shift(); _g.classed(_emptyCssClass, false); } else { // otherwise we'd be getting NaNs, so override // note: abuse others for its ignoring the value accessor cdata = dc.utils.toHierarchy([], function (d) { return d.value; }); sunburstData = partitionNodes(cdata); _g.classed(_emptyCssClass, true); } if (_g) { var slices = _g.selectAll('g.' + _sliceCssClass) .data(sunburstData); createElements(slices, arc, sunburstData); updateElements(sunburstData, arc); removeElements(slices); highlightFilter(); } } function createElements (slices, arc, sunburstData) { var slicesEnter = createSliceNodes(slices); createSlicePath(slicesEnter, arc); createTitles(slicesEnter); createLabels(sunburstData, arc); } function createSliceNodes (slices) { var slicesEnter = slices .enter() .append('g') .attr('class', function (d, i) { return _sliceCssClass + ' _' + i + ' ' + _sliceCssClass + '-level-' + d.depth; }); return slicesEnter; } function createSlicePath (slicesEnter, arc) { var slicePath = slicesEnter.append('path') .attr('fill', fill) .on('click', onClick) .attr('d', function (d, i) { return safeArc(d, i, arc); }); var transition = dc.transition(slicePath, _chart.transitionDuration()); if (transition.attrTween) { transition.attrTween('d', tweenSlice); } } function createTitles (slicesEnter) { if (_chart.renderTitle()) { slicesEnter.append('title').text(function (d) { return _chart.title()(d); }); } } function positionLabels (labelsEnter, arc) { dc.transition(labelsEnter, _chart.transitionDuration()) .attr('transform', function (d) { return labelPosition(d, arc); }) .attr('text-anchor', 'middle') .text(function (d) { // position label... if (sliceHasNoData(d) || sliceTooSmall(d)) { return ''; } return _chart.label()(d); }); } function createLabels (sunburstData, arc) { if (_chart.renderLabel()) { var labels = _g.selectAll('text.' + _sliceCssClass) .data(sunburstData); labels.exit().remove(); var labelsEnter = labels .enter() .append('text') .attr('class', function (d, i) { var classes = _sliceCssClass + ' _' + i; if (_externalLabelRadius) { classes += ' external'; } return classes; }) .on('click', onClick); positionLabels(labelsEnter, arc); } } function updateElements (sunburstData, arc) { updateSlicePaths(sunburstData, arc); updateLabels(sunburstData, arc); updateTitles(sunburstData); } function updateSlicePaths (sunburstData, arc) { var slicePaths = _g.selectAll('g.' + _sliceCssClass) .data(sunburstData) .select('path') .attr('d', function (d, i) { return safeArc(d, i, arc); }); var transition = dc.transition(slicePaths, _chart.transitionDuration()); if (transition.attrTween) { transition.attrTween('d', tweenSlice); } transition.attr('fill', fill); } function updateLabels (sunburstData, arc) { if (_chart.renderLabel()) { var labels = _g.selectAll('text.' + _sliceCssClass) .data(sunburstData); positionLabels(labels, arc); } } function updateTitles (sunburstData) { if (_chart.renderTitle()) { _g.selectAll('g.' + _sliceCssClass) .data(sunburstData) .select('title') .text(function (d) { return _chart.title()(d); }); } } function removeElements (slices) { slices.exit().remove(); } function highlightFilter () { if (_chart.hasFilter()) { _chart.selectAll('g.' + _sliceCssClass).each(function (d) { if (isSelectedSlice(d)) { _chart.highlightSelected(this); } else { _chart.fadeDeselected(this); } }); } else { _chart.selectAll('g.' + _sliceCssClass).each(function (d) { _chart.resetHighlight(this); }); } } /** * Get or set the inner radius of the sunburst chart. If the inner radius is greater than 0px then the * sunburst chart will be rendered as a doughnut chart. Default inner radius is 0px. * @method innerRadius * @memberof dc.sunburstChart * @instance * @param {Number} [innerRadius=0] * @returns {Number|dc.sunburstChart} */ _chart.innerRadius = function (innerRadius) { if (!arguments.length) { return _innerRadius; } _innerRadius = innerRadius; return _chart; }; /** * Get or set the outer radius. If the radius is not set, it will be half of the minimum of the * chart width and height. * @method radius * @memberof dc.sunburstChart * @instance * @param {Number} [radius] * @returns {Number|dc.sunburstChart} */ _chart.radius = function (radius) { if (!arguments.length) { return _radius; } _radius = radius; return _chart; }; /** * Get or set center x coordinate position. Default is center of svg. * @method cx * @memberof dc.sunburstChart * @instance * @param {Number} [cx] * @returns {Number|dc.sunburstChart} */ _chart.cx = function (cx) { if (!arguments.length) { return (_cx || _chart.width() / 2); } _cx = cx; return _chart; }; /** * Get or set center y coordinate position. Default is center of svg. * @method cy * @memberof dc.sunburstChart * @instance * @param {Number} [cy] * @returns {Number|dc.sunburstChart} */ _chart.cy = function (cy) { if (!arguments.length) { return (_cy || _chart.height() / 2); } _cy = cy; return _chart; }; /** * Get or set the minimal slice angle for label rendering. Any slice with a smaller angle will not * display a slice label. * @method minAngleForLabel * @memberof dc.sunburstChart * @instance * @param {Number} [minAngleForLabel=0.5] * @returns {Number|dc.sunburstChart} */ _chart.minAngleForLabel = function (minAngleForLabel) { if (!arguments.length) { return _minAngleForLabel; } _minAngleForLabel = minAngleForLabel; return _chart; }; /** * Title to use for the only slice when there is no data. * @method emptyTitle * @memberof dc.sunburstChart * @instance * @param {String} [title] * @returns {String|dc.sunburstChart} */ _chart.emptyTitle = function (title) { if (arguments.length === 0) { return _emptyTitle; } _emptyTitle = title; return _chart; }; /** * Position slice labels offset from the outer edge of the chart. * * The argument specifies the extra radius to be added for slice labels. * @method externalLabels * @memberof dc.sunburstChart * @instance * @param {Number} [externalLabelRadius] * @returns {Number|dc.sunburstChart} */ _chart.externalLabels = function (externalLabelRadius) { if (arguments.length === 0) { return _externalLabelRadius; } else if (externalLabelRadius) { _externalLabelRadius = externalLabelRadius; } else { _externalLabelRadius = undefined; } return _chart; }; function buildArcs () { return d3.arc() .startAngle(function (d) { return d.x0; }) .endAngle(function (d) { return d.x1; }) .innerRadius(function (d) { return d.data.path && d.data.path.length === 1 ? _innerRadius : Math.sqrt(d.y0); }) .outerRadius(function (d) { return Math.sqrt(d.y1); }); } function isSelectedSlice (d) { return isPathFiltered(d.path); } function isPathFiltered (path) { for (var i = 0; i < _chart.filters().length; i++) { var currentFilter = _chart.filters()[i]; if (currentFilter.isFiltered(path)) { return true; } } return false; } // returns all filters that are a parent or child of the path function filtersForPath (path) { var pathFilter = dc.filters.HierarchyFilter(path); var filters = []; for (var i = 0; i < _chart.filters().length; i++) { var currentFilter = _chart.filters()[i]; if (currentFilter.isFiltered(path) || pathFilter.isFiltered(currentFilter)) { filters.push(currentFilter); } } return filters; } _chart._doRedraw = function () { drawChart(); return _chart; }; function partitionNodes (data) { // The changes picked up from https://github.com/d3/d3-hierarchy/issues/50 var hierarchy = d3.hierarchy(data) .sum(function (d) { return d.children ? 0 : _chart.cappedValueAccessor(d); }) .sort(function (a, b) { return d3.ascending(a.data.path, b.data.path); }); var partition = d3.partition() .size([2 * Math.PI, _radius * _radius]); partition(hierarchy); // In D3v4 the returned data is slightly different, change it enough to suit our purposes. var nodes = hierarchy.descendants().map(function (d) { d.key = d.data.key; d.path = d.data.path; return d; }); return nodes; } function sliceTooSmall (d) { var angle = d.x1 - d.x0; return isNaN(angle) || angle < _minAngleForLabel; } function sliceHasNoData (d) { return _chart.cappedValueAccessor(d) === 0; } function tweenSlice (b) { b.innerRadius = _innerRadius; //? var current = this._current; if (isOffCanvas(current)) { current = {x: 0, y: 0, dx: 0, dy: 0}; } // unfortunally, we can't tween an entire hierarchy since it has 2 way links. var tweenTarget = {x: b.x, y: b.y, dx: b.dx, dy: b.dy}; var i = d3.interpolate(current, tweenTarget); this._current = i(0); return function (t) { return safeArc(Object.assign({}, b, i(t)), 0, buildArcs()); }; } function isOffCanvas (current) { return !current || isNaN(current.dx) || isNaN(current.dy); } function fill (d, i) { return _chart.getColor(d, i); } function _onClick (d) { // Clicking on Legends do not filter, it throws exception // Must be better way to handle this, in legends we need to access `d.key` var path = d.path || d.key; var filter = dc.filters.HierarchyFilter(path); // filters are equal to, parents or children of the path. var filters = filtersForPath(path); var exactMatch = false; // clear out any filters that cover the path filtered. for (var i = filters.length - 1; i >= 0; i--) { var currentFilter = filters[i]; if (dc.utils.arraysIdentical(currentFilter, path)) { exactMatch = true; } _chart.filter(filters[i]); } dc.events.trigger(function () { // if it is a new filter - put it in. if (!exactMatch) { _chart.filter(filter); } _chart.redrawGroup(); }); } _chart.onClick = onClick; function onClick (d, i) { if (_g.attr('class') !== _emptyCssClass) { _onClick(d, i); } } function safeArc (d, i, arc) { var path = arc(d, i); if (path.indexOf('NaN') >= 0) { path = 'M0,0'; } return path; } function labelPosition (d, arc) { var centroid; if (_externalLabelRadius) { centroid = d3.svg.arc() .outerRadius(_radius + _externalLabelRadius) .innerRadius(_radius + _externalLabelRadius) .centroid(d); } else { centroid = arc.centroid(d); } if (isNaN(centroid[0]) || isNaN(centroid[1])) { return 'translate(0,0)'; } else { return 'translate(' + centroid + ')'; } } _chart.legendables = function () { return _chart.data().map(function (d, i) { var legendable = {name: d.key, data: d.value, others: d.others, chart: _chart}; legendable.color = _chart.getColor(d, i); return legendable; }); }; _chart.legendHighlight = function (d) { highlightSliceFromLegendable(d, true); }; _chart.legendReset = function (d) { highlightSliceFromLegendable(d, false); }; _chart.legendToggle = function (d) { _chart.onClick({key: d.name, others: d.others}); }; function highlightSliceFromLegendable (legendable, highlighted) { _chart.selectAll('g.pie-slice').each(function (d) { if (legendable.name === d.key) { d3.select(this).classed('highlight', highlighted); } }); } return _chart.anchor(parent, chartGroup); }; /** * Concrete bar chart/histogram implementation. * * Examples: * - {@link http://dc-js.github.com/dc.js/ Nasdaq 100 Index} * - {@link http://dc-js.github.com/dc.js/crime/index.html Canadian City Crime Stats} * @class barChart * @memberof dc * @mixes dc.stackMixin * @mixes dc.coordinateGridMixin * @example * // create a bar chart under #chart-container1 element using the default global chart group * var chart1 = dc.barChart('#chart-container1'); * // create a bar chart under #chart-container2 element using chart group A * var chart2 = dc.barChart('#chart-container2', 'chartGroupA'); * // create a sub-chart under a composite parent chart * var chart3 = dc.barChart(compositeChart); * @param {String|node|d3.selection|dc.compositeChart} parent - Any valid * {@link https://github.com/d3/d3-selection/blob/master/README.md#select d3 single selector} * specifying a dom block element such as a div; or a dom element or d3 selection. If the bar * chart is a sub-chart in a {@link dc.compositeChart Composite Chart} then pass in the parent * composite chart instance instead. * @param {String} [chartGroup] - The name of the chart group this chart instance should be placed in. * Interaction with a chart will only trigger events and redraws within the chart's group. * @returns {dc.barChart} */ dc.barChart = function (parent, chartGroup) { var MIN_BAR_WIDTH = 1; var DEFAULT_GAP_BETWEEN_BARS = 2; var LABEL_PADDING = 3; var _chart = dc.stackMixin(dc.coordinateGridMixin({})); var _gap = DEFAULT_GAP_BETWEEN_BARS; var _centerBar = false; var _alwaysUseRounding = false; var _barWidth; dc.override(_chart, 'rescale', function () { _chart._rescale(); _barWidth = undefined; return _chart; }); dc.override(_chart, 'render', function () { if (_chart.round() && _centerBar && !_alwaysUseRounding) { dc.logger.warn('By default, brush rounding is disabled if bars are centered. ' + 'See dc.js bar chart API documentation for details.'); } return _chart._render(); }); _chart.label(function (d) { return dc.utils.printSingleValue(d.y0 + d.y); }, false); _chart.plotData = function () { var layers = _chart.chartBodyG().selectAll('g.stack') .data(_chart.data()); calculateBarWidth(); layers = layers .enter() .append('g') .attr('class', function (d, i) { return 'stack ' + '_' + i; }) .merge(layers); var last = layers.size() - 1; layers.each(function (d, i) { var layer = d3.select(this); renderBars(layer, i, d); if (_chart.renderLabel() && last === i) { renderLabels(layer, i, d); } }); }; function barHeight (d) { return dc.utils.safeNumber(Math.abs(_chart.y()(d.y + d.y0) - _chart.y()(d.y0))); } function labelXPos (d) { var x = _chart.x()(d.x); if (!_centerBar) { x += _barWidth / 2; } if (_chart.isOrdinal() && _gap !== undefined) { x += _gap / 2; } return dc.utils.safeNumber(x); } function labelYPos (d) { var y = _chart.y()(d.y + d.y0); if (d.y < 0) { y -= barHeight(d); } return dc.utils.safeNumber(y - LABEL_PADDING); } function renderLabels (layer, layerIndex, d) { var labels = layer.selectAll('text.barLabel') .data(d.values, dc.pluck('x')); var labelsEnterUpdate = labels .enter() .append('text') .attr('class', 'barLabel') .attr('text-anchor', 'middle') .attr('x', labelXPos) .attr('y', labelYPos) .merge(labels); if (_chart.isOrdinal()) { labelsEnterUpdate.on('click', _chart.onClick); labelsEnterUpdate.attr('cursor', 'pointer'); } dc.transition(labelsEnterUpdate, _chart.transitionDuration(), _chart.transitionDelay()) .attr('x', labelXPos) .attr('y', labelYPos) .text(function (d) { return _chart.label()(d); }); dc.transition(labels.exit(), _chart.transitionDuration(), _chart.transitionDelay()) .attr('height', 0) .remove(); } function barXPos (d) { var x = _chart.x()(d.x); if (_centerBar) { x -= _barWidth / 2; } if (_chart.isOrdinal() && _gap !== undefined) { x += _gap / 2; } return dc.utils.safeNumber(x); } function renderBars (layer, layerIndex, d) { var bars = layer.selectAll('rect.bar') .data(d.values, dc.pluck('x')); var enter = bars.enter() .append('rect') .attr('class', 'bar') .attr('fill', dc.pluck('data', _chart.getColor)) .attr('x', barXPos) .attr('y', _chart.yAxisHeight()) .attr('height', 0); var barsEnterUpdate = enter.merge(bars); if (_chart.renderTitle()) { enter.append('title').text(dc.pluck('data', _chart.title(d.name))); } if (_chart.isOrdinal()) { barsEnterUpdate.on('click', _chart.onClick); } dc.transition(barsEnterUpdate, _chart.transitionDuration(), _chart.transitionDelay()) .attr('x', barXPos) .attr('y', function (d) { var y = _chart.y()(d.y + d.y0); if (d.y < 0) { y -= barHeight(d); } return dc.utils.safeNumber(y); }) .attr('width', _barWidth) .attr('height', function (d) { return barHeight(d); }) .attr('fill', dc.pluck('data', _chart.getColor)) .select('title').text(dc.pluck('data', _chart.title(d.name))); dc.transition(bars.exit(), _chart.transitionDuration(), _chart.transitionDelay()) .attr('x', function (d) { return _chart.x()(d.x); }) .attr('width', _barWidth * 0.9) .remove(); } function calculateBarWidth () { if (_barWidth === undefined) { var numberOfBars = _chart.xUnitCount(); // please can't we always use rangeBands for bar charts? if (_chart.isOrdinal() && _gap === undefined) { _barWidth = Math.floor(_chart.x().bandwidth()); } else if (_gap) { _barWidth = Math.floor((_chart.xAxisLength() - (numberOfBars - 1) * _gap) / numberOfBars); } else { _barWidth = Math.floor(_chart.xAxisLength() / (1 + _chart.barPadding()) / numberOfBars); } if (_barWidth === Infinity || isNaN(_barWidth) || _barWidth < MIN_BAR_WIDTH) { _barWidth = MIN_BAR_WIDTH; } } } _chart.fadeDeselectedArea = function (brushSelection) { var bars = _chart.chartBodyG().selectAll('rect.bar'); if (_chart.isOrdinal()) { if (_chart.hasFilter()) { bars.classed(dc.constants.SELECTED_CLASS, function (d) { return _chart.hasFilter(d.x); }); bars.classed(dc.constants.DESELECTED_CLASS, function (d) { return !_chart.hasFilter(d.x); }); } else { bars.classed(dc.constants.SELECTED_CLASS, false); bars.classed(dc.constants.DESELECTED_CLASS, false); } } else if (_chart.brushOn() || _chart.parentBrushOn()) { if (!_chart.brushIsEmpty(brushSelection)) { var start = brushSelection[0]; var end = brushSelection[1]; bars.classed(dc.constants.DESELECTED_CLASS, function (d) { return d.x < start || d.x >= end; }); } else { bars.classed(dc.constants.DESELECTED_CLASS, false); } } }; /** * Whether the bar chart will render each bar centered around the data position on the x-axis. * @method centerBar * @memberof dc.barChart * @instance * @param {Boolean} [centerBar=false] * @returns {Boolean|dc.barChart} */ _chart.centerBar = function (centerBar) { if (!arguments.length) { return _centerBar; } _centerBar = centerBar; return _chart; }; dc.override(_chart, 'onClick', function (d) { _chart._onClick(d.data); }); /** * Get or set the spacing between bars as a fraction of bar size. Valid values are between 0-1. * Setting this value will also remove any previously set {@link dc.barChart#gap gap}. See the * {@link https://github.com/d3/d3-scale/blob/master/README.md#scaleBand d3 docs} * for a visual description of how the padding is applied. * @method barPadding * @memberof dc.barChart * @instance * @param {Number} [barPadding=0] * @returns {Number|dc.barChart} */ _chart.barPadding = function (barPadding) { if (!arguments.length) { return _chart._rangeBandPadding(); } _chart._rangeBandPadding(barPadding); _gap = undefined; return _chart; }; _chart._useOuterPadding = function () { return _gap === undefined; }; /** * Get or set the outer padding on an ordinal bar chart. This setting has no effect on non-ordinal charts. * Will pad the width by `padding * barWidth` on each side of the chart. * @method outerPadding * @memberof dc.barChart * @instance * @param {Number} [padding=0.5] * @returns {Number|dc.barChart} */ _chart.outerPadding = _chart._outerRangeBandPadding; /** * Manually set fixed gap (in px) between bars instead of relying on the default auto-generated * gap. By default the bar chart implementation will calculate and set the gap automatically * based on the number of data points and the length of the x axis. * @method gap * @memberof dc.barChart * @instance * @param {Number} [gap=2] * @returns {Number|dc.barChart} */ _chart.gap = function (gap) { if (!arguments.length) { return _gap; } _gap = gap; return _chart; }; _chart.extendBrush = function (brushSelection) { if (brushSelection && _chart.round() && (!_centerBar || _alwaysUseRounding)) { brushSelection[0] = _chart.round()(brushSelection[0]); brushSelection[1] = _chart.round()(brushSelection[1]); } return brushSelection; }; /** * Set or get whether rounding is enabled when bars are centered. If false, using * rounding with centered bars will result in a warning and rounding will be ignored. This flag * has no effect if bars are not {@link dc.barChart#centerBar centered}. * When using standard d3.js rounding methods, the brush often doesn't align correctly with * centered bars since the bars are offset. The rounding function must add an offset to * compensate, such as in the following example. * @method alwaysUseRounding * @memberof dc.barChart * @instance * @example * chart.round(function(n) { return Math.floor(n) + 0.5; }); * @param {Boolean} [alwaysUseRounding=false] * @returns {Boolean|dc.barChart} */ _chart.alwaysUseRounding = function (alwaysUseRounding) { if (!arguments.length) { return _alwaysUseRounding; } _alwaysUseRounding = alwaysUseRounding; return _chart; }; function colorFilter (color, inv) { return function () { var item = d3.select(this); var match = item.attr('fill') === color; return inv ? !match : match; }; } _chart.legendHighlight = function (d) { if (!_chart.isLegendableHidden(d)) { _chart.g().selectAll('rect.bar') .classed('highlight', colorFilter(d.color)) .classed('fadeout', colorFilter(d.color, true)); } }; _chart.legendReset = function () { _chart.g().selectAll('rect.bar') .classed('highlight', false) .classed('fadeout', false); }; dc.override(_chart, 'xAxisMax', function () { var max = this._xAxisMax(); if ('resolution' in _chart.xUnits()) { var res = _chart.xUnits().resolution; max += res; } return max; }); return _chart.anchor(parent, chartGroup); }; /** * Concrete line/area chart implementation. * * Examples: * - {@link http://dc-js.github.com/dc.js/ Nasdaq 100 Index} * - {@link http://dc-js.github.com/dc.js/crime/index.html Canadian City Crime Stats} * @class lineChart * @memberof dc * @mixes dc.stackMixin * @mixes dc.coordinateGridMixin * @example * // create a line chart under #chart-container1 element using the default global chart group * var chart1 = dc.lineChart('#chart-container1'); * // create a line chart under #chart-container2 element using chart group A * var chart2 = dc.lineChart('#chart-container2', 'chartGroupA'); * // create a sub-chart under a composite parent chart * var chart3 = dc.lineChart(compositeChart); * @param {String|node|d3.selection|dc.compositeChart} parent - Any valid * {@link https://github.com/d3/d3-selection/blob/master/README.md#select d3 single selector} * specifying a dom block element such as a div; or a dom element or d3 selection. If the line * chart is a sub-chart in a {@link dc.compositeChart Composite Chart} then pass in the parent * composite chart instance instead. * @param {String} [chartGroup] - The name of the chart group this chart instance should be placed in. * Interaction with a chart will only trigger events and redraws within the chart's group. * @returns {dc.lineChart} */ dc.lineChart = function (parent, chartGroup) { var DEFAULT_DOT_RADIUS = 5; var TOOLTIP_G_CLASS = 'dc-tooltip'; var DOT_CIRCLE_CLASS = 'dot'; var Y_AXIS_REF_LINE_CLASS = 'yRef'; var X_AXIS_REF_LINE_CLASS = 'xRef'; var DEFAULT_DOT_OPACITY = 1e-6; var LABEL_PADDING = 3; var _chart = dc.stackMixin(dc.coordinateGridMixin({})); var _renderArea = false; var _dotRadius = DEFAULT_DOT_RADIUS; var _dataPointRadius = null; var _dataPointFillOpacity = DEFAULT_DOT_OPACITY; var _dataPointStrokeOpacity = DEFAULT_DOT_OPACITY; var _curve = null; var _interpolate = null; // d3.curveLinear; // deprecated in 3.0 var _tension = null; // deprecated in 3.0 var _defined; var _dashStyle; var _xyTipsOn = true; _chart.transitionDuration(500); _chart.transitionDelay(0); _chart._rangeBandPadding(1); _chart.plotData = function () { var chartBody = _chart.chartBodyG(); var layersList = chartBody.select('g.stack-list'); if (layersList.empty()) { layersList = chartBody.append('g').attr('class', 'stack-list'); } var layers = layersList.selectAll('g.stack').data(_chart.data()); var layersEnter = layers .enter() .append('g') .attr('class', function (d, i) { return 'stack ' + '_' + i; }); layers = layersEnter.merge(layers); drawLine(layersEnter, layers); drawArea(layersEnter, layers); drawDots(chartBody, layers); if (_chart.renderLabel()) { drawLabels(layers); } }; /** * Gets or sets the curve factory to use for lines and areas drawn, allowing e.g. step * functions, splines, and cubic interpolation. Typically you would use one of the interpolator functions * provided by {@link https://github.com/d3/d3-shape/blob/master/README.md#curves d3 curves}. * * Replaces the use of {@link dc.lineChart#interpolate} and {@link dc.lineChart#tension} * in dc.js < 3.0 * * This is passed to * {@link https://github.com/d3/d3-shape/blob/master/README.md#line_curve line.curve} and * {@link https://github.com/d3/d3-shape/blob/master/README.md#area_curve area.curve}. * @example * // default * chart * .curve(d3.curveLinear); * // Add tension to curves that support it * chart * .curve(d3.curveCardinal.tension(0.5)); * // You can use some specialized variation like * // https://en.wikipedia.org/wiki/Centripetal_Catmull%E2%80%93Rom_spline * chart * .curve(d3.curveCatmullRom.alpha(0.5)); * @method curve * @memberof dc.lineChart * @instance * @see {@link https://github.com/d3/d3-shape/blob/master/README.md#line_curve line.curve} * @see {@link https://github.com/d3/d3-shape/blob/master/README.md#area_curve area.curve} * @param {d3.curve} [curve=d3.curveLinear] * @returns {d3.curve|dc.lineChart} */ _chart.curve = function (curve) { if (!arguments.length) { return _curve; } _curve = curve; return _chart; }; /** * Gets or sets the interpolator to use for lines drawn, by string name, allowing e.g. step * functions, splines, and cubic interpolation. * * Possible values are: 'linear', 'linear-closed', 'step', 'step-before', 'step-after', 'basis', * 'basis-open', 'basis-closed', 'bundle', 'cardinal', 'cardinal-open', 'cardinal-closed', and * 'monotone'. * * This function exists for backward compatibility. Use {@link dc.lineChart#curve} * which is generic and provides more options. * Value set through `.curve` takes precedence over `.interpolate` and `.tension`. * @method interpolate * @memberof dc.lineChart * @instance * @deprecated since version 3.0 use {@link dc.lineChart#curve} instead * @see {@link dc.lineChart#curve} * @param {d3.curve} [interpolate=d3.curveLinear] * @returns {d3.curve|dc.lineChart} */ _chart.interpolate = dc.logger.deprecate(function (interpolate) { if (!arguments.length) { return _interpolate; } _interpolate = interpolate; return _chart; }, 'dc.lineChart.interpolate has been deprecated since version 3.0 use dc.lineChart.curve instead'); /** * Gets or sets the tension to use for lines drawn, in the range 0 to 1. * * Passed to the {@link https://github.com/d3/d3-shape/blob/master/README.md#curves d3 curve function} * if it provides a `.tension` function. Example: * {@link https://github.com/d3/d3-shape/blob/master/README.md#curveCardinal_tension curveCardinal.tension}. * * This function exists for backward compatibility. Use {@link dc.lineChart#curve} * which is generic and provides more options. * Value set through `.curve` takes precedence over `.interpolate` and `.tension`. * @method tension * @memberof dc.lineChart * @instance * @deprecated since version 3.0 use {@link dc.lineChart#curve} instead * @see {@link dc.lineChart#curve} * @param {Number} [tension=0] * @returns {Number|dc.lineChart} */ _chart.tension = dc.logger.deprecate(function (tension) { if (!arguments.length) { return _tension; } _tension = tension; return _chart; }, 'dc.lineChart.tension has been deprecated since version 3.0 use dc.lineChart.curve instead'); /** * Gets or sets a function that will determine discontinuities in the line which should be * skipped: the path will be broken into separate subpaths if some points are undefined. * This function is passed to * {@link https://github.com/d3/d3-shape/blob/master/README.md#line_defined line.defined} * * Note: crossfilter will sometimes coerce nulls to 0, so you may need to carefully write * custom reduce functions to get this to work, depending on your data. See * {@link https://github.com/dc-js/dc.js/issues/615#issuecomment-49089248 this GitHub comment} * for more details and an example. * @method defined * @memberof dc.lineChart * @instance * @see {@link https://github.com/d3/d3-shape/blob/master/README.md#line_defined line.defined} * @param {Function} [defined] * @returns {Function|dc.lineChart} */ _chart.defined = function (defined) { if (!arguments.length) { return _defined; } _defined = defined; return _chart; }; /** * Set the line's d3 dashstyle. This value becomes the 'stroke-dasharray' of line. Defaults to empty * array (solid line). * @method dashStyle * @memberof dc.lineChart * @instance * @see {@link https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/stroke-dasharray stroke-dasharray} * @example * // create a Dash Dot Dot Dot * chart.dashStyle([3,1,1,1]); * @param {Array} [dashStyle=[]] * @returns {Array|dc.lineChart} */ _chart.dashStyle = function (dashStyle) { if (!arguments.length) { return _dashStyle; } _dashStyle = dashStyle; return _chart; }; /** * Get or set render area flag. If the flag is set to true then the chart will render the area * beneath each line and the line chart effectively becomes an area chart. * @method renderArea * @memberof dc.lineChart * @instance * @param {Boolean} [renderArea=false] * @returns {Boolean|dc.lineChart} */ _chart.renderArea = function (renderArea) { if (!arguments.length) { return _renderArea; } _renderArea = renderArea; return _chart; }; function colors (d, i) { return _chart.getColor.call(d, d.values, i); } // To keep it backward compatible, this covers multiple cases // See https://github.com/dc-js/dc.js/issues/1376 // It will be removed when interpolate and tension are removed. function getCurveFactory () { var curve = null; // _curve takes precedence if (_curve) { return _curve; } // Approximate the D3v3 behavior if (typeof _interpolate === 'function') { curve = _interpolate; } else { // If _interpolate is string var mapping = { 'linear': d3.curveLinear, 'linear-closed': d3.curveLinearClosed, 'step': d3.curveStep, 'step-before': d3.curveStepBefore, 'step-after': d3.curveStepAfter, 'basis': d3.curveBasis, 'basis-open': d3.curveBasisOpen, 'basis-closed': d3.curveBasisClosed, 'bundle': d3.curveBundle, 'cardinal': d3.curveCardinal, 'cardinal-open': d3.curveCardinalOpen, 'cardinal-closed': d3.curveCardinalClosed, 'monotone': d3.curveMonotoneX }; curve = mapping[_interpolate]; } // Default value if (!curve) { curve = d3.curveLinear; } if (_tension !== null) { if (typeof curve.tension !== 'function') { dc.logger.warn('tension was specified but the curve/interpolate does not support it.'); } else { curve = curve.tension(_tension); } } return curve; } function drawLine (layersEnter, layers) { var line = d3.line() .x(function (d) { return _chart.x()(d.x); }) .y(function (d) { return _chart.y()(d.y + d.y0); }) .curve(getCurveFactory()); if (_defined) { line.defined(_defined); } var path = layersEnter.append('path') .attr('class', 'line') .attr('stroke', colors); if (_dashStyle) { path.attr('stroke-dasharray', _dashStyle); } dc.transition(layers.select('path.line'), _chart.transitionDuration(), _chart.transitionDelay()) //.ease('linear') .attr('stroke', colors) .attr('d', function (d) { return safeD(line(d.values)); }); } function drawArea (layersEnter, layers) { if (_renderArea) { var area = d3.area() .x(function (d) { return _chart.x()(d.x); }) .y1(function (d) { return _chart.y()(d.y + d.y0); }) .y0(function (d) { return _chart.y()(d.y0); }) .curve(getCurveFactory()); if (_defined) { area.defined(_defined); } layersEnter.append('path') .attr('class', 'area') .attr('fill', colors) .attr('d', function (d) { return safeD(area(d.values)); }); dc.transition(layers.select('path.area'), _chart.transitionDuration(), _chart.transitionDelay()) //.ease('linear') .attr('fill', colors) .attr('d', function (d) { return safeD(area(d.values)); }); } } function safeD (d) { return (!d || d.indexOf('NaN') >= 0) ? 'M0,0' : d; } function drawDots (chartBody, layers) { if (_chart.xyTipsOn() === 'always' || (!(_chart.brushOn() || _chart.parentBrushOn()) && _chart.xyTipsOn())) { var tooltipListClass = TOOLTIP_G_CLASS + '-list'; var tooltips = chartBody.select('g.' + tooltipListClass); if (tooltips.empty()) { tooltips = chartBody.append('g').attr('class', tooltipListClass); } layers.each(function (d, layerIndex) { var points = d.values; if (_defined) { points = points.filter(_defined); } var g = tooltips.select('g.' + TOOLTIP_G_CLASS + '._' + layerIndex); if (g.empty()) { g = tooltips.append('g').attr('class', TOOLTIP_G_CLASS + ' _' + layerIndex); } createRefLines(g); var dots = g.selectAll('circle.' + DOT_CIRCLE_CLASS) .data(points, dc.pluck('x')); var dotsEnterModify = dots .enter() .append('circle') .attr('class', DOT_CIRCLE_CLASS) .attr('cx', function (d) { return dc.utils.safeNumber(_chart.x()(d.x)); }) .attr('cy', function (d) { return dc.utils.safeNumber(_chart.y()(d.y + d.y0)); }) .attr('r', getDotRadius()) .style('fill-opacity', _dataPointFillOpacity) .style('stroke-opacity', _dataPointStrokeOpacity) .attr('fill', _chart.getColor) .on('mousemove', function () { var dot = d3.select(this); showDot(dot); showRefLines(dot, g); }) .on('mouseout', function () { var dot = d3.select(this); hideDot(dot); hideRefLines(g); }) .merge(dots); dotsEnterModify.call(renderTitle, d); dc.transition(dotsEnterModify, _chart.transitionDuration()) .attr('cx', function (d) { return dc.utils.safeNumber(_chart.x()(d.x)); }) .attr('cy', function (d) { return dc.utils.safeNumber(_chart.y()(d.y + d.y0)); }) .attr('fill', _chart.getColor); dots.exit().remove(); }); } } _chart.label(function (d) { return dc.utils.printSingleValue(d.y0 + d.y); }, false); function drawLabels (layers) { layers.each(function (d, layerIndex) { var layer = d3.select(this); var labels = layer.selectAll('text.lineLabel') .data(d.values, dc.pluck('x')); var labelsEnterModify = labels .enter() .append('text') .attr('class', 'lineLabel') .attr('text-anchor', 'middle') .merge(labels); dc.transition(labelsEnterModify, _chart.transitionDuration()) .attr('x', function (d) { return dc.utils.safeNumber(_chart.x()(d.x)); }) .attr('y', function (d) { var y = _chart.y()(d.y + d.y0) - LABEL_PADDING; return dc.utils.safeNumber(y); }) .text(function (d) { return _chart.label()(d); }); dc.transition(labels.exit(), _chart.transitionDuration()) .attr('height', 0) .remove(); }); } function createRefLines (g) { var yRefLine = g.select('path.' + Y_AXIS_REF_LINE_CLASS).empty() ? g.append('path').attr('class', Y_AXIS_REF_LINE_CLASS) : g.select('path.' + Y_AXIS_REF_LINE_CLASS); yRefLine.style('display', 'none').attr('stroke-dasharray', '5,5'); var xRefLine = g.select('path.' + X_AXIS_REF_LINE_CLASS).empty() ? g.append('path').attr('class', X_AXIS_REF_LINE_CLASS) : g.select('path.' + X_AXIS_REF_LINE_CLASS); xRefLine.style('display', 'none').attr('stroke-dasharray', '5,5'); } function showDot (dot) { dot.style('fill-opacity', 0.8); dot.style('stroke-opacity', 0.8); dot.attr('r', _dotRadius); return dot; } function showRefLines (dot, g) { var x = dot.attr('cx'); var y = dot.attr('cy'); var yAxisX = (_chart._yAxisX() - _chart.margins().left); var yAxisRefPathD = 'M' + yAxisX + ' ' + y + 'L' + (x) + ' ' + (y); var xAxisRefPathD = 'M' + x + ' ' + _chart.yAxisHeight() + 'L' + x + ' ' + y; g.select('path.' + Y_AXIS_REF_LINE_CLASS).style('display', '').attr('d', yAxisRefPathD); g.select('path.' + X_AXIS_REF_LINE_CLASS).style('display', '').attr('d', xAxisRefPathD); } function getDotRadius () { return _dataPointRadius || _dotRadius; } function hideDot (dot) { dot.style('fill-opacity', _dataPointFillOpacity) .style('stroke-opacity', _dataPointStrokeOpacity) .attr('r', getDotRadius()); } function hideRefLines (g) { g.select('path.' + Y_AXIS_REF_LINE_CLASS).style('display', 'none'); g.select('path.' + X_AXIS_REF_LINE_CLASS).style('display', 'none'); } function renderTitle (dot, d) { if (_chart.renderTitle()) { dot.select('title').remove(); dot.append('title').text(dc.pluck('data', _chart.title(d.name))); } } /** * Turn on/off the mouseover behavior of an individual data point which renders a circle and x/y axis * dashed lines back to each respective axis. This is ignored if the chart * {@link dc.coordinateGridMixin#brushOn brush} is on * @method xyTipsOn * @memberof dc.lineChart * @instance * @param {Boolean} [xyTipsOn=false] * @returns {Boolean|dc.lineChart} */ _chart.xyTipsOn = function (xyTipsOn) { if (!arguments.length) { return _xyTipsOn; } _xyTipsOn = xyTipsOn; return _chart; }; /** * Get or set the radius (in px) for dots displayed on the data points. * @method dotRadius * @memberof dc.lineChart * @instance * @param {Number} [dotRadius=5] * @returns {Number|dc.lineChart} */ _chart.dotRadius = function (dotRadius) { if (!arguments.length) { return _dotRadius; } _dotRadius = dotRadius; return _chart; }; /** * Always show individual dots for each datapoint. * * If `options` is falsy, it disables data point rendering. If no `options` are provided, the * current `options` values are instead returned. * @method renderDataPoints * @memberof dc.lineChart * @instance * @example * chart.renderDataPoints({radius: 2, fillOpacity: 0.8, strokeOpacity: 0.8}) * @param {{fillOpacity: Number, strokeOpacity: Number, radius: Number}} [options={fillOpacity: 0.8, strokeOpacity: 0.8, radius: 2}] * @returns {{fillOpacity: Number, strokeOpacity: Number, radius: Number}|dc.lineChart} */ _chart.renderDataPoints = function (options) { if (!arguments.length) { return { fillOpacity: _dataPointFillOpacity, strokeOpacity: _dataPointStrokeOpacity, radius: _dataPointRadius }; } else if (!options) { _dataPointFillOpacity = DEFAULT_DOT_OPACITY; _dataPointStrokeOpacity = DEFAULT_DOT_OPACITY; _dataPointRadius = null; } else { _dataPointFillOpacity = options.fillOpacity || 0.8; _dataPointStrokeOpacity = options.strokeOpacity || 0.8; _dataPointRadius = options.radius || 2; } return _chart; }; function colorFilter (color, dashstyle, inv) { return function () { var item = d3.select(this); var match = (item.attr('stroke') === color && item.attr('stroke-dasharray') === ((dashstyle instanceof Array) ? dashstyle.join(',') : null)) || item.attr('fill') === color; return inv ? !match : match; }; } _chart.legendHighlight = function (d) { if (!_chart.isLegendableHidden(d)) { _chart.g().selectAll('path.line, path.area') .classed('highlight', colorFilter(d.color, d.dashstyle)) .classed('fadeout', colorFilter(d.color, d.dashstyle, true)); } }; _chart.legendReset = function () { _chart.g().selectAll('path.line, path.area') .classed('highlight', false) .classed('fadeout', false); }; dc.override(_chart, 'legendables', function () { var legendables = _chart._legendables(); if (!_dashStyle) { return legendables; } return legendables.map(function (l) { l.dashstyle = _dashStyle; return l; }); }); return _chart.anchor(parent, chartGroup); }; /** * The data count widget is a simple widget designed to display the number of records selected by the * current filters out of the total number of records in the data set. Once created the data count widget * will automatically update the text content of child elements with the following classes: * * * `.total-count` - total number of records * * `.filter-count` - number of records matched by the current filters * * Note: this widget works best for the specific case of showing the number of records out of a * total. If you want a more general-purpose numeric display, please use the * {@link dc.numberDisplay} widget instead. * * Examples: * - {@link http://dc-js.github.com/dc.js/ Nasdaq 100 Index} * @class dataCount * @memberof dc * @mixes dc.baseMixin * @example * var ndx = crossfilter(data); * var all = ndx.groupAll(); * * dc.dataCount('.dc-data-count') * .dimension(ndx) * .group(all); * @param {String|node|d3.selection} parent - Any valid * {@link https://github.com/d3/d3-selection/blob/master/README.md#select d3 single selector} specifying * a dom block element such as a div; or a dom element or d3 selection. * @param {String} [chartGroup] - The name of the chart group this chart instance should be placed in. * Interaction with a chart will only trigger events and redraws within the chart's group. * @returns {dc.dataCount} */ dc.dataCount = function (parent, chartGroup) { var _formatNumber = d3.format(',d'); var _chart = dc.baseMixin({}); var _html = {some: '', all: ''}; /** * Gets or sets an optional object specifying HTML templates to use depending how many items are * selected. The text `%total-count` will replaced with the total number of records, and the text * `%filter-count` will be replaced with the number of selected records. * - all: HTML template to use if all items are selected * - some: HTML template to use if not all items are selected * @method html * @memberof dc.dataCount * @instance * @example * counter.html({ * some: '%filter-count out of %total-count records selected', * all: 'All records selected. Click on charts to apply filters' * }) * @param {{some:String, all: String}} [options] * @returns {{some:String, all: String}|dc.dataCount} */ _chart.html = function (options) { if (!arguments.length) { return _html; } if (options.all) { _html.all = options.all; } if (options.some) { _html.some = options.some; } return _chart; }; /** * Gets or sets an optional function to format the filter count and total count. * @method formatNumber * @memberof dc.dataCount * @instance * @see {@link https://github.com/d3/d3-format/blob/master/README.md#format d3.format} * @example * counter.formatNumber(d3.format('.2g')) * @param {Function} [formatter=d3.format('.2g')] * @returns {Function|dc.dataCount} */ _chart.formatNumber = function (formatter) { if (!arguments.length) { return _formatNumber; } _formatNumber = formatter; return _chart; }; _chart._doRender = function () { var tot = _chart.dimension().size(), val = _chart.group().value(); var all = _formatNumber(tot); var selected = _formatNumber(val); if ((tot === val) && (_html.all !== '')) { _chart.root().html(_html.all.replace('%total-count', all).replace('%filter-count', selected)); } else if (_html.some !== '') { _chart.root().html(_html.some.replace('%total-count', all).replace('%filter-count', selected)); } else { _chart.selectAll('.total-count').text(all); _chart.selectAll('.filter-count').text(selected); } return _chart; }; _chart._doRedraw = function () { return _chart._doRender(); }; return _chart.anchor(parent, chartGroup); }; /** * The data table is a simple widget designed to list crossfilter focused data set (rows being * filtered) in a good old tabular fashion. * * Note: Unlike other charts, the data table (and data grid chart) use the {@link dc.dataTable#group group} attribute as a * keying function for {@link https://github.com/d3/d3-collection/blob/master/README.md#nest nesting} the data * together in groups. Do not pass in a crossfilter group as this will not work. * * Another interesting feature of the data table is that you can pass a crossfilter group to the `dimension`, as * long as you specify the {@link dc.dataTable#order order} as `d3.descending`, since the data * table will use `dimension.top()` to fetch the data in that case, and the method is equally * supported on the crossfilter group as the crossfilter dimension. * * Examples: * - {@link http://dc-js.github.com/dc.js/ Nasdaq 100 Index} * - {@link http://dc-js.github.io/dc.js/examples/table-on-aggregated-data.html dataTable on a crossfilter group} * ({@link https://github.com/dc-js/dc.js/blob/develop/web/examples/table-on-aggregated-data.html source}) * @class dataTable * @memberof dc * @mixes dc.baseMixin * @param {String|node|d3.selection} parent - Any valid * {@link https://github.com/d3/d3-selection/blob/master/README.md#select d3 single selector} specifying * a dom block element such as a div; or a dom element or d3 selection. * @param {String} [chartGroup] - The name of the chart group this chart instance should be placed in. * Interaction with a chart will only trigger events and redraws within the chart's group. * @returns {dc.dataTable} */ dc.dataTable = function (parent, chartGroup) { var LABEL_CSS_CLASS = 'dc-table-label'; var ROW_CSS_CLASS = 'dc-table-row'; var COLUMN_CSS_CLASS = 'dc-table-column'; var GROUP_CSS_CLASS = 'dc-table-group'; var HEAD_CSS_CLASS = 'dc-table-head'; var _chart = dc.baseMixin({}); var _size = 25; var _columns = []; var _sortBy = function (d) { return d; }; var _order = d3.ascending; var _beginSlice = 0; var _endSlice; var _showGroups = true; _chart._doRender = function () { _chart.selectAll('tbody').remove(); renderRows(renderGroups()); return _chart; }; _chart._doColumnValueFormat = function (v, d) { return ((typeof v === 'function') ? v(d) : // v as function ((typeof v === 'string') ? d[v] : // v is field name string v.format(d) // v is Object, use fn (element 2) ) ); }; _chart._doColumnHeaderFormat = function (d) { // if 'function', convert to string representation // show a string capitalized // if an object then display its label string as-is. return (typeof d === 'function') ? _chart._doColumnHeaderFnToString(d) : ((typeof d === 'string') ? _chart._doColumnHeaderCapitalize(d) : String(d.label)); }; _chart._doColumnHeaderCapitalize = function (s) { // capitalize return s.charAt(0).toUpperCase() + s.slice(1); }; _chart._doColumnHeaderFnToString = function (f) { // columnString(f) { var s = String(f); var i1 = s.indexOf('return '); if (i1 >= 0) { var i2 = s.lastIndexOf(';'); if (i2 >= 0) { s = s.substring(i1 + 7, i2); var i3 = s.indexOf('numberFormat'); if (i3 >= 0) { s = s.replace('numberFormat', ''); } } } return s; }; function renderGroups () { // The 'original' example uses all 'functions'. // If all 'functions' are used, then don't remove/add a header, and leave // the html alone. This preserves the functionality of earlier releases. // A 2nd option is a string representing a field in the data. // A third option is to supply an Object such as an array of 'information', and // supply your own _doColumnHeaderFormat and _doColumnValueFormat functions to // create what you need. var bAllFunctions = true; _columns.forEach(function (f) { bAllFunctions = bAllFunctions & (typeof f === 'function'); }); if (!bAllFunctions) { // ensure one thead var thead = _chart.selectAll('thead').data([0]); thead.exit().remove(); thead = thead.enter() .append('thead') .merge(thead); // with one tr var headrow = thead.selectAll('tr').data([0]); headrow.exit().remove(); headrow = headrow.enter() .append('tr') .merge(headrow); // with a th for each column var headcols = headrow.selectAll('th') .data(_columns); headcols.exit().remove(); headcols.enter().append('th') .merge(headcols) .attr('class', HEAD_CSS_CLASS) .html(function (d) { return (_chart._doColumnHeaderFormat(d)); }); } var groups = _chart.root().selectAll('tbody') .data(nestEntries(), function (d) { return _chart.keyAccessor()(d); }); var rowGroup = groups .enter() .append('tbody'); if (_showGroups === true) { rowGroup .append('tr') .attr('class', GROUP_CSS_CLASS) .append('td') .attr('class', LABEL_CSS_CLASS) .attr('colspan', _columns.length) .html(function (d) { return _chart.keyAccessor()(d); }); } groups.exit().remove(); return rowGroup; } function nestEntries () { var entries; if (_order === d3.ascending) { entries = _chart.dimension().bottom(_size); } else { entries = _chart.dimension().top(_size); } return d3.nest() .key(_chart.group()) .sortKeys(_order) .entries(entries.sort(function (a, b) { return _order(_sortBy(a), _sortBy(b)); }).slice(_beginSlice, _endSlice)); } function renderRows (groups) { var rows = groups.order() .selectAll('tr.' + ROW_CSS_CLASS) .data(function (d) { return d.values; }); var rowEnter = rows.enter() .append('tr') .attr('class', ROW_CSS_CLASS); _columns.forEach(function (v, i) { rowEnter.append('td') .attr('class', COLUMN_CSS_CLASS + ' _' + i) .html(function (d) { return _chart._doColumnValueFormat(v, d); }); }); rows.exit().remove(); return rows; } _chart._doRedraw = function () { return _chart._doRender(); }; /** * Get or set the group function for the data table. The group function takes a data row and * returns the key to specify to {@link https://github.com/d3/d3-collection/blob/master/README.md#nest d3.nest} * to split rows into groups. * * Do not pass in a crossfilter group as this will not work. * @method group * @memberof dc.dataTable * @instance * @example * // group rows by the value of their field * chart * .group(function(d) { return d.field; }) * @param {Function} groupFunction Function taking a row of data and returning the nest key. * @returns {Function|dc.dataTable} */ /** * Get or set the table size which determines the number of rows displayed by the widget. * @method size * @memberof dc.dataTable * @instance * @param {Number} [size=25] * @returns {Number|dc.dataTable} */ _chart.size = function (size) { if (!arguments.length) { return _size; } _size = size; return _chart; }; /** * Get or set the index of the beginning slice which determines which entries get displayed * by the widget. Useful when implementing pagination. * * Note: the sortBy function will determine how the rows are ordered for pagination purposes. * See the {@link http://dc-js.github.io/dc.js/examples/table-pagination.html table pagination example} * to see how to implement the pagination user interface using `beginSlice` and `endSlice`. * @method beginSlice * @memberof dc.dataTable * @instance * @param {Number} [beginSlice=0] * @returns {Number|dc.dataTable} */ _chart.beginSlice = function (beginSlice) { if (!arguments.length) { return _beginSlice; } _beginSlice = beginSlice; return _chart; }; /** * Get or set the index of the end slice which determines which entries get displayed by the * widget. Useful when implementing pagination. See {@link dc.dataTable#beginSlice `beginSlice`} for more information. * @method endSlice * @memberof dc.dataTable * @instance * @param {Number|undefined} [endSlice=undefined] * @returns {Number|dc.dataTable} */ _chart.endSlice = function (endSlice) { if (!arguments.length) { return _endSlice; } _endSlice = endSlice; return _chart; }; /** * Get or set column functions. The data table widget supports several methods of specifying the * columns to display. * * The original method uses an array of functions to generate dynamic columns. Column functions * are simple javascript functions with only one input argument `d` which represents a row in * the data set. The return value of these functions will be used to generate the content for * each cell. However, this method requires the HTML for the table to have a fixed set of column * headers. * *
chart.columns([
     *     function(d) { return d.date; },
     *     function(d) { return d.open; },
     *     function(d) { return d.close; },
     *     function(d) { return numberFormat(d.close - d.open); },
     *     function(d) { return d.volume; }
     * ]);
     * 
* * In the second method, you can list the columns to read from the data without specifying it as * a function, except where necessary (ie, computed columns). Note the data element name is * capitalized when displayed in the table header. You can also mix in functions as necessary, * using the third `{label, format}` form, as shown below. * *
chart.columns([
     *     "date",    // d["date"], ie, a field accessor; capitalized automatically
     *     "open",    // ...
     *     "close",   // ...
     *     {
     *         label: "Change",
     *         format: function (d) {
     *             return numberFormat(d.close - d.open);
     *         }
     *     },
     *     "volume"   // d["volume"], ie, a field accessor; capitalized automatically
     * ]);
     * 
* * In the third example, we specify all fields using the `{label, format}` method: *
chart.columns([
     *     {
     *         label: "Date",
     *         format: function (d) { return d.date; }
     *     },
     *     {
     *         label: "Open",
     *         format: function (d) { return numberFormat(d.open); }
     *     },
     *     {
     *         label: "Close",
     *         format: function (d) { return numberFormat(d.close); }
     *     },
     *     {
     *         label: "Change",
     *         format: function (d) { return numberFormat(d.close - d.open); }
     *     },
     *     {
     *         label: "Volume",
     *         format: function (d) { return d.volume; }
     *     }
     * ]);
     * 
* * You may wish to override the dataTable functions `_doColumnHeaderCapitalize` and * `_doColumnHeaderFnToString`, which are used internally to translate the column information or * function into a displayed header. The first one is used on the "string" column specifier; the * second is used to transform a stringified function into something displayable. For the Stock * example, the function for Change becomes the table header **d.close - d.open**. * * Finally, you can even specify a completely different form of column definition. To do this, * override `_chart._doColumnHeaderFormat` and `_chart._doColumnValueFormat` Be aware that * fields without numberFormat specification will be displayed just as they are stored in the * data, unformatted. * @method columns * @memberof dc.dataTable * @instance * @param {Array} [columns=[]] * @returns {Array}|dc.dataTable} */ _chart.columns = function (columns) { if (!arguments.length) { return _columns; } _columns = columns; return _chart; }; /** * Get or set sort-by function. This function works as a value accessor at row level and returns a * particular field to be sorted by. * @method sortBy * @memberof dc.dataTable * @instance * @example * chart.sortBy(function(d) { * return d.date; * }); * @param {Function} [sortBy=identity function] * @returns {Function|dc.dataTable} */ _chart.sortBy = function (sortBy) { if (!arguments.length) { return _sortBy; } _sortBy = sortBy; return _chart; }; /** * Get or set sort order. If the order is `d3.ascending`, the data table will use * `dimension().bottom()` to fetch the data; otherwise it will use `dimension().top()` * @method order * @memberof dc.dataTable * @instance * @see {@link https://github.com/d3/d3-array/blob/master/README.md#ascending d3.ascending} * @see {@link https://github.com/d3/d3-array/blob/master/README.md#descending d3.descending} * @example * chart.order(d3.descending); * @param {Function} [order=d3.ascending] * @returns {Function|dc.dataTable} */ _chart.order = function (order) { if (!arguments.length) { return _order; } _order = order; return _chart; }; /** * Get or set if group rows will be shown. The dataTable {@link dc.dataTable#group group} * function must be specified even if groups are not shown. * @method showGroups * @memberof dc.dataTable * @instance * @example * chart * .group([value], [name]) * .showGroups(true|false); * @param {Boolean} [showGroups=true] * @returns {Boolean|dc.dataTable} */ _chart.showGroups = function (showGroups) { if (!arguments.length) { return _showGroups; } _showGroups = showGroups; return _chart; }; return _chart.anchor(parent, chartGroup); }; /** * Data grid is a simple widget designed to list the filtered records, providing * a simple way to define how the items are displayed. * * Note: Unlike other charts, the data grid chart (and data table) use the {@link dc.dataGrid#group group} attribute as a keying function * for {@link https://github.com/d3/d3-collection/blob/master/README.md#nest nesting} the data together in groups. * Do not pass in a crossfilter group as this will not work. * * Examples: * - {@link http://europarl.me/dc.js/web/ep/index.html List of members of the european parliament} * @class dataGrid * @memberof dc * @mixes dc.baseMixin * @param {String|node|d3.selection} parent - Any valid * {@link https://github.com/d3/d3-selection/blob/master/README.md#select d3 single selector} specifying * a dom block element such as a div; or a dom element or d3 selection. * @param {String} [chartGroup] - The name of the chart group this chart instance should be placed in. * Interaction with a chart will only trigger events and redraws within the chart's group. * @returns {dc.dataGrid} */ dc.dataGrid = function (parent, chartGroup) { var LABEL_CSS_CLASS = 'dc-grid-label'; var ITEM_CSS_CLASS = 'dc-grid-item'; var GROUP_CSS_CLASS = 'dc-grid-group'; var GRID_CSS_CLASS = 'dc-grid-top'; var _chart = dc.baseMixin({}); var _size = 999; // shouldn't be needed, but you might var _html = function (d) { return 'you need to provide an html() handling param: ' + JSON.stringify(d); }; var _sortBy = function (d) { return d; }; var _order = d3.ascending; var _beginSlice = 0, _endSlice; var _htmlGroup = function (d) { return '

' + _chart.keyAccessor()(d) + '

'; }; _chart._doRender = function () { _chart.selectAll('div.' + GRID_CSS_CLASS).remove(); renderItems(renderGroups()); return _chart; }; function renderGroups () { var groups = _chart.root().selectAll('div.' + GRID_CSS_CLASS) .data(nestEntries(), function (d) { return _chart.keyAccessor()(d); }); var itemGroup = groups .enter() .append('div') .attr('class', GRID_CSS_CLASS); if (_htmlGroup) { itemGroup .html(function (d) { return _htmlGroup(d); }); } groups.exit().remove(); return itemGroup; } function nestEntries () { var entries = _chart.dimension().top(_size); return d3.nest() .key(_chart.group()) .sortKeys(_order) .entries(entries.sort(function (a, b) { return _order(_sortBy(a), _sortBy(b)); }).slice(_beginSlice, _endSlice)); } function renderItems (groups) { var items = groups.order() .selectAll('div.' + ITEM_CSS_CLASS) .data(function (d) { return d.values; }); items.exit().remove(); items = items .enter() .append('div') .attr('class', ITEM_CSS_CLASS) .html(function (d) { return _html(d); }) .merge(items); return items; } _chart._doRedraw = function () { return _chart._doRender(); }; /** * Get or set the group function for the data grid. The group function takes a data row and * returns the key to specify to {@link https://github.com/d3/d3-collection/blob/master/README.md#nest d3.nest} * to split rows into groups. * * Do not pass in a crossfilter group as this will not work. * @method group * @memberof dc.dataGrid * @instance * @example * // group rows by the value of their field * chart * .group(function(d) { return d.field; }) * @param {Function} groupFunction Function taking a row of data and returning the nest key. * @returns {Function|dc.dataTable} */ /** * Get or set the index of the beginning slice which determines which entries get displayed by the widget. * Useful when implementing pagination. * @method beginSlice * @memberof dc.dataGrid * @instance * @param {Number} [beginSlice=0] * @returns {Number|dc.dataGrid} */ _chart.beginSlice = function (beginSlice) { if (!arguments.length) { return _beginSlice; } _beginSlice = beginSlice; return _chart; }; /** * Get or set the index of the end slice which determines which entries get displayed by the widget. * Useful when implementing pagination. * @method endSlice * @memberof dc.dataGrid * @instance * @param {Number} [endSlice] * @returns {Number|dc.dataGrid} */ _chart.endSlice = function (endSlice) { if (!arguments.length) { return _endSlice; } _endSlice = endSlice; return _chart; }; /** * Get or set the grid size which determines the number of items displayed by the widget. * @method size * @memberof dc.dataGrid * @instance * @param {Number} [size=999] * @returns {Number|dc.dataGrid} */ _chart.size = function (size) { if (!arguments.length) { return _size; } _size = size; return _chart; }; /** * Get or set the function that formats an item. The data grid widget uses a * function to generate dynamic html. Use your favourite templating engine or * generate the string directly. * @method html * @memberof dc.dataGrid * @instance * @example * chart.html(function (d) { return '
'+data.exampleString+'
';}); * @param {Function} [html] * @returns {Function|dc.dataGrid} */ _chart.html = function (html) { if (!arguments.length) { return _html; } _html = html; return _chart; }; /** * Get or set the function that formats a group label. * @method htmlGroup * @memberof dc.dataGrid * @instance * @example * chart.htmlGroup (function (d) { return '

'.d.key . 'with ' . d.values.length .' items

'}); * @param {Function} [htmlGroup] * @returns {Function|dc.dataGrid} */ _chart.htmlGroup = function (htmlGroup) { if (!arguments.length) { return _htmlGroup; } _htmlGroup = htmlGroup; return _chart; }; /** * Get or set sort-by function. This function works as a value accessor at the item * level and returns a particular field to be sorted. * @method sortBy * @memberof dc.dataGrid * @instance * @example * chart.sortBy(function(d) { * return d.date; * }); * @param {Function} [sortByFunction] * @returns {Function|dc.dataGrid} */ _chart.sortBy = function (sortByFunction) { if (!arguments.length) { return _sortBy; } _sortBy = sortByFunction; return _chart; }; /** * Get or set sort the order function. * @method order * @memberof dc.dataGrid * @instance * @see {@link https://github.com/d3/d3-array/blob/master/README.md#ascending d3.ascending} * @see {@link https://github.com/d3/d3-array/blob/master/README.md#descending d3.descending} * @example * chart.order(d3.descending); * @param {Function} [order=d3.ascending] * @returns {Function|dc.dataGrid} */ _chart.order = function (order) { if (!arguments.length) { return _order; } _order = order; return _chart; }; return _chart.anchor(parent, chartGroup); }; /** * A concrete implementation of a general purpose bubble chart that allows data visualization using the * following dimensions: * - x axis position * - y axis position * - bubble radius * - color * * Examples: * - {@link http://dc-js.github.com/dc.js/ Nasdaq 100 Index} * - {@link http://dc-js.github.com/dc.js/vc/index.html US Venture Capital Landscape 2011} * @class bubbleChart * @memberof dc * @mixes dc.bubbleMixin * @mixes dc.coordinateGridMixin * @example * // create a bubble chart under #chart-container1 element using the default global chart group * var bubbleChart1 = dc.bubbleChart('#chart-container1'); * // create a bubble chart under #chart-container2 element using chart group A * var bubbleChart2 = dc.bubbleChart('#chart-container2', 'chartGroupA'); * @param {String|node|d3.selection} parent - Any valid * {@link https://github.com/d3/d3-selection/blob/master/README.md#select d3 single selector} specifying * a dom block element such as a div; or a dom element or d3 selection. * @param {String} [chartGroup] - The name of the chart group this chart instance should be placed in. * Interaction with a chart will only trigger events and redraws within the chart's group. * @returns {dc.bubbleChart} */ dc.bubbleChart = function (parent, chartGroup) { var _chart = dc.bubbleMixin(dc.coordinateGridMixin({})); _chart.transitionDuration(750); _chart.transitionDelay(0); var bubbleLocator = function (d) { return 'translate(' + (bubbleX(d)) + ',' + (bubbleY(d)) + ')'; }; _chart.plotData = function () { _chart.calculateRadiusDomain(); _chart.r().range([_chart.MIN_RADIUS, _chart.xAxisLength() * _chart.maxBubbleRelativeSize()]); var data = _chart.data(); var bubbleG = _chart.chartBodyG().selectAll('g.' + _chart.BUBBLE_NODE_CLASS) .data(data, function (d) { return d.key; }); if (_chart.sortBubbleSize()) { // update dom order based on sort bubbleG.order(); } bubbleG = renderNodes(bubbleG); updateNodes(bubbleG); removeNodes(bubbleG); _chart.fadeDeselectedArea(_chart.filter()); }; function renderNodes (bubbleG) { var bubbleGEnter = bubbleG.enter().append('g'); bubbleGEnter .attr('class', _chart.BUBBLE_NODE_CLASS) .attr('transform', bubbleLocator) .append('circle').attr('class', function (d, i) { return _chart.BUBBLE_CLASS + ' _' + i; }) .on('click', _chart.onClick) .attr('fill', _chart.getColor) .attr('r', 0); bubbleG = bubbleGEnter.merge(bubbleG); dc.transition(bubbleG, _chart.transitionDuration(), _chart.transitionDelay()) .select('circle.' + _chart.BUBBLE_CLASS) .attr('r', function (d) { return _chart.bubbleR(d); }) .attr('opacity', function (d) { return (_chart.bubbleR(d) > 0) ? 1 : 0; }); _chart._doRenderLabel(bubbleGEnter); _chart._doRenderTitles(bubbleGEnter); return bubbleG; } function updateNodes (bubbleG) { dc.transition(bubbleG, _chart.transitionDuration(), _chart.transitionDelay()) .attr('transform', bubbleLocator) .select('circle.' + _chart.BUBBLE_CLASS) .attr('fill', _chart.getColor) .attr('r', function (d) { return _chart.bubbleR(d); }) .attr('opacity', function (d) { return (_chart.bubbleR(d) > 0) ? 1 : 0; }); _chart.doUpdateLabels(bubbleG); _chart.doUpdateTitles(bubbleG); } function removeNodes (bubbleG) { bubbleG.exit().remove(); } function bubbleX (d) { var x = _chart.x()(_chart.keyAccessor()(d)); if (isNaN(x) || !isFinite(x)) { x = 0; } return x; } function bubbleY (d) { var y = _chart.y()(_chart.valueAccessor()(d)); if (isNaN(y) || !isFinite(y)) { y = 0; } return y; } _chart.renderBrush = function () { // override default x axis brush from parent chart }; _chart.redrawBrush = function (brushSelection, doTransition) { // override default x axis brush from parent chart _chart.fadeDeselectedArea(brushSelection); }; return _chart.anchor(parent, chartGroup); }; /** * Composite charts are a special kind of chart that render multiple charts on the same Coordinate * Grid. You can overlay (compose) different bar/line/area charts in a single composite chart to * achieve some quite flexible charting effects. * @class compositeChart * @memberof dc * @mixes dc.coordinateGridMixin * @example * // create a composite chart under #chart-container1 element using the default global chart group * var compositeChart1 = dc.compositeChart('#chart-container1'); * // create a composite chart under #chart-container2 element using chart group A * var compositeChart2 = dc.compositeChart('#chart-container2', 'chartGroupA'); * @param {String|node|d3.selection} parent - Any valid * {@link https://github.com/d3/d3-selection/blob/master/README.md#select d3 single selector} specifying * a dom block element such as a div; or a dom element or d3 selection. * @param {String} [chartGroup] - The name of the chart group this chart instance should be placed in. * Interaction with a chart will only trigger events and redraws within the chart's group. * @returns {dc.compositeChart} */ dc.compositeChart = function (parent, chartGroup) { var SUB_CHART_CLASS = 'sub'; var DEFAULT_RIGHT_Y_AXIS_LABEL_PADDING = 12; var _chart = dc.coordinateGridMixin({}); var _children = []; var _childOptions = {}; var _shareColors = false, _shareTitle = true, _alignYAxes = false; var _rightYAxis = d3.axisRight(), _rightYAxisLabel = 0, _rightYAxisLabelPadding = DEFAULT_RIGHT_Y_AXIS_LABEL_PADDING, _rightY, _rightAxisGridLines = false; _chart._mandatoryAttributes([]); _chart.transitionDuration(500); _chart.transitionDelay(0); dc.override(_chart, '_generateG', function () { var g = this.__generateG(); for (var i = 0; i < _children.length; ++i) { var child = _children[i]; generateChildG(child, i); if (!child.dimension()) { child.dimension(_chart.dimension()); } if (!child.group()) { child.group(_chart.group()); } child.chartGroup(_chart.chartGroup()); child.svg(_chart.svg()); child.xUnits(_chart.xUnits()); child.transitionDuration(_chart.transitionDuration(), _chart.transitionDelay()); child.parentBrushOn(_chart.brushOn()); child.brushOn(false); child.renderTitle(_chart.renderTitle()); child.elasticX(_chart.elasticX()); } return g; }); _chart.applyBrushSelection = function (rangedFilter) { _chart.replaceFilter(rangedFilter); for (var i = 0; i < _children.length; ++i) { _children[i].replaceFilter(rangedFilter); } _chart.redrawGroup(); }; _chart._prepareYAxis = function () { var left = (leftYAxisChildren().length !== 0); var right = (rightYAxisChildren().length !== 0); var ranges = calculateYAxisRanges(left, right); if (left) { prepareLeftYAxis(ranges); } if (right) { prepareRightYAxis(ranges); } if (leftYAxisChildren().length > 0 && !_rightAxisGridLines) { _chart._renderHorizontalGridLinesForAxis(_chart.g(), _chart.y(), _chart.yAxis()); } else if (rightYAxisChildren().length > 0) { _chart._renderHorizontalGridLinesForAxis(_chart.g(), _rightY, _rightYAxis); } }; _chart.renderYAxis = function () { if (leftYAxisChildren().length !== 0) { _chart.renderYAxisAt('y', _chart.yAxis(), _chart.margins().left); _chart.renderYAxisLabel('y', _chart.yAxisLabel(), -90); } if (rightYAxisChildren().length !== 0) { _chart.renderYAxisAt('yr', _chart.rightYAxis(), _chart.width() - _chart.margins().right); _chart.renderYAxisLabel('yr', _chart.rightYAxisLabel(), 90, _chart.width() - _rightYAxisLabelPadding); } }; function calculateYAxisRanges (left, right) { var lyAxisMin, lyAxisMax, ryAxisMin, ryAxisMax; var ranges; if (left) { lyAxisMin = yAxisMin(); lyAxisMax = yAxisMax(); } if (right) { ryAxisMin = rightYAxisMin(); ryAxisMax = rightYAxisMax(); } if (_chart.alignYAxes() && left && right) { ranges = alignYAxisRanges(lyAxisMin, lyAxisMax, ryAxisMin, ryAxisMax); } return ranges || { lyAxisMin: lyAxisMin, lyAxisMax: lyAxisMax, ryAxisMin: ryAxisMin, ryAxisMax: ryAxisMax }; } function alignYAxisRanges (lyAxisMin, lyAxisMax, ryAxisMin, ryAxisMax) { // since the two series will share a zero, each Y is just a multiple // of the other. and the ratio should be the ratio of the ranges of the // input data, so that they come out the same height. so we just min/max // note: both ranges already include zero due to the stack mixin (#667) // if #667 changes, we can reconsider whether we want data height or // height from zero to be equal. and it will be possible for the axes // to be aligned but not visible. var extentRatio = (ryAxisMax - ryAxisMin) / (lyAxisMax - lyAxisMin); return { lyAxisMin: Math.min(lyAxisMin, ryAxisMin / extentRatio), lyAxisMax: Math.max(lyAxisMax, ryAxisMax / extentRatio), ryAxisMin: Math.min(ryAxisMin, lyAxisMin * extentRatio), ryAxisMax: Math.max(ryAxisMax, lyAxisMax * extentRatio) }; } function prepareRightYAxis (ranges) { var needDomain = _chart.rightY() === undefined || _chart.elasticY(), needRange = needDomain || _chart.resizing(); if (_chart.rightY() === undefined) { _chart.rightY(d3.scaleLinear()); } if (needDomain) { _chart.rightY().domain([ranges.ryAxisMin, ranges.ryAxisMax]); } if (needRange) { _chart.rightY().rangeRound([_chart.yAxisHeight(), 0]); } _chart.rightY().range([_chart.yAxisHeight(), 0]); _chart.rightYAxis(_chart.rightYAxis().scale(_chart.rightY())); // In D3v4 create a RightAxis // _chart.rightYAxis().orient('right'); } function prepareLeftYAxis (ranges) { var needDomain = _chart.y() === undefined || _chart.elasticY(), needRange = needDomain || _chart.resizing(); if (_chart.y() === undefined) { _chart.y(d3.scaleLinear()); } if (needDomain) { _chart.y().domain([ranges.lyAxisMin, ranges.lyAxisMax]); } if (needRange) { _chart.y().rangeRound([_chart.yAxisHeight(), 0]); } _chart.y().range([_chart.yAxisHeight(), 0]); _chart.yAxis(_chart.yAxis().scale(_chart.y())); // In D3v4 create a LeftAxis // _chart.yAxis().orient('left'); } function generateChildG (child, i) { child._generateG(_chart.g()); child.g().attr('class', SUB_CHART_CLASS + ' _' + i); } _chart.plotData = function () { for (var i = 0; i < _children.length; ++i) { var child = _children[i]; if (!child.g()) { generateChildG(child, i); } if (_shareColors) { child.colors(_chart.colors()); } child.x(_chart.x()); child.xAxis(_chart.xAxis()); if (child.useRightYAxis()) { child.y(_chart.rightY()); child.yAxis(_chart.rightYAxis()); } else { child.y(_chart.y()); child.yAxis(_chart.yAxis()); } child.plotData(); child._activateRenderlets(); } }; /** * Get or set whether to draw gridlines from the right y axis. Drawing from the left y axis is the * default behavior. This option is only respected when subcharts with both left and right y-axes * are present. * @method useRightAxisGridLines * @memberof dc.compositeChart * @instance * @param {Boolean} [useRightAxisGridLines=false] * @returns {Boolean|dc.compositeChart} */ _chart.useRightAxisGridLines = function (useRightAxisGridLines) { if (!arguments) { return _rightAxisGridLines; } _rightAxisGridLines = useRightAxisGridLines; return _chart; }; /** * Get or set chart-specific options for all child charts. This is equivalent to calling * {@link dc.baseMixin#options .options} on each child chart. * @method childOptions * @memberof dc.compositeChart * @instance * @param {Object} [childOptions] * @returns {Object|dc.compositeChart} */ _chart.childOptions = function (childOptions) { if (!arguments.length) { return _childOptions; } _childOptions = childOptions; _children.forEach(function (child) { child.options(_childOptions); }); return _chart; }; _chart.fadeDeselectedArea = function (brushSelection) { if (_chart.brushOn()) { for (var i = 0; i < _children.length; ++i) { var child = _children[i]; child.fadeDeselectedArea(brushSelection); } } }; /** * Set or get the right y axis label. * @method rightYAxisLabel * @memberof dc.compositeChart * @instance * @param {String} [rightYAxisLabel] * @param {Number} [padding] * @returns {String|dc.compositeChart} */ _chart.rightYAxisLabel = function (rightYAxisLabel, padding) { if (!arguments.length) { return _rightYAxisLabel; } _rightYAxisLabel = rightYAxisLabel; _chart.margins().right -= _rightYAxisLabelPadding; _rightYAxisLabelPadding = (padding === undefined) ? DEFAULT_RIGHT_Y_AXIS_LABEL_PADDING : padding; _chart.margins().right += _rightYAxisLabelPadding; return _chart; }; /** * Combine the given charts into one single composite coordinate grid chart. * @method compose * @memberof dc.compositeChart * @instance * @example * moveChart.compose([ * // when creating sub-chart you need to pass in the parent chart * dc.lineChart(moveChart) * .group(indexAvgByMonthGroup) // if group is missing then parent's group will be used * .valueAccessor(function (d){return d.value.avg;}) * // most of the normal functions will continue to work in a composed chart * .renderArea(true) * .stack(monthlyMoveGroup, function (d){return d.value;}) * .title(function (d){ * var value = d.value.avg?d.value.avg:d.value; * if(isNaN(value)) value = 0; * return dateFormat(d.key) + '\n' + numberFormat(value); * }), * dc.barChart(moveChart) * .group(volumeByMonthGroup) * .centerBar(true) * ]); * @param {Array} [subChartArray] * @returns {dc.compositeChart} */ _chart.compose = function (subChartArray) { _children = subChartArray; _children.forEach(function (child) { child.height(_chart.height()); child.width(_chart.width()); child.margins(_chart.margins()); if (_shareTitle) { child.title(_chart.title()); } child.options(_childOptions); }); return _chart; }; /** * Returns the child charts which are composed into the composite chart. * @method children * @memberof dc.compositeChart * @instance * @returns {Array} */ _chart.children = function () { return _children; }; /** * Get or set color sharing for the chart. If set, the {@link dc.colorMixin#colors .colors()} value from this chart * will be shared with composed children. Additionally if the child chart implements * Stackable and has not set a custom .colorAccessor, then it will generate a color * specific to its order in the composition. * @method shareColors * @memberof dc.compositeChart * @instance * @param {Boolean} [shareColors=false] * @returns {Boolean|dc.compositeChart} */ _chart.shareColors = function (shareColors) { if (!arguments.length) { return _shareColors; } _shareColors = shareColors; return _chart; }; /** * Get or set title sharing for the chart. If set, the {@link dc.baseMixin#title .title()} value from * this chart will be shared with composed children. * @method shareTitle * @memberof dc.compositeChart * @instance * @param {Boolean} [shareTitle=true] * @returns {Boolean|dc.compositeChart} */ _chart.shareTitle = function (shareTitle) { if (!arguments.length) { return _shareTitle; } _shareTitle = shareTitle; return _chart; }; /** * Get or set the y scale for the right axis. The right y scale is typically automatically * generated by the chart implementation. * @method rightY * @memberof dc.compositeChart * @instance * @see {@link https://github.com/d3/d3-scale/blob/master/README.md d3.scale} * @param {d3.scale} [yScale] * @returns {d3.scale|dc.compositeChart} */ _chart.rightY = function (yScale) { if (!arguments.length) { return _rightY; } _rightY = yScale; _chart.rescale(); return _chart; }; /** * Get or set alignment between left and right y axes. A line connecting '0' on both y axis * will be parallel to x axis. This only has effect when {@link #dc.coordinateGridMixin+elasticY elasticY} is true. * @method alignYAxes * @memberof dc.compositeChart * @instance * @param {Boolean} [alignYAxes=false] * @returns {Chart} */ _chart.alignYAxes = function (alignYAxes) { if (!arguments.length) { return _alignYAxes; } _alignYAxes = alignYAxes; _chart.rescale(); return _chart; }; function leftYAxisChildren () { return _children.filter(function (child) { return !child.useRightYAxis(); }); } function rightYAxisChildren () { return _children.filter(function (child) { return child.useRightYAxis(); }); } function getYAxisMin (charts) { return charts.map(function (c) { return c.yAxisMin(); }); } delete _chart.yAxisMin; function yAxisMin () { return d3.min(getYAxisMin(leftYAxisChildren())); } function rightYAxisMin () { return d3.min(getYAxisMin(rightYAxisChildren())); } function getYAxisMax (charts) { return charts.map(function (c) { return c.yAxisMax(); }); } delete _chart.yAxisMax; function yAxisMax () { return dc.utils.add(d3.max(getYAxisMax(leftYAxisChildren())), _chart.yAxisPadding()); } function rightYAxisMax () { return dc.utils.add(d3.max(getYAxisMax(rightYAxisChildren())), _chart.yAxisPadding()); } function getAllXAxisMinFromChildCharts () { return _children.map(function (c) { return c.xAxisMin(); }); } dc.override(_chart, 'xAxisMin', function () { return dc.utils.subtract(d3.min(getAllXAxisMinFromChildCharts()), _chart.xAxisPadding(), _chart.xAxisPaddingUnit()); }); function getAllXAxisMaxFromChildCharts () { return _children.map(function (c) { return c.xAxisMax(); }); } dc.override(_chart, 'xAxisMax', function () { return dc.utils.add(d3.max(getAllXAxisMaxFromChildCharts()), _chart.xAxisPadding(), _chart.xAxisPaddingUnit()); }); _chart.legendables = function () { return _children.reduce(function (items, child) { if (_shareColors) { child.colors(_chart.colors()); } items.push.apply(items, child.legendables()); return items; }, []); }; _chart.legendHighlight = function (d) { for (var j = 0; j < _children.length; ++j) { var child = _children[j]; child.legendHighlight(d); } }; _chart.legendReset = function (d) { for (var j = 0; j < _children.length; ++j) { var child = _children[j]; child.legendReset(d); } }; _chart.legendToggle = function () { console.log('composite should not be getting legendToggle itself'); }; /** * Set or get the right y axis used by the composite chart. This function is most useful when y * axis customization is required. The y axis in dc.js is an instance of a * [d3.axisRight](https://github.com/d3/d3-axis/blob/master/README.md#axisRight) therefore it supports any valid * d3 axis manipulation. * * **Caution**: The right y axis is usually generated internally by dc; resetting it may cause * unexpected results. Note also that when used as a getter, this function is not chainable: it * returns the axis, not the chart, * {@link https://github.com/dc-js/dc.js/wiki/FAQ#why-does-everything-break-after-a-call-to-xaxis-or-yaxis * so attempting to call chart functions after calling `.yAxis()` will fail}. * @method rightYAxis * @memberof dc.compositeChart * @instance * @see {@link https://github.com/d3/d3-axis/blob/master/README.md#axisRight} * @example * // customize y axis tick format * chart.rightYAxis().tickFormat(function (v) {return v + '%';}); * // customize y axis tick values * chart.rightYAxis().tickValues([0, 100, 200, 300]); * @param {d3.axisRight} [rightYAxis] * @returns {d3.axisRight|dc.compositeChart} */ _chart.rightYAxis = function (rightYAxis) { if (!arguments.length) { return _rightYAxis; } _rightYAxis = rightYAxis; return _chart; }; return _chart.anchor(parent, chartGroup); }; /** * A series chart is a chart that shows multiple series of data overlaid on one chart, where the * series is specified in the data. It is a specialization of Composite Chart and inherits all * composite features other than recomposing the chart. * * Examples: * - {@link http://dc-js.github.io/dc.js/examples/series.html Series Chart} * @class seriesChart * @memberof dc * @mixes dc.compositeChart * @example * // create a series chart under #chart-container1 element using the default global chart group * var seriesChart1 = dc.seriesChart("#chart-container1"); * // create a series chart under #chart-container2 element using chart group A * var seriesChart2 = dc.seriesChart("#chart-container2", "chartGroupA"); * @param {String|node|d3.selection} parent - Any valid * {@link https://github.com/d3/d3-selection/blob/master/README.md#select d3 single selector} specifying * a dom block element such as a div; or a dom element or d3 selection. * @param {String} [chartGroup] - The name of the chart group this chart instance should be placed in. * Interaction with a chart will only trigger events and redraws within the chart's group. * @returns {dc.seriesChart} */ dc.seriesChart = function (parent, chartGroup) { var _chart = dc.compositeChart(parent, chartGroup); function keySort (a, b) { return d3.ascending(_chart.keyAccessor()(a), _chart.keyAccessor()(b)); } var _charts = {}; var _chartFunction = dc.lineChart; var _seriesAccessor; var _seriesSort = d3.ascending; var _valueSort = keySort; _chart._mandatoryAttributes().push('seriesAccessor', 'chart'); _chart.shareColors(true); _chart._preprocessData = function () { var keep = []; var childrenChanged; var nester = d3.nest().key(_seriesAccessor); if (_seriesSort) { nester.sortKeys(_seriesSort); } if (_valueSort) { nester.sortValues(_valueSort); } var nesting = nester.entries(_chart.data()); var children = nesting.map(function (sub, i) { var subChart = _charts[sub.key] || _chartFunction.call(_chart, _chart, chartGroup, sub.key, i); if (!_charts[sub.key]) { childrenChanged = true; } _charts[sub.key] = subChart; keep.push(sub.key); return subChart .dimension(_chart.dimension()) .group({ all: typeof sub.values === 'function' ? sub.values : dc.utils.constant(sub.values) }, sub.key) .keyAccessor(_chart.keyAccessor()) .valueAccessor(_chart.valueAccessor()) .brushOn(false); }); // this works around the fact compositeChart doesn't really // have a removal interface Object.keys(_charts) .filter(function (c) {return keep.indexOf(c) === -1;}) .forEach(function (c) { clearChart(c); childrenChanged = true; }); _chart._compose(children); if (childrenChanged && _chart.legend()) { _chart.legend().render(); } }; function clearChart (c) { if (_charts[c].g()) { _charts[c].g().remove(); } delete _charts[c]; } function resetChildren () { Object.keys(_charts).map(clearChart); _charts = {}; } /** * Get or set the chart function, which generates the child charts. * @method chart * @memberof dc.seriesChart * @instance * @example * // put curve on the line charts used for the series * chart.chart(function(c) { return dc.lineChart(c).curve(d3.curveBasis); }) * // do a scatter series chart * chart.chart(dc.scatterPlot) * @param {Function} [chartFunction=dc.lineChart] * @returns {Function|dc.seriesChart} */ _chart.chart = function (chartFunction) { if (!arguments.length) { return _chartFunction; } _chartFunction = chartFunction; resetChildren(); return _chart; }; /** * **mandatory** * * Get or set accessor function for the displayed series. Given a datum, this function * should return the series that datum belongs to. * @method seriesAccessor * @memberof dc.seriesChart * @instance * @example * // simple series accessor * chart.seriesAccessor(function(d) { return "Expt: " + d.key[0]; }) * @param {Function} [accessor] * @returns {Function|dc.seriesChart} */ _chart.seriesAccessor = function (accessor) { if (!arguments.length) { return _seriesAccessor; } _seriesAccessor = accessor; resetChildren(); return _chart; }; /** * Get or set a function to sort the list of series by, given series values. * @method seriesSort * @memberof dc.seriesChart * @instance * @see {@link https://github.com/d3/d3-array/blob/master/README.md#ascending d3.ascending} * @see {@link https://github.com/d3/d3-array/blob/master/README.md#descending d3.descending} * @example * chart.seriesSort(d3.descending); * @param {Function} [sortFunction=d3.ascending] * @returns {Function|dc.seriesChart} */ _chart.seriesSort = function (sortFunction) { if (!arguments.length) { return _seriesSort; } _seriesSort = sortFunction; resetChildren(); return _chart; }; /** * Get or set a function to sort each series values by. By default this is the key accessor which, * for example, will ensure a lineChart series connects its points in increasing key/x order, * rather than haphazardly. * @method valueSort * @memberof dc.seriesChart * @instance * @see {@link https://github.com/d3/d3-array/blob/master/README.md#ascending d3.ascending} * @see {@link https://github.com/d3/d3-array/blob/master/README.md#descending d3.descending} * @example * // Default value sort * _chart.valueSort(function keySort (a, b) { * return d3.ascending(_chart.keyAccessor()(a), _chart.keyAccessor()(b)); * }); * @param {Function} [sortFunction] * @returns {Function|dc.seriesChart} */ _chart.valueSort = function (sortFunction) { if (!arguments.length) { return _valueSort; } _valueSort = sortFunction; resetChildren(); return _chart; }; // make compose private _chart._compose = _chart.compose; delete _chart.compose; return _chart; }; /** * The geo choropleth chart is designed as an easy way to create a crossfilter driven choropleth map * from GeoJson data. This chart implementation was inspired by * {@link http://bl.ocks.org/4060606 the great d3 choropleth example}. * * Examples: * - {@link http://dc-js.github.com/dc.js/vc/index.html US Venture Capital Landscape 2011} * @class geoChoroplethChart * @memberof dc * @mixes dc.colorMixin * @mixes dc.baseMixin * @example * // create a choropleth chart under '#us-chart' element using the default global chart group * var chart1 = dc.geoChoroplethChart('#us-chart'); * // create a choropleth chart under '#us-chart2' element using chart group A * var chart2 = dc.compositeChart('#us-chart2', 'chartGroupA'); * @param {String|node|d3.selection} parent - Any valid * {@link https://github.com/d3/d3-selection/blob/master/README.md#select d3 single selector} specifying * a dom block element such as a div; or a dom element or d3 selection. * @param {String} [chartGroup] - The name of the chart group this chart instance should be placed in. * Interaction with a chart will only trigger events and redraws within the chart's group. * @returns {dc.geoChoroplethChart} */ dc.geoChoroplethChart = function (parent, chartGroup) { var _chart = dc.colorMixin(dc.baseMixin({})); _chart.colorAccessor(function (d) { return d || 0; }); var _geoPath = d3.geoPath(); var _projectionFlag; var _projection; var _geoJsons = []; _chart._doRender = function () { _chart.resetSvg(); for (var layerIndex = 0; layerIndex < _geoJsons.length; ++layerIndex) { var states = _chart.svg().append('g') .attr('class', 'layer' + layerIndex); var regionG = states.selectAll('g.' + geoJson(layerIndex).name) .data(geoJson(layerIndex).data); regionG = regionG.enter() .append('g') .attr('class', geoJson(layerIndex).name) .merge(regionG); regionG .append('path') .attr('fill', 'white') .attr('d', _getGeoPath()); regionG.append('title'); plotData(layerIndex); } _projectionFlag = false; }; function plotData (layerIndex) { var data = generateLayeredData(); if (isDataLayer(layerIndex)) { var regionG = renderRegionG(layerIndex); renderPaths(regionG, layerIndex, data); renderTitle(regionG, layerIndex, data); } } function generateLayeredData () { var data = {}; var groupAll = _chart.data(); for (var i = 0; i < groupAll.length; ++i) { data[_chart.keyAccessor()(groupAll[i])] = _chart.valueAccessor()(groupAll[i]); } return data; } function isDataLayer (layerIndex) { return geoJson(layerIndex).keyAccessor; } function renderRegionG (layerIndex) { var regionG = _chart.svg() .selectAll(layerSelector(layerIndex)) .classed('selected', function (d) { return isSelected(layerIndex, d); }) .classed('deselected', function (d) { return isDeselected(layerIndex, d); }) .attr('class', function (d) { var layerNameClass = geoJson(layerIndex).name; var regionClass = dc.utils.nameToId(geoJson(layerIndex).keyAccessor(d)); var baseClasses = layerNameClass + ' ' + regionClass; if (isSelected(layerIndex, d)) { baseClasses += ' selected'; } if (isDeselected(layerIndex, d)) { baseClasses += ' deselected'; } return baseClasses; }); return regionG; } function layerSelector (layerIndex) { return 'g.layer' + layerIndex + ' g.' + geoJson(layerIndex).name; } function isSelected (layerIndex, d) { return _chart.hasFilter() && _chart.hasFilter(getKey(layerIndex, d)); } function isDeselected (layerIndex, d) { return _chart.hasFilter() && !_chart.hasFilter(getKey(layerIndex, d)); } function getKey (layerIndex, d) { return geoJson(layerIndex).keyAccessor(d); } function geoJson (index) { return _geoJsons[index]; } function renderPaths (regionG, layerIndex, data) { var paths = regionG .select('path') .attr('fill', function () { var currentFill = d3.select(this).attr('fill'); if (currentFill) { return currentFill; } return 'none'; }) .on('click', function (d) { return _chart.onClick(d, layerIndex); }); dc.transition(paths, _chart.transitionDuration(), _chart.transitionDelay()).attr('fill', function (d, i) { return _chart.getColor(data[geoJson(layerIndex).keyAccessor(d)], i); }); } _chart.onClick = function (d, layerIndex) { var selectedRegion = geoJson(layerIndex).keyAccessor(d); dc.events.trigger(function () { _chart.filter(selectedRegion); _chart.redrawGroup(); }); }; function renderTitle (regionG, layerIndex, data) { if (_chart.renderTitle()) { regionG.selectAll('title').text(function (d) { var key = getKey(layerIndex, d); var value = data[key]; return _chart.title()({key: key, value: value}); }); } } _chart._doRedraw = function () { for (var layerIndex = 0; layerIndex < _geoJsons.length; ++layerIndex) { plotData(layerIndex); if (_projectionFlag) { _chart.svg().selectAll('g.' + geoJson(layerIndex).name + ' path').attr('d', _getGeoPath()); } } _projectionFlag = false; }; /** * **mandatory** * * Use this function to insert a new GeoJson map layer. This function can be invoked multiple times * if you have multiple GeoJson data layers to render on top of each other. If you overlay multiple * layers with the same name the new overlay will override the existing one. * @method overlayGeoJson * @memberof dc.geoChoroplethChart * @instance * @see {@link http://geojson.org/ GeoJSON} * @see {@link https://github.com/topojson/topojson/wiki TopoJSON} * @see {@link https://github.com/topojson/topojson-1.x-api-reference/blob/master/API-Reference.md#wiki-feature topojson.feature} * @example * // insert a layer for rendering US states * chart.overlayGeoJson(statesJson.features, 'state', function(d) { * return d.properties.name; * }); * @param {geoJson} json - a geojson feed * @param {String} name - name of the layer * @param {Function} keyAccessor - accessor function used to extract 'key' from the GeoJson data. The key extracted by * this function should match the keys returned by the crossfilter groups. * @returns {dc.geoChoroplethChart} */ _chart.overlayGeoJson = function (json, name, keyAccessor) { for (var i = 0; i < _geoJsons.length; ++i) { if (_geoJsons[i].name === name) { _geoJsons[i].data = json; _geoJsons[i].keyAccessor = keyAccessor; return _chart; } } _geoJsons.push({name: name, data: json, keyAccessor: keyAccessor}); return _chart; }; /** * Gets or sets a custom geo projection function. See the available * {@link https://github.com/d3/d3-geo/blob/master/README.md#projections d3 geo projection functions}. * * Starting version 3.0 it has been deprecated to rely on the default projection being * {@link https://github.com/d3/d3-geo/blob/master/README.md#geoAlbersUsa d3.geoAlbersUsa()}. Please * set it explicitly. {@link https://bl.ocks.org/mbostock/5557726 * Considering that `null` is also a valid value for projection}, if you need * projection to be `null` please set it explicitly to `null`. * @method projection * @memberof dc.geoChoroplethChart * @instance * @see {@link https://github.com/d3/d3-geo/blob/master/README.md#projections d3.projection} * @see {@link https://github.com/d3/d3-geo-projection d3-geo-projection} * @param {d3.projection} [projection=d3.geoAlbersUsa()] * @returns {d3.projection|dc.geoChoroplethChart} */ _chart.projection = function (projection) { if (!arguments.length) { return _projection; } _projection = projection; _projectionFlag = true; return _chart; }; var _getGeoPath = function () { if (_projection === undefined) { dc.logger.warn('choropleth projection default of geoAlbers is deprecated,' + ' in next version projection will need to be set explicitly'); return _geoPath.projection(d3.geoAlbersUsa()); } return _geoPath.projection(_projection); }; /** * Returns all GeoJson layers currently registered with this chart. The returned array is a * reference to this chart's internal data structure, so any modification to this array will also * modify this chart's internal registration. * @method geoJsons * @memberof dc.geoChoroplethChart * @instance * @returns {Array<{name:String, data: Object, accessor: Function}>} */ _chart.geoJsons = function () { return _geoJsons; }; /** * Returns the {@link https://github.com/d3/d3-geo/blob/master/README.md#paths d3.geoPath} object used to * render the projection and features. Can be useful for figuring out the bounding box of the * feature set and thus a way to calculate scale and translation for the projection. * @method geoPath * @memberof dc.geoChoroplethChart * @instance * @see {@link https://github.com/d3/d3-geo/blob/master/README.md#paths d3.geoPath} * @returns {d3.geoPath} */ _chart.geoPath = function () { return _geoPath; }; /** * Remove a GeoJson layer from this chart by name * @method removeGeoJson * @memberof dc.geoChoroplethChart * @instance * @param {String} name * @returns {dc.geoChoroplethChart} */ _chart.removeGeoJson = function (name) { var geoJsons = []; for (var i = 0; i < _geoJsons.length; ++i) { var layer = _geoJsons[i]; if (layer.name !== name) { geoJsons.push(layer); } } _geoJsons = geoJsons; return _chart; }; return _chart.anchor(parent, chartGroup); }; /** * The bubble overlay chart is quite different from the typical bubble chart. With the bubble overlay * chart you can arbitrarily place bubbles on an existing svg or bitmap image, thus changing the * typical x and y positioning while retaining the capability to visualize data using bubble radius * and coloring. * * Examples: * - {@link http://dc-js.github.com/dc.js/crime/index.html Canadian City Crime Stats} * @class bubbleOverlay * @memberof dc * @mixes dc.bubbleMixin * @mixes dc.baseMixin * @example * // create a bubble overlay chart on top of the '#chart-container1 svg' element using the default global chart group * var bubbleChart1 = dc.bubbleOverlayChart('#chart-container1').svg(d3.select('#chart-container1 svg')); * // create a bubble overlay chart on top of the '#chart-container2 svg' element using chart group A * var bubbleChart2 = dc.compositeChart('#chart-container2', 'chartGroupA').svg(d3.select('#chart-container2 svg')); * @param {String|node|d3.selection} parent - Any valid * {@link https://github.com/d3/d3-selection/blob/master/README.md#select d3 single selector} specifying * a dom block element such as a div; or a dom element or d3 selection. * @param {String} [chartGroup] - The name of the chart group this chart instance should be placed in. * Interaction with a chart will only trigger events and redraws within the chart's group. * @returns {dc.bubbleOverlay} */ dc.bubbleOverlay = function (parent, chartGroup) { var BUBBLE_OVERLAY_CLASS = 'bubble-overlay'; var BUBBLE_NODE_CLASS = 'node'; var BUBBLE_CLASS = 'bubble'; /** * **mandatory** * * Set the underlying svg image element. Unlike other dc charts this chart will not generate a svg * element; therefore the bubble overlay chart will not work if this function is not invoked. If the * underlying image is a bitmap, then an empty svg will need to be created on top of the image. * @method svg * @memberof dc.bubbleOverlay * @instance * @example * // set up underlying svg element * chart.svg(d3.select('#chart svg')); * @param {SVGElement|d3.selection} [imageElement] * @returns {dc.bubbleOverlay} */ var _chart = dc.bubbleMixin(dc.baseMixin({})); var _g; var _points = []; _chart.transitionDuration(750); _chart.transitionDelay(0); _chart.radiusValueAccessor(function (d) { return d.value; }); /** * **mandatory** * * Set up a data point on the overlay. The name of a data point should match a specific 'key' among * data groups generated using keyAccessor. If a match is found (point name <-> data group key) * then a bubble will be generated at the position specified by the function. x and y * value specified here are relative to the underlying svg. * @method point * @memberof dc.bubbleOverlay * @instance * @param {String} name * @param {Number} x * @param {Number} y * @returns {dc.bubbleOverlay} */ _chart.point = function (name, x, y) { _points.push({name: name, x: x, y: y}); return _chart; }; _chart._doRender = function () { _g = initOverlayG(); _chart.r().range([_chart.MIN_RADIUS, _chart.width() * _chart.maxBubbleRelativeSize()]); initializeBubbles(); _chart.fadeDeselectedArea(_chart.filter()); return _chart; }; function initOverlayG () { _g = _chart.select('g.' + BUBBLE_OVERLAY_CLASS); if (_g.empty()) { _g = _chart.svg().append('g').attr('class', BUBBLE_OVERLAY_CLASS); } return _g; } function initializeBubbles () { var data = mapData(); _chart.calculateRadiusDomain(); _points.forEach(function (point) { var nodeG = getNodeG(point, data); var circle = nodeG.select('circle.' + BUBBLE_CLASS); if (circle.empty()) { circle = nodeG.append('circle') .attr('class', BUBBLE_CLASS) .attr('r', 0) .attr('fill', _chart.getColor) .on('click', _chart.onClick); } dc.transition(circle, _chart.transitionDuration(), _chart.transitionDelay()) .attr('r', function (d) { return _chart.bubbleR(d); }); _chart._doRenderLabel(nodeG); _chart._doRenderTitles(nodeG); }); } function mapData () { var data = {}; _chart.data().forEach(function (datum) { data[_chart.keyAccessor()(datum)] = datum; }); return data; } function getNodeG (point, data) { var bubbleNodeClass = BUBBLE_NODE_CLASS + ' ' + dc.utils.nameToId(point.name); var nodeG = _g.select('g.' + dc.utils.nameToId(point.name)); if (nodeG.empty()) { nodeG = _g.append('g') .attr('class', bubbleNodeClass) .attr('transform', 'translate(' + point.x + ',' + point.y + ')'); } nodeG.datum(data[point.name]); return nodeG; } _chart._doRedraw = function () { updateBubbles(); _chart.fadeDeselectedArea(_chart.filter()); return _chart; }; function updateBubbles () { var data = mapData(); _chart.calculateRadiusDomain(); _points.forEach(function (point) { var nodeG = getNodeG(point, data); var circle = nodeG.select('circle.' + BUBBLE_CLASS); dc.transition(circle, _chart.transitionDuration(), _chart.transitionDelay()) .attr('r', function (d) { return _chart.bubbleR(d); }) .attr('fill', _chart.getColor); _chart.doUpdateLabels(nodeG); _chart.doUpdateTitles(nodeG); }); } _chart.debug = function (flag) { if (flag) { var debugG = _chart.select('g.' + dc.constants.DEBUG_GROUP_CLASS); if (debugG.empty()) { debugG = _chart.svg() .append('g') .attr('class', dc.constants.DEBUG_GROUP_CLASS); } var debugText = debugG.append('text') .attr('x', 10) .attr('y', 20); debugG .append('rect') .attr('width', _chart.width()) .attr('height', _chart.height()) .on('mousemove', function () { var position = d3.mouse(debugG.node()); var msg = position[0] + ', ' + position[1]; debugText.text(msg); }); } else { _chart.selectAll('.debug').remove(); } return _chart; }; _chart.anchor(parent, chartGroup); return _chart; }; /** * Concrete row chart implementation. * * Examples: * - {@link http://dc-js.github.com/dc.js/ Nasdaq 100 Index} * @class rowChart * @memberof dc * @mixes dc.capMixin * @mixes dc.marginMixin * @mixes dc.colorMixin * @mixes dc.baseMixin * @example * // create a row chart under #chart-container1 element using the default global chart group * var chart1 = dc.rowChart('#chart-container1'); * // create a row chart under #chart-container2 element using chart group A * var chart2 = dc.rowChart('#chart-container2', 'chartGroupA'); * @param {String|node|d3.selection} parent - Any valid * {@link https://github.com/d3/d3-selection/blob/master/README.md#select d3 single selector} specifying * a dom block element such as a div; or a dom element or d3 selection. * @param {String} [chartGroup] - The name of the chart group this chart instance should be placed in. * Interaction with a chart will only trigger events and redraws within the chart's group. * @returns {dc.rowChart} */ dc.rowChart = function (parent, chartGroup) { var _g; var _labelOffsetX = 10; var _labelOffsetY = 15; var _hasLabelOffsetY = false; var _dyOffset = '0.35em'; // this helps center labels https://github.com/d3/d3-3.x-api-reference/blob/master/SVG-Shapes.md#svg_text var _titleLabelOffsetX = 2; var _gap = 5; var _fixedBarHeight = false; var _rowCssClass = 'row'; var _titleRowCssClass = 'titlerow'; var _renderTitleLabel = false; var _chart = dc.capMixin(dc.marginMixin(dc.colorMixin(dc.baseMixin({})))); var _x; var _elasticX; var _xAxis = d3.axisBottom(); var _rowData; _chart.rowsCap = _chart.cap; function calculateAxisScale () { if (!_x || _elasticX) { var extent = d3.extent(_rowData, _chart.cappedValueAccessor); if (extent[0] > 0) { extent[0] = 0; } if (extent[1] < 0) { extent[1] = 0; } _x = d3.scaleLinear().domain(extent) .range([0, _chart.effectiveWidth()]); } _xAxis.scale(_x); } function drawAxis () { var axisG = _g.select('g.axis'); calculateAxisScale(); if (axisG.empty()) { axisG = _g.append('g').attr('class', 'axis'); } axisG.attr('transform', 'translate(0, ' + _chart.effectiveHeight() + ')'); dc.transition(axisG, _chart.transitionDuration(), _chart.transitionDelay()) .call(_xAxis); } _chart._doRender = function () { _chart.resetSvg(); _g = _chart.svg() .append('g') .attr('transform', 'translate(' + _chart.margins().left + ',' + _chart.margins().top + ')'); drawChart(); return _chart; }; _chart.title(function (d) { return _chart.cappedKeyAccessor(d) + ': ' + _chart.cappedValueAccessor(d); }); _chart.label(_chart.cappedKeyAccessor); /** * Gets or sets the x scale. The x scale can be any d3 * {@link https://github.com/d3/d3-scale/blob/master/README.md d3.scale}. * @method x * @memberof dc.rowChart * @instance * @see {@link https://github.com/d3/d3-scale/blob/master/README.md d3.scale} * @param {d3.scale} [scale] * @returns {d3.scale|dc.rowChart} */ _chart.x = function (scale) { if (!arguments.length) { return _x; } _x = scale; return _chart; }; function drawGridLines () { _g.selectAll('g.tick') .select('line.grid-line') .remove(); _g.selectAll('g.tick') .append('line') .attr('class', 'grid-line') .attr('x1', 0) .attr('y1', 0) .attr('x2', 0) .attr('y2', function () { return -_chart.effectiveHeight(); }); } function drawChart () { _rowData = _chart.data(); drawAxis(); drawGridLines(); var rows = _g.selectAll('g.' + _rowCssClass) .data(_rowData); removeElements(rows); rows = createElements(rows) .merge(rows); updateElements(rows); } function createElements (rows) { var rowEnter = rows.enter() .append('g') .attr('class', function (d, i) { return _rowCssClass + ' _' + i; }); rowEnter.append('rect').attr('width', 0); createLabels(rowEnter); return rowEnter; } function removeElements (rows) { rows.exit().remove(); } function rootValue () { var root = _x(0); return (root === -Infinity || root !== root) ? _x(1) : root; } function updateElements (rows) { var n = _rowData.length; var height; if (!_fixedBarHeight) { height = (_chart.effectiveHeight() - (n + 1) * _gap) / n; } else { height = _fixedBarHeight; } // vertically align label in center unless they override the value via property setter if (!_hasLabelOffsetY) { _labelOffsetY = height / 2; } var rect = rows.attr('transform', function (d, i) { return 'translate(0,' + ((i + 1) * _gap + i * height) + ')'; }).select('rect') .attr('height', height) .attr('fill', _chart.getColor) .on('click', onClick) .classed('deselected', function (d) { return (_chart.hasFilter()) ? !isSelectedRow(d) : false; }) .classed('selected', function (d) { return (_chart.hasFilter()) ? isSelectedRow(d) : false; }); dc.transition(rect, _chart.transitionDuration(), _chart.transitionDelay()) .attr('width', function (d) { return Math.abs(rootValue() - _x(_chart.valueAccessor()(d))); }) .attr('transform', translateX); createTitles(rows); updateLabels(rows); } function createTitles (rows) { if (_chart.renderTitle()) { rows.select('title').remove(); rows.append('title').text(_chart.title()); } } function createLabels (rowEnter) { if (_chart.renderLabel()) { rowEnter.append('text') .on('click', onClick); } if (_chart.renderTitleLabel()) { rowEnter.append('text') .attr('class', _titleRowCssClass) .on('click', onClick); } } function updateLabels (rows) { if (_chart.renderLabel()) { var lab = rows.select('text') .attr('x', _labelOffsetX) .attr('y', _labelOffsetY) .attr('dy', _dyOffset) .on('click', onClick) .attr('class', function (d, i) { return _rowCssClass + ' _' + i; }) .text(function (d) { return _chart.label()(d); }); dc.transition(lab, _chart.transitionDuration(), _chart.transitionDelay()) .attr('transform', translateX); } if (_chart.renderTitleLabel()) { var titlelab = rows.select('.' + _titleRowCssClass) .attr('x', _chart.effectiveWidth() - _titleLabelOffsetX) .attr('y', _labelOffsetY) .attr('dy', _dyOffset) .attr('text-anchor', 'end') .on('click', onClick) .attr('class', function (d, i) { return _titleRowCssClass + ' _' + i ; }) .text(function (d) { return _chart.title()(d); }); dc.transition(titlelab, _chart.transitionDuration(), _chart.transitionDelay()) .attr('transform', translateX); } } /** * Turn on/off Title label rendering (values) using SVG style of text-anchor 'end'. * @method renderTitleLabel * @memberof dc.rowChart * @instance * @param {Boolean} [renderTitleLabel=false] * @returns {Boolean|dc.rowChart} */ _chart.renderTitleLabel = function (renderTitleLabel) { if (!arguments.length) { return _renderTitleLabel; } _renderTitleLabel = renderTitleLabel; return _chart; }; function onClick (d) { _chart.onClick(d); } function translateX (d) { var x = _x(_chart.cappedValueAccessor(d)), x0 = rootValue(), s = x > x0 ? x0 : x; return 'translate(' + s + ',0)'; } _chart._doRedraw = function () { drawChart(); return _chart; }; /** * Get or sets the x axis for the row chart instance. * See the {@link https://github.com/d3/d3-axis/blob/master/README.md d3.axis} * documention for more information. * @method xAxis * @memberof dc.rowChart * @instance * @example * // customize x axis tick format * chart.xAxis().tickFormat(function (v) {return v + '%';}); * // customize x axis tick values * chart.xAxis().tickValues([0, 100, 200, 300]); * // use a top-oriented axis. Note: position of the axis and grid lines will need to * // be set manually, see https://dc-js.github.io/dc.js/examples/row-top-axis.html * chart.xAxis(d3.axisTop()) * @returns {d3.axis} */ _chart.xAxis = function (xAxis) { if (!arguments.length) { return _xAxis; } _xAxis = xAxis; return this; }; /** * Get or set the fixed bar height. Default is [false] which will auto-scale bars. * For example, if you want to fix the height for a specific number of bars (useful in TopN charts) * you could fix height as follows (where count = total number of bars in your TopN and gap is * your vertical gap space). * @method fixedBarHeight * @memberof dc.rowChart * @instance * @example * chart.fixedBarHeight( chartheight - (count + 1) * gap / count); * @param {Boolean|Number} [fixedBarHeight=false] * @returns {Boolean|Number|dc.rowChart} */ _chart.fixedBarHeight = function (fixedBarHeight) { if (!arguments.length) { return _fixedBarHeight; } _fixedBarHeight = fixedBarHeight; return _chart; }; /** * Get or set the vertical gap space between rows on a particular row chart instance. * @method gap * @memberof dc.rowChart * @instance * @param {Number} [gap=5] * @returns {Number|dc.rowChart} */ _chart.gap = function (gap) { if (!arguments.length) { return _gap; } _gap = gap; return _chart; }; /** * 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 * data range when filtered. * @method elasticX * @memberof dc.rowChart * @instance * @param {Boolean} [elasticX] * @returns {Boolean|dc.rowChart} */ _chart.elasticX = function (elasticX) { if (!arguments.length) { return _elasticX; } _elasticX = elasticX; return _chart; }; /** * Get or set the x offset (horizontal space to the top left corner of a row) for labels on a particular row chart. * @method labelOffsetX * @memberof dc.rowChart * @instance * @param {Number} [labelOffsetX=10] * @returns {Number|dc.rowChart} */ _chart.labelOffsetX = function (labelOffsetX) { if (!arguments.length) { return _labelOffsetX; } _labelOffsetX = labelOffsetX; return _chart; }; /** * Get or set the y offset (vertical space to the top left corner of a row) for labels on a particular row chart. * @method labelOffsetY * @memberof dc.rowChart * @instance * @param {Number} [labelOffsety=15] * @returns {Number|dc.rowChart} */ _chart.labelOffsetY = function (labelOffsety) { if (!arguments.length) { return _labelOffsetY; } _labelOffsetY = labelOffsety; _hasLabelOffsetY = true; return _chart; }; /** * Get of set the x offset (horizontal space between right edge of row and right edge or text. * @method titleLabelOffsetX * @memberof dc.rowChart * @instance * @param {Number} [titleLabelOffsetX=2] * @returns {Number|dc.rowChart} */ _chart.titleLabelOffsetX = function (titleLabelOffsetX) { if (!arguments.length) { return _titleLabelOffsetX; } _titleLabelOffsetX = titleLabelOffsetX; return _chart; }; function isSelectedRow (d) { return _chart.hasFilter(_chart.cappedKeyAccessor(d)); } return _chart.anchor(parent, chartGroup); }; /** * Legend is a attachable widget that can be added to other dc charts to render horizontal legend * labels. * * Examples: * - {@link http://dc-js.github.com/dc.js/ Nasdaq 100 Index} * - {@link http://dc-js.github.com/dc.js/crime/index.html Canadian City Crime Stats} * @class legend * @memberof dc * @example * chart.legend(dc.legend().x(400).y(10).itemHeight(13).gap(5)) * @returns {dc.legend} */ dc.legend = function () { var LABEL_GAP = 2; var _legend = {}, _parent, _x = 0, _y = 0, _itemHeight = 12, _gap = 5, _horizontal = false, _legendWidth = 560, _itemWidth = 70, _autoItemWidth = false, _legendText = dc.pluck('name'), _maxItems; var _g; _legend.parent = function (p) { if (!arguments.length) { return _parent; } _parent = p; return _legend; }; _legend.render = function () { _parent.svg().select('g.dc-legend').remove(); _g = _parent.svg().append('g') .attr('class', 'dc-legend') .attr('transform', 'translate(' + _x + ',' + _y + ')'); var legendables = _parent.legendables(); if (_maxItems !== undefined) { legendables = legendables.slice(0, _maxItems); } var itemEnter = _g.selectAll('g.dc-legend-item') .data(legendables) .enter() .append('g') .attr('class', 'dc-legend-item') .on('mouseover', function (d) { _parent.legendHighlight(d); }) .on('mouseout', function (d) { _parent.legendReset(d); }) .on('click', function (d) { d.chart.legendToggle(d); }); _g.selectAll('g.dc-legend-item') .classed('fadeout', function (d) { return d.chart.isLegendableHidden(d); }); if (legendables.some(dc.pluck('dashstyle'))) { itemEnter .append('line') .attr('x1', 0) .attr('y1', _itemHeight / 2) .attr('x2', _itemHeight) .attr('y2', _itemHeight / 2) .attr('stroke-width', 2) .attr('stroke-dasharray', dc.pluck('dashstyle')) .attr('stroke', dc.pluck('color')); } else { itemEnter .append('rect') .attr('width', _itemHeight) .attr('height', _itemHeight) .attr('fill', function (d) {return d ? d.color : 'blue';}); } itemEnter.append('text') .text(_legendText) .attr('x', _itemHeight + LABEL_GAP) .attr('y', function () { return _itemHeight / 2 + (this.clientHeight ? this.clientHeight : 13) / 2 - 2; }); var _cumulativeLegendTextWidth = 0; var row = 0; itemEnter.attr('transform', function (d, i) { if (_horizontal) { var itemWidth = _autoItemWidth === true ? this.getBBox().width + _gap : _itemWidth; if ((_cumulativeLegendTextWidth + itemWidth) > _legendWidth && _cumulativeLegendTextWidth > 0) { ++row; _cumulativeLegendTextWidth = 0; } var translateBy = 'translate(' + _cumulativeLegendTextWidth + ',' + row * legendItemHeight() + ')'; _cumulativeLegendTextWidth += itemWidth; return translateBy; } else { return 'translate(0,' + i * legendItemHeight() + ')'; } }); }; function legendItemHeight () { return _gap + _itemHeight; } /** * Set or get x coordinate for legend widget. * @method x * @memberof dc.legend * @instance * @param {Number} [x=0] * @returns {Number|dc.legend} */ _legend.x = function (x) { if (!arguments.length) { return _x; } _x = x; return _legend; }; /** * Set or get y coordinate for legend widget. * @method y * @memberof dc.legend * @instance * @param {Number} [y=0] * @returns {Number|dc.legend} */ _legend.y = function (y) { if (!arguments.length) { return _y; } _y = y; return _legend; }; /** * Set or get gap between legend items. * @method gap * @memberof dc.legend * @instance * @param {Number} [gap=5] * @returns {Number|dc.legend} */ _legend.gap = function (gap) { if (!arguments.length) { return _gap; } _gap = gap; return _legend; }; /** * Set or get legend item height. * @method itemHeight * @memberof dc.legend * @instance * @param {Number} [itemHeight=12] * @returns {Number|dc.legend} */ _legend.itemHeight = function (itemHeight) { if (!arguments.length) { return _itemHeight; } _itemHeight = itemHeight; return _legend; }; /** * Position legend horizontally instead of vertically. * @method horizontal * @memberof dc.legend * @instance * @param {Boolean} [horizontal=false] * @returns {Boolean|dc.legend} */ _legend.horizontal = function (horizontal) { if (!arguments.length) { return _horizontal; } _horizontal = horizontal; return _legend; }; /** * Maximum width for horizontal legend. * @method legendWidth * @memberof dc.legend * @instance * @param {Number} [legendWidth=500] * @returns {Number|dc.legend} */ _legend.legendWidth = function (legendWidth) { if (!arguments.length) { return _legendWidth; } _legendWidth = legendWidth; return _legend; }; /** * Legend item width for horizontal legend. * @method itemWidth * @memberof dc.legend * @instance * @param {Number} [itemWidth=70] * @returns {Number|dc.legend} */ _legend.itemWidth = function (itemWidth) { if (!arguments.length) { return _itemWidth; } _itemWidth = itemWidth; return _legend; }; /** * Turn automatic width for legend items on or off. If true, {@link dc.legend#itemWidth itemWidth} is ignored. * This setting takes into account the {@link dc.legend#gap gap}. * @method autoItemWidth * @memberof dc.legend * @instance * @param {Boolean} [autoItemWidth=false] * @returns {Boolean|dc.legend} */ _legend.autoItemWidth = function (autoItemWidth) { if (!arguments.length) { return _autoItemWidth; } _autoItemWidth = autoItemWidth; return _legend; }; /** * Set or get the legend text function. The legend widget uses this function to render the legend * text for each item. If no function is specified the legend widget will display the names * associated with each group. * @method legendText * @memberof dc.legend * @instance * @param {Function} [legendText] * @returns {Function|dc.legend} * @example * // default legendText * legend.legendText(dc.pluck('name')) * * // create numbered legend items * chart.legend(dc.legend().legendText(function(d, i) { return i + '. ' + d.name; })) * * // create legend displaying group counts * chart.legend(dc.legend().legendText(function(d) { return d.name + ': ' d.data; })) **/ _legend.legendText = function (legendText) { if (!arguments.length) { return _legendText; } _legendText = legendText; return _legend; }; /** * Maximum number of legend items to display * @method maxItems * @memberof dc.legend * @instance * @param {Number} [maxItems] * @return {dc.legend} */ _legend.maxItems = function (maxItems) { if (!arguments.length) { return _maxItems; } _maxItems = dc.utils.isNumber(maxItems) ? maxItems : undefined; return _legend; }; return _legend; }; /** * htmlLegend is a attachable widget that can be added to other dc charts to render horizontal/vertical legend * labels. * * @class htmlLegend * @memberof dc * @example * chart.legend(dc.htmlLegend().container(legendContainerElement).horizontal(false)) * @returns {dc.htmlLegend} */ dc.htmlLegend = function () { var _legend = {}, _htmlLegendDivCssClass = 'dc-html-legend', _legendItemCssClassHorizontal = 'dc-legend-item-horizontal', _legendItemCssClassVertical = 'dc-legend-item-vertical', _parent, _container, _legendText = dc.pluck('name'), _maxItems, _horizontal = false, _legendItemClass, _highlightSelected = false; _legend.parent = function (p) { if (!arguments.length) { return _parent; } _parent = p; return _legend; }; _legend.render = function () { var _defaultLegendItemCssClass = _horizontal ? _legendItemCssClassHorizontal : _legendItemCssClassVertical; _container.select('div.dc-html-legend').remove(); var _l = _container.append('div').attr('class', _htmlLegendDivCssClass); _l.attr('style', 'max-width:' + _container.nodes()[0].style.width); var legendables = _parent.legendables(); var filters = _parent.filters(); if (_maxItems !== undefined) { legendables = legendables.slice(0, _maxItems); } var legendItemClassName = _legendItemClass ? _legendItemClass : _defaultLegendItemCssClass; var itemEnter = _l.selectAll('div.' + legendItemClassName) .data(legendables).enter() .append('div') .classed(legendItemClassName, true) .on('mouseover', _parent.legendHighlight) .on('mouseout', _parent.legendReset) .on('click', _parent.legendToggle); if (_highlightSelected) { itemEnter.classed(dc.constants.SELECTED_CLASS, function (d) { return filters.indexOf(d.name) !== -1; }); } itemEnter.append('span') .attr('class', 'dc-legend-item-color') .style('background-color', dc.pluck('color')); itemEnter.append('span') .attr('class', 'dc-legend-item-label') .attr('title', _legendText) .text(_legendText); }; /** #### .container([selector]) Set the container selector for the legend widget. Required. **/ _legend.container = function (c) { if (!arguments.length) { return _container; } _container = d3.select(c); return _legend; }; /** #### .legendItemClass([selector]) This can be optionally used to override class for legenditem and just use this class style. The reason to have this is so this can be done for a particular chart rather than overriding the style for all charts Setting this will disable the highlighting of selected items also. **/ _legend.legendItemClass = function (c) { if (!arguments.length) { return _legendItemClass; } _legendItemClass = c; return _legend; }; /** #### .highlightSelected([boolean]) This can be optionally used to enable highlighting legends for the selections/filters for the chart. **/ _legend.highlightSelected = function (c) { if (!arguments.length) { return _highlightSelected; } _highlightSelected = c; return _legend; }; /** #### .horizontal([boolean]) Display the legend horizontally instead of horizontally **/ _legend.horizontal = function (b) { if (!arguments.length) { return _horizontal; } _horizontal = b; return _legend; }; /** * Set or get the legend text function. The legend widget uses this function to render the legend * text for each item. If no function is specified the legend widget will display the names * associated with each group. * @method legendText * @memberof dc.htmlLegend * @instance * @param {Function} [legendText] * @returns {Function|dc.htmlLegend} * @example * // default legendText * legend.legendText(dc.pluck('name')) * * // create numbered legend items * chart.legend(dc.htmlLegend().legendText(function(d, i) { return i + '. ' + d.name; })) * * // create legend displaying group counts * chart.legend(dc.htmlLegend().legendText(function(d) { return d.name + ': ' d.data; })) **/ _legend.legendText = function (legendText) { if (!arguments.length) { return _legendText; } _legendText = legendText; return _legend; }; /** * Maximum number of legend items to display * @method maxItems * @memberof dc.htmlLegend * @instance * @param {Number} [maxItems] * @return {dc.htmlLegend} */ _legend.maxItems = function (maxItems) { if (!arguments.length) { return _maxItems; } _maxItems = dc.utils.isNumber(maxItems) ? maxItems : undefined; return _legend; }; return _legend; }; /** * A scatter plot chart * * Examples: * - {@link http://dc-js.github.io/dc.js/examples/scatter.html Scatter Chart} * - {@link http://dc-js.github.io/dc.js/examples/multi-scatter.html Multi-Scatter Chart} * @class scatterPlot * @memberof dc * @mixes dc.coordinateGridMixin * @example * // create a scatter plot under #chart-container1 element using the default global chart group * var chart1 = dc.scatterPlot('#chart-container1'); * // create a scatter plot under #chart-container2 element using chart group A * var chart2 = dc.scatterPlot('#chart-container2', 'chartGroupA'); * // create a sub-chart under a composite parent chart * var chart3 = dc.scatterPlot(compositeChart); * @param {String|node|d3.selection} parent - Any valid * {@link https://github.com/d3/d3-selection/blob/master/README.md#select d3 single selector} specifying * a dom block element such as a div; or a dom element or d3 selection. * @param {String} [chartGroup] - The name of the chart group this chart instance should be placed in. * Interaction with a chart will only trigger events and redraws within the chart's group. * @returns {dc.scatterPlot} */ dc.scatterPlot = function (parent, chartGroup) { var _chart = dc.coordinateGridMixin({}); var _symbol = d3.symbol(); var _existenceAccessor = function (d) { return d.value; }; var originalKeyAccessor = _chart.keyAccessor(); _chart.keyAccessor(function (d) { return originalKeyAccessor(d)[0]; }); _chart.valueAccessor(function (d) { return originalKeyAccessor(d)[1]; }); _chart.colorAccessor(function () { return _chart._groupName; }); _chart.title(function (d) { // this basically just counteracts the setting of its own key/value accessors // see https://github.com/dc-js/dc.js/issues/702 return _chart.keyAccessor()(d) + ',' + _chart.valueAccessor()(d) + ': ' + _chart.existenceAccessor()(d); }); var _locator = function (d) { return 'translate(' + _chart.x()(_chart.keyAccessor()(d)) + ',' + _chart.y()(_chart.valueAccessor()(d)) + ')'; }; var _highlightedSize = 7; var _symbolSize = 5; var _excludedSize = 3; var _excludedColor = null; var _excludedOpacity = 1.0; var _emptySize = 0; var _emptyOpacity = 0; var _nonemptyOpacity = 1; var _emptyColor = null; var _filtered = []; // Use a 2 dimensional brush _chart.brush(d3.brush()); function elementSize (d, i) { if (!_existenceAccessor(d)) { return Math.pow(_emptySize, 2); } else if (_filtered[i]) { return Math.pow(_symbolSize, 2); } else { return Math.pow(_excludedSize, 2); } } _symbol.size(elementSize); dc.override(_chart, '_filter', function (filter) { if (!arguments.length) { return _chart.__filter(); } return _chart.__filter(dc.filters.RangedTwoDimensionalFilter(filter)); }); _chart.plotData = function () { var symbols = _chart.chartBodyG().selectAll('path.symbol') .data(_chart.data()); symbols = symbols .enter() .append('path') .attr('class', 'symbol') .attr('opacity', 0) .attr('fill', _chart.getColor) .attr('transform', _locator) .merge(symbols); symbols.call(renderTitles, _chart.data()); symbols.each(function (d, i) { _filtered[i] = !_chart.filter() || _chart.filter().isFiltered([_chart.keyAccessor()(d), _chart.valueAccessor()(d)]); }); dc.transition(symbols, _chart.transitionDuration(), _chart.transitionDelay()) .attr('opacity', function (d, i) { if (!_existenceAccessor(d)) { return _emptyOpacity; } else if (_filtered[i]) { return _nonemptyOpacity; } else { return _chart.excludedOpacity(); } }) .attr('fill', function (d, i) { if (_emptyColor && !_existenceAccessor(d)) { return _emptyColor; } else if (_chart.excludedColor() && !_filtered[i]) { return _chart.excludedColor(); } else { return _chart.getColor(d); } }) .attr('transform', _locator) .attr('d', _symbol); dc.transition(symbols.exit(), _chart.transitionDuration(), _chart.transitionDelay()) .attr('opacity', 0).remove(); }; function renderTitles (symbol, d) { if (_chart.renderTitle()) { symbol.selectAll('title').remove(); symbol.append('title').text(function (d) { return _chart.title()(d); }); } } /** * Get or set the existence accessor. If a point exists, it is drawn with * {@link dc.scatterPlot#symbolSize symbolSize} radius and * opacity 1; if it does not exist, it is drawn with * {@link dc.scatterPlot#emptySize emptySize} radius and opacity 0. By default, * the existence accessor checks if the reduced value is truthy. * @method existenceAccessor * @memberof dc.scatterPlot * @instance * @see {@link dc.scatterPlot#symbolSize symbolSize} * @see {@link dc.scatterPlot#emptySize emptySize} * @example * // default accessor * chart.existenceAccessor(function (d) { return d.value; }); * @param {Function} [accessor] * @returns {Function|dc.scatterPlot} */ _chart.existenceAccessor = function (accessor) { if (!arguments.length) { return _existenceAccessor; } _existenceAccessor = accessor; return this; }; /** * Get or set the symbol type used for each point. By default the symbol is a circle (d3.symbolCircle). * Type can be a constant or an accessor. * @method symbol * @memberof dc.scatterPlot * @instance * @see {@link https://github.com/d3/d3-shape/blob/master/README.md#symbol_type symbol.type} * @example * // Circle type * chart.symbol(d3.symbolCircle); * // Square type * chart.symbol(d3.symbolSquare); * @param {Function} [type=d3.symbolCircle] * @returns {Function|dc.scatterPlot} */ _chart.symbol = function (type) { if (!arguments.length) { return _symbol.type(); } _symbol.type(type); return _chart; }; /** * Get or set the symbol generator. By default `dc.scatterPlot` will use * {@link https://github.com/d3/d3-shape/blob/master/README.md#symbol d3.symbol()} * to generate symbols. `dc.scatterPlot` will set the * {@link https://github.com/d3/d3-shape/blob/master/README.md#symbol_size symbol size accessor} * on the symbol generator. * @method customSymbol * @memberof dc.scatterPlot * @instance * @see {@link https://github.com/d3/d3-shape/blob/master/README.md#symbol d3.symbol} * @see {@link https://stackoverflow.com/questions/25332120/create-additional-d3-js-symbols Create additional D3.js symbols} * @param {String|Function} [customSymbol=d3.symbol()] * @returns {String|Function|dc.scatterPlot} */ _chart.customSymbol = function (customSymbol) { if (!arguments.length) { return _symbol; } _symbol = customSymbol; _symbol.size(elementSize); return _chart; }; /** * Set or get radius for symbols. * @method symbolSize * @memberof dc.scatterPlot * @instance * @see {@link https://github.com/d3/d3-shape/blob/master/README.md#symbol_size d3.symbol.size} * @param {Number} [symbolSize=3] * @returns {Number|dc.scatterPlot} */ _chart.symbolSize = function (symbolSize) { if (!arguments.length) { return _symbolSize; } _symbolSize = symbolSize; return _chart; }; /** * Set or get radius for highlighted symbols. * @method highlightedSize * @memberof dc.scatterPlot * @instance * @see {@link https://github.com/d3/d3-shape/blob/master/README.md#symbol_size d3.symbol.size} * @param {Number} [highlightedSize=5] * @returns {Number|dc.scatterPlot} */ _chart.highlightedSize = function (highlightedSize) { if (!arguments.length) { return _highlightedSize; } _highlightedSize = highlightedSize; return _chart; }; /** * Set or get size for symbols excluded from this chart's filter. If null, no * special size is applied for symbols based on their filter status. * @method excludedSize * @memberof dc.scatterPlot * @instance * @see {@link https://github.com/d3/d3-shape/blob/master/README.md#symbol_size d3.symbol.size} * @param {Number} [excludedSize=null] * @returns {Number|dc.scatterPlot} */ _chart.excludedSize = function (excludedSize) { if (!arguments.length) { return _excludedSize; } _excludedSize = excludedSize; return _chart; }; /** * Set or get color for symbols excluded from this chart's filter. If null, no * special color is applied for symbols based on their filter status. * @method excludedColor * @memberof dc.scatterPlot * @instance * @param {Number} [excludedColor=null] * @returns {Number|dc.scatterPlot} */ _chart.excludedColor = function (excludedColor) { if (!arguments.length) { return _excludedColor; } _excludedColor = excludedColor; return _chart; }; /** * Set or get opacity for symbols excluded from this chart's filter. * @method excludedOpacity * @memberof dc.scatterPlot * @instance * @param {Number} [excludedOpacity=1.0] * @returns {Number|dc.scatterPlot} */ _chart.excludedOpacity = function (excludedOpacity) { if (!arguments.length) { return _excludedOpacity; } _excludedOpacity = excludedOpacity; return _chart; }; /** * Set or get radius for symbols when the group is empty. * @method emptySize * @memberof dc.scatterPlot * @instance * @see {@link https://github.com/d3/d3-shape/blob/master/README.md#symbol_size d3.symbol.size} * @param {Number} [emptySize=0] * @returns {Number|dc.scatterPlot} */ _chart.hiddenSize = _chart.emptySize = function (emptySize) { if (!arguments.length) { return _emptySize; } _emptySize = emptySize; return _chart; }; /** * Set or get color for symbols when the group is empty. If null, just use the * {@link dc.colorMixin#colors colorMixin.colors} color scale zero value. * @name emptyColor * @memberof dc.scatterPlot * @instance * @param {String} [emptyColor=null] * @return {String} * @return {dc.scatterPlot}/ */ _chart.emptyColor = function (emptyColor) { if (!arguments.length) { return _emptyColor; } _emptyColor = emptyColor; return _chart; }; /** * Set or get opacity for symbols when the group is empty. * @name emptyOpacity * @memberof dc.scatterPlot * @instance * @param {Number} [emptyOpacity=0] * @return {Number} * @return {dc.scatterPlot} */ _chart.emptyOpacity = function (emptyOpacity) { if (!arguments.length) { return _emptyOpacity; } _emptyOpacity = emptyOpacity; return _chart; }; /** * Set or get opacity for symbols when the group is not empty. * @name nonemptyOpacity * @memberof dc.scatterPlot * @instance * @param {Number} [nonemptyOpacity=1] * @return {Number} * @return {dc.scatterPlot} */ _chart.nonemptyOpacity = function (nonemptyOpacity) { if (!arguments.length) { return _emptyOpacity; } _nonemptyOpacity = nonemptyOpacity; return _chart; }; _chart.legendables = function () { return [{chart: _chart, name: _chart._groupName, color: _chart.getColor()}]; }; _chart.legendHighlight = function (d) { resizeSymbolsWhere(function (symbol) { return symbol.attr('fill') === d.color; }, _highlightedSize); _chart.chartBodyG().selectAll('.chart-body path.symbol').filter(function () { return d3.select(this).attr('fill') !== d.color; }).classed('fadeout', true); }; _chart.legendReset = function (d) { resizeSymbolsWhere(function (symbol) { return symbol.attr('fill') === d.color; }, _symbolSize); _chart.chartBodyG().selectAll('.chart-body path.symbol').filter(function () { return d3.select(this).attr('fill') !== d.color; }).classed('fadeout', false); }; function resizeSymbolsWhere (condition, size) { var symbols = _chart.chartBodyG().selectAll('.chart-body path.symbol').filter(function () { return condition(d3.select(this)); }); var oldSize = _symbol.size(); _symbol.size(Math.pow(size, 2)); dc.transition(symbols, _chart.transitionDuration(), _chart.transitionDelay()).attr('d', _symbol); _symbol.size(oldSize); } _chart.createBrushHandlePaths = function () { // no handle paths for poly-brushes }; _chart.extendBrush = function (brushSelection) { if (_chart.round()) { brushSelection[0] = brushSelection[0].map(_chart.round()); brushSelection[1] = brushSelection[1].map(_chart.round()); } return brushSelection; }; _chart.brushIsEmpty = function (brushSelection) { return !brushSelection || brushSelection[0][0] >= brushSelection[1][0] || brushSelection[0][1] >= brushSelection[1][1]; }; _chart._brushing = function () { // Avoids infinite recursion (mutual recursion between range and focus operations) // Source Event will be null when brush.move is called programmatically (see below as well). if (!d3.event.sourceEvent) { return; } // Ignore event if recursive event - i.e. not directly generated by user action (like mouse/touch etc.) // In this case we are more worried about this handler causing brush move programmatically which will // cause this handler to be invoked again with a new d3.event (and current event set as sourceEvent) // This check avoids recursive calls if (d3.event.sourceEvent.type && ['start', 'brush', 'end'].indexOf(d3.event.sourceEvent.type) !== -1) { return; } var brushSelection = d3.event.selection; // Testing with pixels is more reliable var brushIsEmpty = _chart.brushIsEmpty(brushSelection); if (brushSelection) { brushSelection = brushSelection.map(function (point) { return point.map(function (coord, i) { var scale = i === 0 ? _chart.x() : _chart.y(); return scale.invert(coord); }); }); brushSelection = _chart.extendBrush(brushSelection); // The rounding process might have made brushSelection empty, so we need to recheck brushIsEmpty = brushIsEmpty && _chart.brushIsEmpty(brushSelection); } _chart.redrawBrush(brushSelection, false); var ranged2DFilter = brushIsEmpty ? null : dc.filters.RangedTwoDimensionalFilter(brushSelection); dc.events.trigger(function () { _chart.replaceFilter(ranged2DFilter); _chart.redrawGroup(); }, dc.constants.EVENT_DELAY); }; _chart.redrawBrush = function (brushSelection, doTransition) { // override default x axis brush from parent chart var _brush = _chart.brush(); var _gBrush = _chart.gBrush(); if (_chart.brushOn() && _gBrush) { if (_chart.resizing()) { _chart.setBrushExtents(doTransition); } if (!brushSelection) { _gBrush .call(_brush.move, brushSelection); } else { brushSelection = brushSelection.map(function (point) { return point.map(function (coord, i) { var scale = i === 0 ? _chart.x() : _chart.y(); return scale(coord); }); }); var gBrush = dc.optionalTransition(doTransition, _chart.transitionDuration(), _chart.transitionDelay())(_gBrush); gBrush .call(_brush.move, brushSelection); } } _chart.fadeDeselectedArea(brushSelection); }; _chart.setBrushY = function (gBrush) { gBrush.call(_chart.brush().y(_chart.y())); }; return _chart.anchor(parent, chartGroup); }; /** * A display of a single numeric value. * Unlike other charts, you do not need to set a dimension. Instead a group object must be provided and * a valueAccessor that returns a single value. * @class numberDisplay * @memberof dc * @mixes dc.baseMixin * @example * // create a number display under #chart-container1 element using the default global chart group * var display1 = dc.numberDisplay('#chart-container1'); * @param {String|node|d3.selection} parent - Any valid * {@link https://github.com/d3/d3-selection/blob/master/README.md#select d3 single selector} specifying * a dom block element such as a div; or a dom element or d3 selection. * @param {String} [chartGroup] - The name of the chart group this chart instance should be placed in. * Interaction with a chart will only trigger events and redraws within the chart's group. * @returns {dc.numberDisplay} */ dc.numberDisplay = function (parent, chartGroup) { var SPAN_CLASS = 'number-display'; var _formatNumber = d3.format('.2s'); var _chart = dc.baseMixin({}); var _html = {one: '', some: '', none: ''}; var _lastValue; // dimension not required _chart._mandatoryAttributes(['group']); // default to ordering by value, to emulate old group.top(1) behavior when multiple groups _chart.ordering(function (kv) { return kv.value; }); /** * Gets or sets an optional object specifying HTML templates to use depending on the number * displayed. The text `%number` will be replaced with the current value. * - one: HTML template to use if the number is 1 * - zero: HTML template to use if the number is 0 * - some: HTML template to use otherwise * @method html * @memberof dc.numberDisplay * @instance * @example * numberWidget.html({ * one:'%number record', * some:'%number records', * none:'no records'}) * @param {{one:String, some:String, none:String}} [html={one: '', some: '', none: ''}] * @returns {{one:String, some:String, none:String}|dc.numberDisplay} */ _chart.html = function (html) { if (!arguments.length) { return _html; } if (html.none) { _html.none = html.none;//if none available } else if (html.one) { _html.none = html.one;//if none not available use one } else if (html.some) { _html.none = html.some;//if none and one not available use some } if (html.one) { _html.one = html.one;//if one available } else if (html.some) { _html.one = html.some;//if one not available use some } if (html.some) { _html.some = html.some;//if some available } else if (html.one) { _html.some = html.one;//if some not available use one } return _chart; }; /** * Calculate and return the underlying value of the display. * @method value * @memberof dc.numberDisplay * @instance * @returns {Number} */ _chart.value = function () { return _chart.data(); }; function maxBin (all) { if (!all.length) { return null; } var sorted = _chart._computeOrderedGroups(all); return sorted[sorted.length - 1]; } _chart.data(function (group) { var valObj = group.value ? group.value() : maxBin(group.all()); return _chart.valueAccessor()(valObj); }); _chart.transitionDuration(250); // good default _chart.transitionDelay(0); _chart._doRender = function () { var newValue = _chart.value(), span = _chart.selectAll('.' + SPAN_CLASS); if (span.empty()) { span = span.data([0]) .enter() .append('span') .attr('class', SPAN_CLASS) .merge(span); } span.transition() .duration(_chart.transitionDuration()) .delay(_chart.transitionDelay()) .ease(d3.easeQuad) .tween('text', function () { // [XA] don't try and interpolate from Infinity, else this breaks. var interpStart = isFinite(_lastValue) ? _lastValue : 0; var interp = d3.interpolateNumber(interpStart || 0, newValue); _lastValue = newValue; // need to save it in D3v4 var node = this; return function (t) { var html = null, num = _chart.formatNumber()(interp(t)); if (newValue === 0 && (_html.none !== '')) { html = _html.none; } else if (newValue === 1 && (_html.one !== '')) { html = _html.one; } else if (_html.some !== '') { html = _html.some; } node.innerHTML = html ? html.replace('%number', num) : num; }; }); }; _chart._doRedraw = function () { return _chart._doRender(); }; /** * Get or set a function to format the value for the display. * @method formatNumber * @memberof dc.numberDisplay * @instance * @see {@link https://github.com/d3/d3-format/blob/master/README.md#format d3.format} * @param {Function} [formatter=d3.format('.2s')] * @returns {Function|dc.numberDisplay} */ _chart.formatNumber = function (formatter) { if (!arguments.length) { return _formatNumber; } _formatNumber = formatter; return _chart; }; return _chart.anchor(parent, chartGroup); }; /** * A heat map is matrix that represents the values of two dimensions of data using colors. * @class heatMap * @memberof dc * @mixes dc.colorMixin * @mixes dc.marginMixin * @mixes dc.baseMixin * @example * // create a heat map under #chart-container1 element using the default global chart group * var heatMap1 = dc.heatMap('#chart-container1'); * // create a heat map under #chart-container2 element using chart group A * var heatMap2 = dc.heatMap('#chart-container2', 'chartGroupA'); * @param {String|node|d3.selection} parent - Any valid * {@link https://github.com/d3/d3-selection/blob/master/README.md#select d3 single selector} specifying * a dom block element such as a div; or a dom element or d3 selection. * @param {String} [chartGroup] - The name of the chart group this chart instance should be placed in. * Interaction with a chart will only trigger events and redraws within the chart's group. * @returns {dc.heatMap} */ dc.heatMap = function (parent, chartGroup) { var DEFAULT_BORDER_RADIUS = 6.75; var _chartBody; var _cols; var _rows; var _colOrdering = d3.ascending; var _rowOrdering = d3.ascending; var _colScale = d3.scaleBand(); var _rowScale = d3.scaleBand(); var _xBorderRadius = DEFAULT_BORDER_RADIUS; var _yBorderRadius = DEFAULT_BORDER_RADIUS; var _chart = dc.colorMixin(dc.marginMixin(dc.baseMixin({}))); _chart._mandatoryAttributes(['group']); _chart.title(_chart.colorAccessor()); var _colsLabel = function (d) { return d; }; var _rowsLabel = function (d) { return d; }; /** * Set or get the column label function. The chart class uses this function to render * column labels on the X axis. It is passed the column name. * @method colsLabel * @memberof dc.heatMap * @instance * @example * // the default label function just returns the name * chart.colsLabel(function(d) { return d; }); * @param {Function} [labelFunction=function(d) { return d; }] * @returns {Function|dc.heatMap} */ _chart.colsLabel = function (labelFunction) { if (!arguments.length) { return _colsLabel; } _colsLabel = labelFunction; return _chart; }; /** * Set or get the row label function. The chart class uses this function to render * row labels on the Y axis. It is passed the row name. * @method rowsLabel * @memberof dc.heatMap * @instance * @example * // the default label function just returns the name * chart.rowsLabel(function(d) { return d; }); * @param {Function} [labelFunction=function(d) { return d; }] * @returns {Function|dc.heatMap} */ _chart.rowsLabel = function (labelFunction) { if (!arguments.length) { return _rowsLabel; } _rowsLabel = labelFunction; return _chart; }; var _xAxisOnClick = function (d) { filterAxis(0, d); }; var _yAxisOnClick = function (d) { filterAxis(1, d); }; var _boxOnClick = function (d) { var filter = d.key; dc.events.trigger(function () { _chart.filter(filter); _chart.redrawGroup(); }); }; function filterAxis (axis, value) { var cellsOnAxis = _chart.selectAll('.box-group').filter(function (d) { return d.key[axis] === value; }); var unfilteredCellsOnAxis = cellsOnAxis.filter(function (d) { return !_chart.hasFilter(d.key); }); dc.events.trigger(function () { var selection = unfilteredCellsOnAxis.empty() ? cellsOnAxis : unfilteredCellsOnAxis; var filters = selection.data().map(function (kv) { return dc.filters.TwoDimensionalFilter(kv.key); }); _chart._filter([filters]); _chart.redrawGroup(); }); } dc.override(_chart, 'filter', function (filter) { if (!arguments.length) { return _chart._filter(); } return _chart._filter(dc.filters.TwoDimensionalFilter(filter)); }); /** * Gets or sets the values used to create the rows of the heatmap, as an array. By default, all * the values will be fetched from the data using the value accessor. * @method rows * @memberof dc.heatMap * @instance * @param {Array} [rows] * @returns {Array|dc.heatMap} */ _chart.rows = function (rows) { if (!arguments.length) { return _rows; } _rows = rows; return _chart; }; /** #### .rowOrdering([orderFunction]) Get or set an accessor to order the rows. Default is d3.ascending. */ _chart.rowOrdering = function (_) { if (!arguments.length) { return _rowOrdering; } _rowOrdering = _; return _chart; }; /** * Gets or sets the keys used to create the columns of the heatmap, as an array. By default, all * the values will be fetched from the data using the key accessor. * @method cols * @memberof dc.heatMap * @instance * @param {Array} [cols] * @returns {Array|dc.heatMap} */ _chart.cols = function (cols) { if (!arguments.length) { return _cols; } _cols = cols; return _chart; }; /** #### .colOrdering([orderFunction]) Get or set an accessor to order the cols. Default is ascending. */ _chart.colOrdering = function (_) { if (!arguments.length) { return _colOrdering; } _colOrdering = _; return _chart; }; _chart._doRender = function () { _chart.resetSvg(); _chartBody = _chart.svg() .append('g') .attr('class', 'heatmap') .attr('transform', 'translate(' + _chart.margins().left + ',' + _chart.margins().top + ')'); return _chart._doRedraw(); }; _chart._doRedraw = function () { var data = _chart.data(), rows = _chart.rows() || data.map(_chart.valueAccessor()), cols = _chart.cols() || data.map(_chart.keyAccessor()); if (_rowOrdering) { rows = rows.sort(_rowOrdering); } if (_colOrdering) { cols = cols.sort(_colOrdering); } rows = _rowScale.domain(rows); cols = _colScale.domain(cols); var rowCount = rows.domain().length, colCount = cols.domain().length, boxWidth = Math.floor(_chart.effectiveWidth() / colCount), boxHeight = Math.floor(_chart.effectiveHeight() / rowCount); cols.rangeRound([0, _chart.effectiveWidth()]); rows.rangeRound([_chart.effectiveHeight(), 0]); var boxes = _chartBody.selectAll('g.box-group').data(_chart.data(), function (d, i) { return _chart.keyAccessor()(d, i) + '\0' + _chart.valueAccessor()(d, i); }); boxes.exit().remove(); var gEnter = boxes.enter().append('g') .attr('class', 'box-group'); gEnter.append('rect') .attr('class', 'heat-box') .attr('fill', 'white') .attr('x', function (d, i) { return cols(_chart.keyAccessor()(d, i)); }) .attr('y', function (d, i) { return rows(_chart.valueAccessor()(d, i)); }) .on('click', _chart.boxOnClick()); if (_chart.renderTitle()) { gEnter.append('title'); boxes.select('title').text(_chart.title()); } boxes = gEnter.merge(boxes); dc.transition(boxes.select('rect'), _chart.transitionDuration(), _chart.transitionDelay()) .attr('x', function (d, i) { return cols(_chart.keyAccessor()(d, i)); }) .attr('y', function (d, i) { return rows(_chart.valueAccessor()(d, i)); }) .attr('rx', _xBorderRadius) .attr('ry', _yBorderRadius) .attr('fill', _chart.getColor) .attr('width', boxWidth) .attr('height', boxHeight); var gCols = _chartBody.select('g.cols'); if (gCols.empty()) { gCols = _chartBody.append('g').attr('class', 'cols axis'); } var gColsText = gCols.selectAll('text').data(cols.domain()); gColsText.exit().remove(); gColsText = gColsText .enter() .append('text') .attr('x', function (d) { return cols(d) + boxWidth / 2; }) .style('text-anchor', 'middle') .attr('y', _chart.effectiveHeight()) .attr('dy', 12) .on('click', _chart.xAxisOnClick()) .text(_chart.colsLabel()) .merge(gColsText); dc.transition(gColsText, _chart.transitionDuration(), _chart.transitionDelay()) .text(_chart.colsLabel()) .attr('x', function (d) { return cols(d) + boxWidth / 2; }) .attr('y', _chart.effectiveHeight()); var gRows = _chartBody.select('g.rows'); if (gRows.empty()) { gRows = _chartBody.append('g').attr('class', 'rows axis'); } var gRowsText = gRows.selectAll('text').data(rows.domain()); gRowsText.exit().remove(); gRowsText = gRowsText .enter() .append('text') .style('text-anchor', 'end') .attr('x', 0) .attr('dx', -2) .attr('y', function (d) { return rows(d) + boxHeight / 2; }) .attr('dy', 6) .on('click', _chart.yAxisOnClick()) .text(_chart.rowsLabel()) .merge(gRowsText); dc.transition(gRowsText, _chart.transitionDuration(), _chart.transitionDelay()) .text(_chart.rowsLabel()) .attr('y', function (d) { return rows(d) + boxHeight / 2; }); if (_chart.hasFilter()) { _chart.selectAll('g.box-group').each(function (d) { if (_chart.isSelectedNode(d)) { _chart.highlightSelected(this); } else { _chart.fadeDeselected(this); } }); } else { _chart.selectAll('g.box-group').each(function () { _chart.resetHighlight(this); }); } return _chart; }; /** * Gets or sets the handler that fires when an individual cell is clicked in the heatmap. * By default, filtering of the cell will be toggled. * @method boxOnClick * @memberof dc.heatMap * @instance * @example * // default box on click handler * chart.boxOnClick(function (d) { * var filter = d.key; * dc.events.trigger(function () { * _chart.filter(filter); * _chart.redrawGroup(); * }); * }); * @param {Function} [handler] * @returns {Function|dc.heatMap} */ _chart.boxOnClick = function (handler) { if (!arguments.length) { return _boxOnClick; } _boxOnClick = handler; return _chart; }; /** * Gets or sets the handler that fires when a column tick is clicked in the x axis. * By default, if any cells in the column are unselected, the whole column will be selected, * otherwise the whole column will be unselected. * @method xAxisOnClick * @memberof dc.heatMap * @instance * @param {Function} [handler] * @returns {Function|dc.heatMap} */ _chart.xAxisOnClick = function (handler) { if (!arguments.length) { return _xAxisOnClick; } _xAxisOnClick = handler; return _chart; }; /** * Gets or sets the handler that fires when a row tick is clicked in the y axis. * By default, if any cells in the row are unselected, the whole row will be selected, * otherwise the whole row will be unselected. * @method yAxisOnClick * @memberof dc.heatMap * @instance * @param {Function} [handler] * @returns {Function|dc.heatMap} */ _chart.yAxisOnClick = function (handler) { if (!arguments.length) { return _yAxisOnClick; } _yAxisOnClick = handler; return _chart; }; /** * Gets or sets the X border radius. Set to 0 to get full rectangles. * @method xBorderRadius * @memberof dc.heatMap * @instance * @param {Number} [xBorderRadius=6.75] * @returns {Number|dc.heatMap} */ _chart.xBorderRadius = function (xBorderRadius) { if (!arguments.length) { return _xBorderRadius; } _xBorderRadius = xBorderRadius; return _chart; }; /** * Gets or sets the Y border radius. Set to 0 to get full rectangles. * @method yBorderRadius * @memberof dc.heatMap * @instance * @param {Number} [yBorderRadius=6.75] * @returns {Number|dc.heatMap} */ _chart.yBorderRadius = function (yBorderRadius) { if (!arguments.length) { return _yBorderRadius; } _yBorderRadius = yBorderRadius; return _chart; }; _chart.isSelectedNode = function (d) { return _chart.hasFilter(d.key); }; return _chart.anchor(parent, chartGroup); }; // https://github.com/d3/d3-plugins/blob/master/box/box.js (function () { // Inspired by http://informationandvisualization.de/blog/box-plot d3.box = function () { var width = 1, height = 1, duration = 0, delay = 0, domain = null, value = Number, whiskers = boxWhiskers, quartiles = boxQuartiles, tickFormat = null; // For each small multiple… function box (g) { g.each(function (d, i) { d = d.map(value).sort(d3.ascending); var g = d3.select(this), n = d.length, min = d[0], max = d[n - 1]; // Compute quartiles. Must return exactly 3 elements. var quartileData = d.quartiles = quartiles(d); // Compute whiskers. Must return exactly 2 elements, or null. var whiskerIndices = whiskers && whiskers.call(this, d, i), whiskerData = whiskerIndices && whiskerIndices.map(function (i) { return d[i]; }); // Compute outliers. If no whiskers are specified, all data are 'outliers'. // We compute the outliers as indices, so that we can join across transitions! var outlierIndices = whiskerIndices ? d3.range(0, whiskerIndices[0]).concat(d3.range(whiskerIndices[1] + 1, n)) : d3.range(n); // Compute the new x-scale. var x1 = d3.scaleLinear() .domain(domain && domain.call(this, d, i) || [min, max]) .range([height, 0]); // Retrieve the old x-scale, if this is an update. var x0 = this.__chart__ || d3.scaleLinear() .domain([0, Infinity]) .range(x1.range()); // Stash the new scale. this.__chart__ = x1; // Note: the box, median, and box tick elements are fixed in number, // so we only have to handle enter and update. In contrast, the outliers // and other elements are variable, so we need to exit them! Variable // elements also fade in and out. // Update center line: the vertical line spanning the whiskers. var center = g.selectAll('line.center') .data(whiskerData ? [whiskerData] : []); center.enter().insert('line', 'rect') .attr('class', 'center') .attr('x1', width / 2) .attr('y1', function (d) { return x0(d[0]); }) .attr('x2', width / 2) .attr('y2', function (d) { return x0(d[1]); }) .style('opacity', 1e-6) .transition() .duration(duration) .delay(delay) .style('opacity', 1) .attr('y1', function (d) { return x1(d[0]); }) .attr('y2', function (d) { return x1(d[1]); }); center.transition() .duration(duration) .delay(delay) .style('opacity', 1) .attr('x1', width / 2) .attr('x2', width / 2) .attr('y1', function (d) { return x1(d[0]); }) .attr('y2', function (d) { return x1(d[1]); }); center.exit().transition() .duration(duration) .delay(delay) .style('opacity', 1e-6) .attr('y1', function (d) { return x1(d[0]); }) .attr('y2', function (d) { return x1(d[1]); }) .remove(); // Update innerquartile box. var box = g.selectAll('rect.box') .data([quartileData]); box.enter().append('rect') .attr('class', 'box') .attr('x', 0) .attr('y', function (d) { return x0(d[2]); }) .attr('width', width) .attr('height', function (d) { return x0(d[0]) - x0(d[2]); }) .transition() .duration(duration) .delay(delay) .attr('y', function (d) { return x1(d[2]); }) .attr('height', function (d) { return x1(d[0]) - x1(d[2]); }); box.transition() .duration(duration) .delay(delay) .attr('width', width) .attr('y', function (d) { return x1(d[2]); }) .attr('height', function (d) { return x1(d[0]) - x1(d[2]); }); // Update median line. var medianLine = g.selectAll('line.median') .data([quartileData[1]]); medianLine.enter().append('line') .attr('class', 'median') .attr('x1', 0) .attr('y1', x0) .attr('x2', width) .attr('y2', x0) .transition() .duration(duration) .delay(delay) .attr('y1', x1) .attr('y2', x1); medianLine.transition() .duration(duration) .delay(delay) .attr('x1', 0) .attr('x2', width) .attr('y1', x1) .attr('y2', x1); // Update whiskers. var whisker = g.selectAll('line.whisker') .data(whiskerData || []); whisker.enter().insert('line', 'circle, text') .attr('class', 'whisker') .attr('x1', 0) .attr('y1', x0) .attr('x2', width) .attr('y2', x0) .style('opacity', 1e-6) .transition() .duration(duration) .delay(delay) .attr('y1', x1) .attr('y2', x1) .style('opacity', 1); whisker.transition() .duration(duration) .delay(delay) .attr('x1', 0) .attr('x2', width) .attr('y1', x1) .attr('y2', x1) .style('opacity', 1); whisker.exit().transition() .duration(duration) .delay(delay) .attr('y1', x1) .attr('y2', x1) .style('opacity', 1e-6) .remove(); // Update outliers. var outlier = g.selectAll('circle.outlier') .data(outlierIndices, Number); outlier.enter().insert('circle', 'text') .attr('class', 'outlier') .attr('r', 5) .attr('cx', width / 2) .attr('cy', function (i) { return x0(d[i]); }) .style('opacity', 1e-6) .transition() .duration(duration) .delay(delay) .attr('cy', function (i) { return x1(d[i]); }) .style('opacity', 1); outlier.transition() .duration(duration) .delay(delay) .attr('cx', width / 2) .attr('cy', function (i) { return x1(d[i]); }) .style('opacity', 1); outlier.exit().transition() .duration(duration) .delay(delay) .attr('cy', function (i) { return x1(d[i]); }) .style('opacity', 1e-6) .remove(); // Compute the tick format. var format = tickFormat || x1.tickFormat(8); // Update box ticks. var boxTick = g.selectAll('text.box') .data(quartileData); boxTick.enter().append('text') .attr('class', 'box') .attr('dy', '.3em') .attr('dx', function (d, i) { return i & 1 ? 6 : -6; }) .attr('x', function (d, i) { return i & 1 ? width : 0; }) .attr('y', x0) .attr('text-anchor', function (d, i) { return i & 1 ? 'start' : 'end'; }) .text(format) .transition() .duration(duration) .delay(delay) .attr('y', x1); boxTick.transition() .duration(duration) .delay(delay) .text(format) .attr('x', function (d, i) { return i & 1 ? width : 0; }) .attr('y', x1); // Update whisker ticks. These are handled separately from the box // ticks because they may or may not exist, and we want don't want // to join box ticks pre-transition with whisker ticks post-. var whiskerTick = g.selectAll('text.whisker') .data(whiskerData || []); whiskerTick.enter().append('text') .attr('class', 'whisker') .attr('dy', '.3em') .attr('dx', 6) .attr('x', width) .attr('y', x0) .text(format) .style('opacity', 1e-6) .transition() .duration(duration) .delay(delay) .attr('y', x1) .style('opacity', 1); whiskerTick.transition() .duration(duration) .delay(delay) .text(format) .attr('x', width) .attr('y', x1) .style('opacity', 1); whiskerTick.exit().transition() .duration(duration) .delay(delay) .attr('y', x1) .style('opacity', 1e-6) .remove(); }); d3.timerFlush(); } box.width = function (x) { if (!arguments.length) { return width; } width = x; return box; }; box.height = function (x) { if (!arguments.length) { return height; } height = x; return box; }; box.tickFormat = function (x) { if (!arguments.length) { return tickFormat; } tickFormat = x; return box; }; box.duration = function (x) { if (!arguments.length) { return duration; } duration = x; return box; }; box.domain = function (x) { if (!arguments.length) { return domain; } domain = x === null ? x : typeof x === 'function' ? x : dc.utils.constant(x); return box; }; box.value = function (x) { if (!arguments.length) { return value; } value = x; return box; }; box.whiskers = function (x) { if (!arguments.length) { return whiskers; } whiskers = x; return box; }; box.quartiles = function (x) { if (!arguments.length) { return quartiles; } quartiles = x; return box; }; return box; }; function boxWhiskers (d) { return [0, d.length - 1]; } function boxQuartiles (d) { return [ d3.quantile(d, 0.25), d3.quantile(d, 0.5), d3.quantile(d, 0.75) ]; } })(); /** * A box plot is a chart that depicts numerical data via their quartile ranges. * * Examples: * - {@link http://dc-js.github.io/dc.js/examples/box-plot-time.html Box plot time example} * - {@link http://dc-js.github.io/dc.js/examples/box-plot.html Box plot example} * @class boxPlot * @memberof dc * @mixes dc.coordinateGridMixin * @example * // create a box plot under #chart-container1 element using the default global chart group * var boxPlot1 = dc.boxPlot('#chart-container1'); * // create a box plot under #chart-container2 element using chart group A * var boxPlot2 = dc.boxPlot('#chart-container2', 'chartGroupA'); * @param {String|node|d3.selection} parent - Any valid * {@link https://github.com/d3/d3-selection/blob/master/README.md#select d3 single selector} specifying * a dom block element such as a div; or a dom element or d3 selection. * @param {String} [chartGroup] - The name of the chart group this chart instance should be placed in. * Interaction with a chart will only trigger events and redraws within the chart's group. * @returns {dc.boxPlot} */ dc.boxPlot = function (parent, chartGroup) { var _chart = dc.coordinateGridMixin({}); // Returns a function to compute the interquartile range. function DEFAULT_WHISKERS_IQR (k) { return function (d) { var q1 = d.quartiles[0], q3 = d.quartiles[2], iqr = (q3 - q1) * k, i = -1, j = d.length; do { ++i; } while (d[i] < q1 - iqr); do { --j; } while (d[j] > q3 + iqr); return [i, j]; }; } var _whiskerIqrFactor = 1.5; var _whiskersIqr = DEFAULT_WHISKERS_IQR; var _whiskers = _whiskersIqr(_whiskerIqrFactor); var _box = d3.box(); var _tickFormat = null; var _boxWidth = function (innerChartWidth, xUnits) { if (_chart.isOrdinal()) { return _chart.x().bandwidth(); } else { return innerChartWidth / (1 + _chart.boxPadding()) / xUnits; } }; // default padding to handle min/max whisker text _chart.yAxisPadding(12); // default to ordinal _chart.x(d3.scaleBand()); _chart.xUnits(dc.units.ordinal); // valueAccessor should return an array of values that can be coerced into numbers // or if data is overloaded for a static array of arrays, it should be `Number`. // Empty arrays are not included. _chart.data(function (group) { return group.all().map(function (d) { d.map = function (accessor) { return accessor.call(d, d); }; return d; }).filter(function (d) { var values = _chart.valueAccessor()(d); return values.length !== 0; }); }); /** * Get or set the spacing between boxes as a fraction of box size. Valid values are within 0-1. * See the {@link https://github.com/d3/d3-scale/blob/master/README.md#scaleBand d3 docs} * for a visual description of how the padding is applied. * @method boxPadding * @memberof dc.boxPlot * @instance * @see {@link https://github.com/d3/d3-scale/blob/master/README.md#scaleBand d3.scaleBand} * @param {Number} [padding=0.8] * @returns {Number|dc.boxPlot} */ _chart.boxPadding = _chart._rangeBandPadding; _chart.boxPadding(0.8); /** * Get or set the outer padding on an ordinal box chart. This setting has no effect on non-ordinal charts * or on charts with a custom {@link dc.boxPlot#boxWidth .boxWidth}. Will pad the width by * `padding * barWidth` on each side of the chart. * @method outerPadding * @memberof dc.boxPlot * @instance * @param {Number} [padding=0.5] * @returns {Number|dc.boxPlot} */ _chart.outerPadding = _chart._outerRangeBandPadding; _chart.outerPadding(0.5); /** * Get or set the numerical width of the boxplot box. The width may also be a function taking as * parameters the chart width excluding the right and left margins, as well as the number of x * units. * @example * // Using numerical parameter * chart.boxWidth(10); * // Using function * chart.boxWidth((innerChartWidth, xUnits) { ... }); * @method boxWidth * @memberof dc.boxPlot * @instance * @param {Number|Function} [boxWidth=0.5] * @returns {Number|Function|dc.boxPlot} */ _chart.boxWidth = function (boxWidth) { if (!arguments.length) { return _boxWidth; } _boxWidth = typeof boxWidth === 'function' ? boxWidth : dc.utils.constant(boxWidth); return _chart; }; var boxTransform = function (d, i) { var xOffset = _chart.x()(_chart.keyAccessor()(d, i)); return 'translate(' + xOffset + ', 0)'; }; _chart._preprocessData = function () { if (_chart.elasticX()) { _chart.x().domain([]); } }; _chart.plotData = function () { var _calculatedBoxWidth = _boxWidth(_chart.effectiveWidth(), _chart.xUnitCount()); _box.whiskers(_whiskers) .width(_calculatedBoxWidth) .height(_chart.effectiveHeight()) .value(_chart.valueAccessor()) .domain(_chart.y().domain()) .duration(_chart.transitionDuration()) .tickFormat(_tickFormat); var boxesG = _chart.chartBodyG().selectAll('g.box').data(_chart.data(), _chart.keyAccessor()); var boxesGEnterUpdate = renderBoxes(boxesG); updateBoxes(boxesGEnterUpdate); removeBoxes(boxesG); _chart.fadeDeselectedArea(_chart.filter()); }; function renderBoxes (boxesG) { var boxesGEnter = boxesG.enter().append('g'); boxesGEnter .attr('class', 'box') .attr('transform', boxTransform) .call(_box) .on('click', function (d) { _chart.filter(_chart.keyAccessor()(d)); _chart.redrawGroup(); }); return boxesGEnter.merge(boxesG); } function updateBoxes (boxesG) { dc.transition(boxesG, _chart.transitionDuration(), _chart.transitionDelay()) .attr('transform', boxTransform) .call(_box) .each(function () { d3.select(this).select('rect.box').attr('fill', _chart.getColor); }); } function removeBoxes (boxesG) { boxesG.exit().remove().call(_box); } _chart.fadeDeselectedArea = function (brushSelection) { if (_chart.hasFilter()) { if (_chart.isOrdinal()) { _chart.g().selectAll('g.box').each(function (d) { if (_chart.isSelectedNode(d)) { _chart.highlightSelected(this); } else { _chart.fadeDeselected(this); } }); } else { if (!(_chart.brushOn() || _chart.parentBrushOn())) { return; } var start = brushSelection[0]; var end = brushSelection[1]; var keyAccessor = _chart.keyAccessor(); _chart.g().selectAll('g.box').each(function (d) { var key = keyAccessor(d); if (key < start || key >= end) { _chart.fadeDeselected(this); } else { _chart.highlightSelected(this); } }); } } else { _chart.g().selectAll('g.box').each(function () { _chart.resetHighlight(this); }); } }; _chart.isSelectedNode = function (d) { return _chart.hasFilter(_chart.keyAccessor()(d)); }; _chart.yAxisMin = function () { var min = d3.min(_chart.data(), function (e) { return d3.min(_chart.valueAccessor()(e)); }); return dc.utils.subtract(min, _chart.yAxisPadding()); }; _chart.yAxisMax = function () { var max = d3.max(_chart.data(), function (e) { return d3.max(_chart.valueAccessor()(e)); }); return dc.utils.add(max, _chart.yAxisPadding()); }; /** * Set the numerical format of the boxplot median, whiskers and quartile labels. Defaults to * integer formatting. * @example * // format ticks to 2 decimal places * chart.tickFormat(d3.format('.2f')); * @method tickFormat * @memberof dc.boxPlot * @instance * @param {Function} [tickFormat] * @returns {Number|Function|dc.boxPlot} */ _chart.tickFormat = function (tickFormat) { if (!arguments.length) { return _tickFormat; } _tickFormat = tickFormat; return _chart; }; return _chart.anchor(parent, chartGroup); }; /** * The select menu is a simple widget designed to filter a dimension by selecting an option from * an HTML `` elements. The menu can be * made into a set of radio buttons (single select) or checkboxes (multiple). * @class cboxMenu * @memberof dc * @mixes dc.baseMixin * @example * // create a cboxMenu under #cbox-container using the default global chart group * var cbox = dc.cboxMenu('#cbox-container') * .dimension(states) * .group(stateGroup); * // the option text can be set via the title() function * // by default the option text is '`key`: `value`' * cbox.title(function (d){ * return 'STATE: ' + d.key; * }) * @param {String|node|d3.selection|dc.compositeChart} parent - Any valid * [d3 single selector](https://github.com/mbostock/d3/wiki/Selections#selecting-elements) specifying * a dom block element such as a div; or a dom element or d3 selection. * @param {String} [chartGroup] - The name of the chart group this widget should be placed in. * Interaction with the widget will only trigger events and redraws within its group. * @returns {cboxMenu} **/ dc.cboxMenu = function (parent, chartGroup) { var GROUP_CSS_CLASS = 'dc-cbox-group'; var ITEM_CSS_CLASS = 'dc-cbox-item'; var _chart = dc.baseMixin({}); var _cbox; var _promptText = 'Select all'; var _multiple = false; var _inputType = 'radio'; var _promptValue = null; // generate a random number to use as an ID var _randVal = Math.floor(Math.random() * (100000)) + 1; var _order = function (a, b) { return _chart.keyAccessor()(a) > _chart.keyAccessor()(b) ? 1 : _chart.keyAccessor()(b) > _chart.keyAccessor()(a) ? -1 : 0; }; var _filterDisplayed = function (d) { return _chart.valueAccessor()(d) > 0; }; _chart.data(function (group) { return group.all().filter(_filterDisplayed); }); _chart._doRender = function () { return _chart._doRedraw(); }; /* // IS THIS NEEDED? // Fixing IE 11 crash when redrawing the chart // see here for list of IE user Agents : // http://www.useragentstring.com/pages/useragentstring.php?name=Internet+Explorer var ua = window.navigator.userAgent; // test for IE 11 but not a lower version (which contains MSIE in UA) if (ua.indexOf('Trident/') > 0 && ua.indexOf('MSIE') === -1) { _chart.redraw = _chart.render; } */ _chart._doRedraw = function () { _chart.select('ul').remove(); _cbox = _chart.root() .append('ul') .classed(GROUP_CSS_CLASS, true); renderOptions(); if (_chart.hasFilter() && _multiple) { _cbox.selectAll('input') .property('checked', function (d) { // adding `false` avoids failing test cases in phantomjs return d && _chart.filters().indexOf(String(_chart.keyAccessor()(d))) >= 0 || false; }); } else if (_chart.hasFilter()) { _cbox.selectAll('input') .property('checked', function (d) { if (!d) { return false; } return _chart.keyAccessor()(d) === _chart.filter(); }); } return _chart; }; function renderOptions () { var options = _cbox .selectAll('li.' + ITEM_CSS_CLASS) .data(_chart.data(), function (d) { return _chart.keyAccessor()(d); }); options.exit().remove(); options = options.enter() .append('li') .classed(ITEM_CSS_CLASS, true) .merge(options); options .append('input') .attr('type', _inputType) .attr('value', function (d) { return _chart.keyAccessor()(d); }) .attr('name', 'domain_' + _randVal) .attr('id', function (d, i) { return 'input_' + _randVal + '_' + i; }); options .append('label') .attr('for', function (d, i) { return 'input_' + _randVal + '_' + i; }) .text(_chart.title()); // 'all' option if (_multiple) { _cbox .append('li') .append('input') .attr('type', 'reset') .text(_promptText) .on('click', onChange); } else { var li = _cbox.append('li'); li.append('input') .attr('type', _inputType) .attr('value', _promptValue) .attr('name', 'domain_' + _randVal) .attr('id', function (d, i) { return 'input_' + _randVal + '_all'; }) .property('checked', true); li.append('label') .attr('for', function (d, i) { return 'input_' + _randVal + '_all'; }) .text(_promptText); } _cbox .selectAll('li.' + ITEM_CSS_CLASS) .sort(_order); _cbox.on('change', onChange); return options; } function onChange (d, i) { var values, target = d3.select(d3.event.target), options; if (!target.datum()) { values = _promptValue || null; } else { options = d3.select(this).selectAll('input') .filter(function (o) { if (o) { return this.checked; } }); values = options.nodes().map(function (option) { return option.value; }); // check if only prompt option is selected if (!_multiple && values.length === 1) { values = values[0]; } } _chart.onChange(values); } _chart.onChange = function (val) { if (val && _multiple) { _chart.replaceFilter([val]); } else if (val) { _chart.replaceFilter(val); } else { _chart.filterAll(); } dc.events.trigger(function () { _chart.redrawGroup(); }); }; /** * Get or set the function that controls the ordering of option tags in the * cbox menu. By default options are ordered by the group key in ascending * order. * @name order * @memberof dc.cboxMenu * @instance * @param {Function} [order] * @example * // order by the group's value * chart.order(function (a,b) { * return a.value > b.value ? 1 : b.value > a.value ? -1 : 0; * }); **/ _chart.order = function (order) { if (!arguments.length) { return _order; } _order = order; return _chart; }; /** * Get or set the text displayed in the options used to prompt selection. * @name promptText * @memberof dc.cboxMenu * @instance * @param {String} [promptText='Select all'] * @example * chart.promptText('All states'); **/ _chart.promptText = function (_) { if (!arguments.length) { return _promptText; } _promptText = _; return _chart; }; /** * Get or set the function that filters options prior to display. By default options * with a value of < 1 are not displayed. * @name filterDisplayed * @memberof dc.cboxMenu * @instance * @param {function} [filterDisplayed] * @example * // display all options override the `filterDisplayed` function: * chart.filterDisplayed(function () { * return true; * }); **/ _chart.filterDisplayed = function (filterDisplayed) { if (!arguments.length) { return _filterDisplayed; } _filterDisplayed = filterDisplayed; return _chart; }; /** * Controls the type of input element. Setting it to true converts * the HTML `input` tags from radio buttons to checkboxes. * @name multiple * @memberof dc.cboxMenu * @instance * @param {boolean} [multiple=false] * @example * chart.multiple(true); **/ _chart.multiple = function (multiple) { if (!arguments.length) { return _multiple; } _multiple = multiple; if (_multiple) { _inputType = 'checkbox'; } else { _inputType = 'radio'; } return _chart; }; /** * Controls the default value to be used for * [dimension.filter](https://github.com/crossfilter/crossfilter/wiki/API-Reference#dimension_filter) * when only the prompt value is selected. If `null` (the default), no filtering will occur when * just the prompt is selected. * @name promptValue * @memberof dc.cboxMenu * @instance * @param {?*} [promptValue=null] **/ _chart.promptValue = function (promptValue) { if (!arguments.length) { return _promptValue; } _promptValue = promptValue; return _chart; }; return _chart.anchor(parent, chartGroup); }; // Renamed functions dc.abstractBubbleChart = dc.bubbleMixin; dc.baseChart = dc.baseMixin; dc.capped = dc.capMixin; dc.colorChart = dc.colorMixin; dc.coordinateGridChart = dc.coordinateGridMixin; dc.marginable = dc.marginMixin; dc.stackableChart = dc.stackMixin; // Expose d3 and crossfilter, so that clients in browserify // case can obtain them if they need them. dc.d3 = d3; dc.crossfilter = crossfilter; return dc;} if(typeof define === "function" && define.amd) { define(["d3", "crossfilter2"], _dc); } else if(typeof module === "object" && module.exports) { var _d3 = require('d3'); var _crossfilter = require('crossfilter2'); // When using npm + browserify, 'crossfilter' is a function, // since package.json specifies index.js as main function, and it // does special handling. When using bower + browserify, // there's no main in bower.json (in fact, there's no bower.json), // so we need to fix it. if (typeof _crossfilter !== "function") { _crossfilter = _crossfilter.crossfilter; } module.exports = _dc(_d3, _crossfilter); } else { this.dc = _dc(d3, crossfilter); } } )(); ================================================ FILE: js/admin/jquery-ui/css/smoothness/jquery-ui.css ================================================ /* * jQuery UI CSS Framework * Copyright (c) 2010 AUTHORS.txt (http://jqueryui.com/about) * Dual licensed under the MIT (MIT-LICENSE.txt) and GPL (GPL-LICENSE.txt) licenses. */ /* Layout helpers ----------------------------------*/ .ui-helper-hidden { display: none; } .ui-helper-hidden-accessible { position: absolute; left: -99999999px; } .ui-helper-reset { margin: 0; padding: 0; border: 0; outline: 0; line-height: 1.3; text-decoration: none; font-size: 100%; list-style: none; } .ui-helper-clearfix:after { content: "."; display: block; height: 0; clear: both; visibility: hidden; } .ui-helper-clearfix { display: inline-block; } /* required comment for clearfix to work in Opera \*/ * html .ui-helper-clearfix { height:1%; } .ui-helper-clearfix { display:block; } /* end clearfix */ .ui-helper-zfix { width: 100%; height: 100%; top: 0; left: 0; position: absolute; opacity: 0; filter:Alpha(Opacity=0); } /* Interaction Cues ----------------------------------*/ .ui-state-disabled { cursor: default !important; } /* Icons ----------------------------------*/ /* states and images */ .ui-icon { display: block; text-indent: -99999px; overflow: hidden; background-repeat: no-repeat; } /* Misc visuals ----------------------------------*/ /* Overlays */ .ui-widget-overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; } /* * jQuery UI CSS Framework * Copyright (c) 2010 AUTHORS.txt (http://jqueryui.com/about) * Dual licensed under the MIT (MIT-LICENSE.txt) and GPL (GPL-LICENSE.txt) licenses. * 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 */ /* Component containers ----------------------------------*/ .ui-widget { font-family: Verdana,Arial,sans-serif; font-size: 1.1em; } .ui-widget .ui-widget { font-size: 1em; } .ui-widget input, .ui-widget select, .ui-widget textarea, .ui-widget button { font-family: Verdana,Arial,sans-serif; font-size: 1em; } .ui-widget-content { border: 1px solid #aaaaaa; background: #ffffff url(images/ui-bg_flat_75_ffffff_40x100.png) 50% 50% repeat-x; color: #222222; } .ui-widget-content a { color: #222222; } .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; } .ui-widget-header a { color: #222222; } /* Interaction states ----------------------------------*/ .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; } .ui-state-default a, .ui-state-default a:link, .ui-state-default a:visited { color: #555555; text-decoration: none; } .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; } .ui-state-hover a, .ui-state-hover a:hover { color: #212121; text-decoration: none; } .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; } .ui-state-active a, .ui-state-active a:link, .ui-state-active a:visited { color: #212121; text-decoration: none; } .ui-widget :active { outline: none; } /* Interaction Cues ----------------------------------*/ .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; } .ui-state-highlight a, .ui-widget-content .ui-state-highlight a,.ui-widget-header .ui-state-highlight a { color: #363636; } .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; } .ui-state-error a, .ui-widget-content .ui-state-error a, .ui-widget-header .ui-state-error a { color: #cd0a0a; } .ui-state-error-text, .ui-widget-content .ui-state-error-text, .ui-widget-header .ui-state-error-text { color: #cd0a0a; } .ui-priority-primary, .ui-widget-content .ui-priority-primary, .ui-widget-header .ui-priority-primary { font-weight: bold; } .ui-priority-secondary, .ui-widget-content .ui-priority-secondary, .ui-widget-header .ui-priority-secondary { opacity: .7; filter:Alpha(Opacity=70); font-weight: normal; } .ui-state-disabled, .ui-widget-content .ui-state-disabled, .ui-widget-header .ui-state-disabled { opacity: .35; filter:Alpha(Opacity=35); background-image: none; } /* Icons ----------------------------------*/ /* states and images */ .ui-icon { width: 16px; height: 16px; background-image: url(images/ui-icons_222222_256x240.png); } .ui-widget-content .ui-icon {background-image: url(images/ui-icons_222222_256x240.png); } .ui-widget-header .ui-icon {background-image: url(images/ui-icons_222222_256x240.png); } .ui-state-default .ui-icon { background-image: url(images/ui-icons_888888_256x240.png); } .ui-state-hover .ui-icon, .ui-state-focus .ui-icon {background-image: url(images/ui-icons_454545_256x240.png); } .ui-state-active .ui-icon {background-image: url(images/ui-icons_454545_256x240.png); } .ui-state-highlight .ui-icon {background-image: url(images/ui-icons_2e83ff_256x240.png); } .ui-state-error .ui-icon, .ui-state-error-text .ui-icon {background-image: url(images/ui-icons_cd0a0a_256x240.png); } /* positioning */ .ui-icon-carat-1-n { background-position: 0 0; } .ui-icon-carat-1-ne { background-position: -16px 0; } .ui-icon-carat-1-e { background-position: -32px 0; } .ui-icon-carat-1-se { background-position: -48px 0; } .ui-icon-carat-1-s { background-position: -64px 0; } .ui-icon-carat-1-sw { background-position: -80px 0; } .ui-icon-carat-1-w { background-position: -96px 0; } .ui-icon-carat-1-nw { background-position: -112px 0; } .ui-icon-carat-2-n-s { background-position: -128px 0; } .ui-icon-carat-2-e-w { background-position: -144px 0; } .ui-icon-triangle-1-n { background-position: 0 -16px; } .ui-icon-triangle-1-ne { background-position: -16px -16px; } .ui-icon-triangle-1-e { background-position: -32px -16px; } .ui-icon-triangle-1-se { background-position: -48px -16px; } .ui-icon-triangle-1-s { background-position: -64px -16px; } .ui-icon-triangle-1-sw { background-position: -80px -16px; } .ui-icon-triangle-1-w { background-position: -96px -16px; } .ui-icon-triangle-1-nw { background-position: -112px -16px; } .ui-icon-triangle-2-n-s { background-position: -128px -16px; } .ui-icon-triangle-2-e-w { background-position: -144px -16px; } .ui-icon-arrow-1-n { background-position: 0 -32px; } .ui-icon-arrow-1-ne { background-position: -16px -32px; } .ui-icon-arrow-1-e { background-position: -32px -32px; } .ui-icon-arrow-1-se { background-position: -48px -32px; } .ui-icon-arrow-1-s { background-position: -64px -32px; } .ui-icon-arrow-1-sw { background-position: -80px -32px; } .ui-icon-arrow-1-w { background-position: -96px -32px; } .ui-icon-arrow-1-nw { background-position: -112px -32px; } .ui-icon-arrow-2-n-s { background-position: -128px -32px; } .ui-icon-arrow-2-ne-sw { background-position: -144px -32px; } .ui-icon-arrow-2-e-w { background-position: -160px -32px; } .ui-icon-arrow-2-se-nw { background-position: -176px -32px; } .ui-icon-arrowstop-1-n { background-position: -192px -32px; } .ui-icon-arrowstop-1-e { background-position: -208px -32px; } .ui-icon-arrowstop-1-s { background-position: -224px -32px; } .ui-icon-arrowstop-1-w { background-position: -240px -32px; } .ui-icon-arrowthick-1-n { background-position: 0 -48px; } .ui-icon-arrowthick-1-ne { background-position: -16px -48px; } .ui-icon-arrowthick-1-e { background-position: -32px -48px; } .ui-icon-arrowthick-1-se { background-position: -48px -48px; } .ui-icon-arrowthick-1-s { background-position: -64px -48px; } .ui-icon-arrowthick-1-sw { background-position: -80px -48px; } .ui-icon-arrowthick-1-w { background-position: -96px -48px; } .ui-icon-arrowthick-1-nw { background-position: -112px -48px; } .ui-icon-arrowthick-2-n-s { background-position: -128px -48px; } .ui-icon-arrowthick-2-ne-sw { background-position: -144px -48px; } .ui-icon-arrowthick-2-e-w { background-position: -160px -48px; } .ui-icon-arrowthick-2-se-nw { background-position: -176px -48px; } .ui-icon-arrowthickstop-1-n { background-position: -192px -48px; } .ui-icon-arrowthickstop-1-e { background-position: -208px -48px; } .ui-icon-arrowthickstop-1-s { background-position: -224px -48px; } .ui-icon-arrowthickstop-1-w { background-position: -240px -48px; } .ui-icon-arrowreturnthick-1-w { background-position: 0 -64px; } .ui-icon-arrowreturnthick-1-n { background-position: -16px -64px; } .ui-icon-arrowreturnthick-1-e { background-position: -32px -64px; } .ui-icon-arrowreturnthick-1-s { background-position: -48px -64px; } .ui-icon-arrowreturn-1-w { background-position: -64px -64px; } .ui-icon-arrowreturn-1-n { background-position: -80px -64px; } .ui-icon-arrowreturn-1-e { background-position: -96px -64px; } .ui-icon-arrowreturn-1-s { background-position: -112px -64px; } .ui-icon-arrowrefresh-1-w { background-position: -128px -64px; } .ui-icon-arrowrefresh-1-n { background-position: -144px -64px; } .ui-icon-arrowrefresh-1-e { background-position: -160px -64px; } .ui-icon-arrowrefresh-1-s { background-position: -176px -64px; } .ui-icon-arrow-4 { background-position: 0 -80px; } .ui-icon-arrow-4-diag { background-position: -16px -80px; } .ui-icon-extlink { background-position: -32px -80px; } .ui-icon-newwin { background-position: -48px -80px; } .ui-icon-refresh { background-position: -64px -80px; } .ui-icon-shuffle { background-position: -80px -80px; } .ui-icon-transfer-e-w { background-position: -96px -80px; } .ui-icon-transferthick-e-w { background-position: -112px -80px; } .ui-icon-folder-collapsed { background-position: 0 -96px; } .ui-icon-folder-open { background-position: -16px -96px; } .ui-icon-document { background-position: -32px -96px; } .ui-icon-document-b { background-position: -48px -96px; } .ui-icon-note { background-position: -64px -96px; } .ui-icon-mail-closed { background-position: -80px -96px; } .ui-icon-mail-open { background-position: -96px -96px; } .ui-icon-suitcase { background-position: -112px -96px; } .ui-icon-comment { background-position: -128px -96px; } .ui-icon-person { background-position: -144px -96px; } .ui-icon-print { background-position: -160px -96px; } .ui-icon-trash { background-position: -176px -96px; } .ui-icon-locked { background-position: -192px -96px; } .ui-icon-unlocked { background-position: -208px -96px; } .ui-icon-bookmark { background-position: -224px -96px; } .ui-icon-tag { background-position: -240px -96px; } .ui-icon-home { background-position: 0 -112px; } .ui-icon-flag { background-position: -16px -112px; } .ui-icon-calendar { background-position: -32px -112px; } .ui-icon-cart { background-position: -48px -112px; } .ui-icon-pencil { background-position: -64px -112px; } .ui-icon-clock { background-position: -80px -112px; } .ui-icon-disk { background-position: -96px -112px; } .ui-icon-calculator { background-position: -112px -112px; } .ui-icon-zoomin { background-position: -128px -112px; } .ui-icon-zoomout { background-position: -144px -112px; } .ui-icon-search { background-position: -160px -112px; } .ui-icon-wrench { background-position: -176px -112px; } .ui-icon-gear { background-position: -192px -112px; } .ui-icon-heart { background-position: -208px -112px; } .ui-icon-star { background-position: -224px -112px; } .ui-icon-link { background-position: -240px -112px; } .ui-icon-cancel { background-position: 0 -128px; } .ui-icon-plus { background-position: -16px -128px; } .ui-icon-plusthick { background-position: -32px -128px; } .ui-icon-minus { background-position: -48px -128px; } .ui-icon-minusthick { background-position: -64px -128px; } .ui-icon-close { background-position: -80px -128px; } .ui-icon-closethick { background-position: -96px -128px; } .ui-icon-key { background-position: -112px -128px; } .ui-icon-lightbulb { background-position: -128px -128px; } .ui-icon-scissors { background-position: -144px -128px; } .ui-icon-clipboard { background-position: -160px -128px; } .ui-icon-copy { background-position: -176px -128px; } .ui-icon-contact { background-position: -192px -128px; } .ui-icon-image { background-position: -208px -128px; } .ui-icon-video { background-position: -224px -128px; } .ui-icon-script { background-position: -240px -128px; } .ui-icon-alert { background-position: 0 -144px; } .ui-icon-info { background-position: -16px -144px; } .ui-icon-notice { background-position: -32px -144px; } .ui-icon-help { background-position: -48px -144px; } .ui-icon-check { background-position: -64px -144px; } .ui-icon-bullet { background-position: -80px -144px; } .ui-icon-radio-off { background-position: -96px -144px; } .ui-icon-radio-on { background-position: -112px -144px; } .ui-icon-pin-w { background-position: -128px -144px; } .ui-icon-pin-s { background-position: -144px -144px; } .ui-icon-play { background-position: 0 -160px; } .ui-icon-pause { background-position: -16px -160px; } .ui-icon-seek-next { background-position: -32px -160px; } .ui-icon-seek-prev { background-position: -48px -160px; } .ui-icon-seek-end { background-position: -64px -160px; } .ui-icon-seek-start { background-position: -80px -160px; } /* ui-icon-seek-first is deprecated, use ui-icon-seek-start instead */ .ui-icon-seek-first { background-position: -80px -160px; } .ui-icon-stop { background-position: -96px -160px; } .ui-icon-eject { background-position: -112px -160px; } .ui-icon-volume-off { background-position: -128px -160px; } .ui-icon-volume-on { background-position: -144px -160px; } .ui-icon-power { background-position: 0 -176px; } .ui-icon-signal-diag { background-position: -16px -176px; } .ui-icon-signal { background-position: -32px -176px; } .ui-icon-battery-0 { background-position: -48px -176px; } .ui-icon-battery-1 { background-position: -64px -176px; } .ui-icon-battery-2 { background-position: -80px -176px; } .ui-icon-battery-3 { background-position: -96px -176px; } .ui-icon-circle-plus { background-position: 0 -192px; } .ui-icon-circle-minus { background-position: -16px -192px; } .ui-icon-circle-close { background-position: -32px -192px; } .ui-icon-circle-triangle-e { background-position: -48px -192px; } .ui-icon-circle-triangle-s { background-position: -64px -192px; } .ui-icon-circle-triangle-w { background-position: -80px -192px; } .ui-icon-circle-triangle-n { background-position: -96px -192px; } .ui-icon-circle-arrow-e { background-position: -112px -192px; } .ui-icon-circle-arrow-s { background-position: -128px -192px; } .ui-icon-circle-arrow-w { background-position: -144px -192px; } .ui-icon-circle-arrow-n { background-position: -160px -192px; } .ui-icon-circle-zoomin { background-position: -176px -192px; } .ui-icon-circle-zoomout { background-position: -192px -192px; } .ui-icon-circle-check { background-position: -208px -192px; } .ui-icon-circlesmall-plus { background-position: 0 -208px; } .ui-icon-circlesmall-minus { background-position: -16px -208px; } .ui-icon-circlesmall-close { background-position: -32px -208px; } .ui-icon-squaresmall-plus { background-position: -48px -208px; } .ui-icon-squaresmall-minus { background-position: -64px -208px; } .ui-icon-squaresmall-close { background-position: -80px -208px; } .ui-icon-grip-dotted-vertical { background-position: 0 -224px; } .ui-icon-grip-dotted-horizontal { background-position: -16px -224px; } .ui-icon-grip-solid-vertical { background-position: -32px -224px; } .ui-icon-grip-solid-horizontal { background-position: -48px -224px; } .ui-icon-gripsmall-diagonal-se { background-position: -64px -224px; } .ui-icon-grip-diagonal-se { background-position: -80px -224px; } /* Misc visuals ----------------------------------*/ /* Corner radius */ .ui-corner-tl { -moz-border-radius-topleft: 4px; -webkit-border-top-left-radius: 4px; border-top-left-radius: 4px; } .ui-corner-tr { -moz-border-radius-topright: 4px; -webkit-border-top-right-radius: 4px; border-top-right-radius: 4px; } .ui-corner-bl { -moz-border-radius-bottomleft: 4px; -webkit-border-bottom-left-radius: 4px; border-bottom-left-radius: 4px; } .ui-corner-br { -moz-border-radius-bottomright: 4px; -webkit-border-bottom-right-radius: 4px; border-bottom-right-radius: 4px; } .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; } .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; } .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; } .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; } .ui-corner-all { -moz-border-radius: 4px; -webkit-border-radius: 4px; border-radius: 4px; } /* Overlays */ .ui-widget-overlay { background: #aaaaaa url(images/ui-bg_flat_0_aaaaaa_40x100.png) 50% 50% repeat-x; opacity: .30;filter:Alpha(Opacity=30); } .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 ----------------------------------*/ .ui-resizable { position: relative;} .ui-resizable-handle { position: absolute;font-size: 0.1px;z-index: 99999; display: block;} .ui-resizable-disabled .ui-resizable-handle, .ui-resizable-autohide .ui-resizable-handle { display: none; } .ui-resizable-n { cursor: n-resize; height: 7px; width: 100%; top: -5px; left: 0; } .ui-resizable-s { cursor: s-resize; height: 7px; width: 100%; bottom: -5px; left: 0; } .ui-resizable-e { cursor: e-resize; width: 7px; right: -5px; top: 0; height: 100%; } .ui-resizable-w { cursor: w-resize; width: 7px; left: -5px; top: 0; height: 100%; } .ui-resizable-se { cursor: se-resize; width: 12px; height: 12px; right: 1px; bottom: 1px; } .ui-resizable-sw { cursor: sw-resize; width: 9px; height: 9px; left: -5px; bottom: -5px; } .ui-resizable-nw { cursor: nw-resize; width: 9px; height: 9px; left: -5px; top: -5px; } .ui-resizable-ne { cursor: ne-resize; width: 9px; height: 9px; right: -5px; top: -5px;}/* Selectable ----------------------------------*/ .ui-selectable-helper { border:1px dotted black } /* Accordion ----------------------------------*/ .ui-accordion .ui-accordion-header { cursor: pointer; position: relative; margin-top: 1px; zoom: 1; } .ui-accordion .ui-accordion-li-fix { display: inline; } .ui-accordion .ui-accordion-header-active { border-bottom: 0 !important; } .ui-accordion .ui-accordion-header a { display: block; font-size: 1em; padding: .5em .5em .5em .7em; } /* IE7-/Win - Fix extra vertical space in lists */ .ui-accordion a { zoom: 1; } .ui-accordion-icons .ui-accordion-header a { padding-left: 2.2em; } .ui-accordion .ui-accordion-header .ui-icon { position: absolute; left: .5em; top: 50%; margin-top: -8px; } .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; } .ui-accordion .ui-accordion-content-active { display: block; }/* Autocomplete ----------------------------------*/ .ui-autocomplete { position: absolute; cursor: default; } .ui-autocomplete-loading { background: white url('images/ui-anim_basic_16x16.gif') right center no-repeat; } /* workarounds */ * html .ui-autocomplete { width:1px; } /* without this, the menu expands to 100% in IE6 */ /* Menu ----------------------------------*/ .ui-menu { list-style:none; padding: 2px; margin: 0; display:block; } .ui-menu .ui-menu { margin-top: -3px; } .ui-menu .ui-menu-item { margin:0; padding: 0; zoom: 1; float: left; clear: left; width: 100%; } .ui-menu .ui-menu-item a { text-decoration:none; display:block; padding:.2em .4em; line-height:1.5; zoom:1; } .ui-menu .ui-menu-item a.ui-state-hover, .ui-menu .ui-menu-item a.ui-state-active { font-weight: normal; margin: -1px; } /* Button ----------------------------------*/ .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 */ .ui-button-icon-only { width: 2.2em; } /* to make room for the icon, a width needs to be set here */ button.ui-button-icon-only { width: 2.4em; } /* button elements seem to need a little more width */ .ui-button-icons-only { width: 3.4em; } button.ui-button-icons-only { width: 3.7em; } /*button text element */ .ui-button .ui-button-text { display: block; line-height: 1.4; } .ui-button-text-only .ui-button-text { padding: .4em 1em; } .ui-button-icon-only .ui-button-text, .ui-button-icons-only .ui-button-text { padding: .4em; text-indent: -9999999px; } .ui-button-text-icon .ui-button-text, .ui-button-text-icons .ui-button-text { padding: .4em 1em .4em 2.1em; } .ui-button-text-icons .ui-button-text { padding-left: 2.1em; padding-right: 2.1em; } /* no icon support for input elements, provide padding by default */ input.ui-button { padding: .4em 1em; } /*button icon element(s) */ .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; } .ui-button-icon-only .ui-icon { left: 50%; margin-left: -8px; } .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; } .ui-button-text-icons .ui-button-icon-secondary, .ui-button-icons-only .ui-button-icon-secondary { right: .5em; } /*button sets*/ .ui-buttonset { margin-right: 7px; } .ui-buttonset .ui-button { margin-left: 0; margin-right: -.3em; } /* workarounds */ button.ui-button::-moz-focus-inner { border: 0; padding: 0; } /* reset extra padding in Firefox */ /* Dialog ----------------------------------*/ .ui-dialog { position: absolute; padding: .2em; width: 300px; overflow: hidden; } .ui-dialog .ui-dialog-titlebar { padding: .5em 1em .3em; position: relative; } .ui-dialog .ui-dialog-title { float: left; margin: .1em 16px .2em 0; } .ui-dialog .ui-dialog-titlebar-close { position: absolute; right: .3em; top: 50%; width: 19px; margin: -10px 0 0 0; padding: 1px; height: 18px; } .ui-dialog .ui-dialog-titlebar-close span { display: block; margin: 1px; } .ui-dialog .ui-dialog-titlebar-close:hover, .ui-dialog .ui-dialog-titlebar-close:focus { padding: 0; } .ui-dialog .ui-dialog-content { border: 0; padding: .5em 1em; background: none; overflow: auto; zoom: 1; } .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; } .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; } .ui-dialog .ui-resizable-se { width: 14px; height: 14px; right: 3px; bottom: 3px; } .ui-draggable .ui-dialog-titlebar { cursor: move; } /* Slider ----------------------------------*/ .ui-slider { position: relative; text-align: left; } .ui-slider .ui-slider-handle { position: absolute; z-index: 2; width: 1.2em; height: 1.2em; cursor: default; } .ui-slider .ui-slider-range { position: absolute; z-index: 1; font-size: .7em; display: block; border: 0; background-position: 0 0; } .ui-slider-horizontal { height: .8em; } .ui-slider-horizontal .ui-slider-handle { top: -.3em; margin-left: -.6em; } .ui-slider-horizontal .ui-slider-range { top: 0; height: 100%; } .ui-slider-horizontal .ui-slider-range-min { left: 0; } .ui-slider-horizontal .ui-slider-range-max { right: 0; } .ui-slider-vertical { width: .8em; height: 100px; } .ui-slider-vertical .ui-slider-handle { left: -.3em; margin-left: 0; margin-bottom: -.6em; } .ui-slider-vertical .ui-slider-range { left: 0; width: 100%; } .ui-slider-vertical .ui-slider-range-min { bottom: 0; } .ui-slider-vertical .ui-slider-range-max { top: 0; }/* Tabs ----------------------------------*/ .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") */ .ui-tabs .ui-tabs-nav { margin: 0; padding: .2em .2em 0; } .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; } .ui-tabs .ui-tabs-nav li a { float: left; padding: .5em 1em; text-decoration: none; } .ui-tabs .ui-tabs-nav li.ui-tabs-selected { margin-bottom: 0; padding-bottom: 1px; } .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; } .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... */ .ui-tabs .ui-tabs-panel { display: block; border-width: 0; padding: 1em 1.4em; background: none; } .ui-tabs .ui-tabs-hide { display: none !important; } /* Datepicker ----------------------------------*/ .ui-datepicker { width: 17em; padding: .2em .2em 0; } .ui-datepicker .ui-datepicker-header { position:relative; padding:.2em 0; } .ui-datepicker .ui-datepicker-prev, .ui-datepicker .ui-datepicker-next { position:absolute; top: 2px; width: 1.8em; height: 1.8em; } .ui-datepicker .ui-datepicker-prev-hover, .ui-datepicker .ui-datepicker-next-hover { top: 1px; } .ui-datepicker .ui-datepicker-prev { left:2px; } .ui-datepicker .ui-datepicker-next { right:2px; } .ui-datepicker .ui-datepicker-prev-hover { left:1px; } .ui-datepicker .ui-datepicker-next-hover { right:1px; } .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; } .ui-datepicker .ui-datepicker-title { margin: 0 2.3em; line-height: 1.8em; text-align: center; } .ui-datepicker .ui-datepicker-title select { font-size:1em; margin:1px 0; } .ui-datepicker select.ui-datepicker-month-year {width: 100%;} .ui-datepicker select.ui-datepicker-month, .ui-datepicker select.ui-datepicker-year { width: 49%;} .ui-datepicker table {width: 100%; font-size: .9em; border-collapse: collapse; margin:0 0 .4em; } .ui-datepicker th { padding: .7em .3em; text-align: center; font-weight: bold; border: 0; } .ui-datepicker td { border: 0; padding: 1px; } .ui-datepicker td span, .ui-datepicker td a { display: block; padding: .2em; text-align: right; text-decoration: none; } .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; } .ui-datepicker .ui-datepicker-buttonpane button { float: right; margin: .5em .2em .4em; cursor: pointer; padding: .2em .6em .3em .6em; width:auto; overflow:visible; } .ui-datepicker .ui-datepicker-buttonpane button.ui-datepicker-current { float:left; } /* with multiple calendars */ .ui-datepicker.ui-datepicker-multi { width:auto; } .ui-datepicker-multi .ui-datepicker-group { float:left; } .ui-datepicker-multi .ui-datepicker-group table { width:95%; margin:0 auto .4em; } .ui-datepicker-multi-2 .ui-datepicker-group { width:50%; } .ui-datepicker-multi-3 .ui-datepicker-group { width:33.3%; } .ui-datepicker-multi-4 .ui-datepicker-group { width:25%; } .ui-datepicker-multi .ui-datepicker-group-last .ui-datepicker-header { border-left-width:0; } .ui-datepicker-multi .ui-datepicker-group-middle .ui-datepicker-header { border-left-width:0; } .ui-datepicker-multi .ui-datepicker-buttonpane { clear:left; } .ui-datepicker-row-break { clear:both; width:100%; } /* RTL support */ .ui-datepicker-rtl { direction: rtl; } .ui-datepicker-rtl .ui-datepicker-prev { right: 2px; left: auto; } .ui-datepicker-rtl .ui-datepicker-next { left: 2px; right: auto; } .ui-datepicker-rtl .ui-datepicker-prev:hover { right: 1px; left: auto; } .ui-datepicker-rtl .ui-datepicker-next:hover { left: 1px; right: auto; } .ui-datepicker-rtl .ui-datepicker-buttonpane { clear:right; } .ui-datepicker-rtl .ui-datepicker-buttonpane button { float: left; } .ui-datepicker-rtl .ui-datepicker-buttonpane button.ui-datepicker-current { float:right; } .ui-datepicker-rtl .ui-datepicker-group { float:right; } .ui-datepicker-rtl .ui-datepicker-group-last .ui-datepicker-header { border-right-width:0; border-left-width:1px; } .ui-datepicker-rtl .ui-datepicker-group-middle .ui-datepicker-header { border-right-width:0; border-left-width:1px; } /* IE6 IFRAME FIX (taken from datepicker 1.5.3 */ .ui-datepicker-cover { display: none; /*sorry for IE5*/ display/**/: block; /*sorry for IE5*/ position: absolute; /*must have*/ z-index: -1; /*must have*/ filter: mask(); /*must have*/ top: -4px; /*must have*/ left: -4px; /*must have*/ width: 200px; /*must have*/ height: 200px; /*must have*/ }/* Progressbar ----------------------------------*/ .ui-progressbar { height:2em; text-align: left; } .ui-progressbar .ui-progressbar-value {margin: -1px; height:100%; } ================================================ FILE: js/admin/spectrum/spectrum.css ================================================ /*** Spectrum Colorpicker v1.8.0 https://github.com/bgrins/spectrum Author: Brian Grinstead License: MIT ***/ .sp-container { position:absolute; top:0; left:0; display:inline-block; *display: inline; *zoom: 1; /* https://github.com/bgrins/spectrum/issues/40 */ z-index: 9999994; overflow: hidden; } .sp-container.sp-flat { position: relative; } /* Fix for * { box-sizing: border-box; } */ .sp-container, .sp-container * { -webkit-box-sizing: content-box; -moz-box-sizing: content-box; box-sizing: content-box; } /* http://ansciath.tumblr.com/post/7347495869/css-aspect-ratio */ .sp-top { position:relative; width: 100%; display:inline-block; } .sp-top-inner { position:absolute; top:0; left:0; bottom:0; right:0; } .sp-color { position: absolute; top:0; left:0; bottom:0; right:20%; } .sp-hue { position: absolute; top:0; right:0; bottom:0; left:84%; height: 100%; } .sp-clear-enabled .sp-hue { top:33px; height: 77.5%; } .sp-fill { padding-top: 80%; } .sp-sat, .sp-val { position: absolute; top:0; left:0; right:0; bottom:0; } .sp-alpha-enabled .sp-top { margin-bottom: 18px; } .sp-alpha-enabled .sp-alpha { display: block; } .sp-alpha-handle { position:absolute; top:-4px; bottom: -4px; width: 6px; left: 50%; cursor: pointer; border: 1px solid black; background: white; opacity: .8; } .sp-alpha { display: none; position: absolute; bottom: -14px; right: 0; left: 0; height: 8px; } .sp-alpha-inner { border: solid 1px #333; } .sp-clear { display: none; } .sp-clear.sp-clear-display { background-position: center; } .sp-clear-enabled .sp-clear { display: block; position:absolute; top:0px; right:0; bottom:0; left:84%; height: 28px; } /* Don't allow text selection */ .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 { -webkit-user-select:none; -moz-user-select: -moz-none; -o-user-select:none; user-select: none; } .sp-container.sp-input-disabled .sp-input-container { display: none; } .sp-container.sp-buttons-disabled .sp-button-container { display: none; } .sp-container.sp-palette-buttons-disabled .sp-palette-button-container { display: none; } .sp-palette-only .sp-picker-container { display: none; } .sp-palette-disabled .sp-palette-container { display: none; } .sp-initial-disabled .sp-initial { display: none; } /* Gradients for hue, saturation and value instead of images. Not pretty... but it works */ .sp-sat { background-image: -webkit-gradient(linear, 0 0, 100% 0, from(#FFF), to(rgba(204, 154, 129, 0))); background-image: -webkit-linear-gradient(left, #FFF, rgba(204, 154, 129, 0)); background-image: -moz-linear-gradient(left, #fff, rgba(204, 154, 129, 0)); background-image: -o-linear-gradient(left, #fff, rgba(204, 154, 129, 0)); background-image: -ms-linear-gradient(left, #fff, rgba(204, 154, 129, 0)); background-image: linear-gradient(to right, #fff, rgba(204, 154, 129, 0)); -ms-filter: "progid:DXImageTransform.Microsoft.gradient(GradientType = 1, startColorstr=#FFFFFFFF, endColorstr=#00CC9A81)"; filter : progid:DXImageTransform.Microsoft.gradient(GradientType = 1, startColorstr='#FFFFFFFF', endColorstr='#00CC9A81'); } .sp-val { background-image: -webkit-gradient(linear, 0 100%, 0 0, from(#000000), to(rgba(204, 154, 129, 0))); background-image: -webkit-linear-gradient(bottom, #000000, rgba(204, 154, 129, 0)); background-image: -moz-linear-gradient(bottom, #000, rgba(204, 154, 129, 0)); background-image: -o-linear-gradient(bottom, #000, rgba(204, 154, 129, 0)); background-image: -ms-linear-gradient(bottom, #000, rgba(204, 154, 129, 0)); background-image: linear-gradient(to top, #000, rgba(204, 154, 129, 0)); -ms-filter: "progid:DXImageTransform.Microsoft.gradient(startColorstr=#00CC9A81, endColorstr=#FF000000)"; filter : progid:DXImageTransform.Microsoft.gradient(startColorstr='#00CC9A81', endColorstr='#FF000000'); } .sp-hue { background: -moz-linear-gradient(top, #ff0000 0%, #ffff00 17%, #00ff00 33%, #00ffff 50%, #0000ff 67%, #ff00ff 83%, #ff0000 100%); background: -ms-linear-gradient(top, #ff0000 0%, #ffff00 17%, #00ff00 33%, #00ffff 50%, #0000ff 67%, #ff00ff 83%, #ff0000 100%); background: -o-linear-gradient(top, #ff0000 0%, #ffff00 17%, #00ff00 33%, #00ffff 50%, #0000ff 67%, #ff00ff 83%, #ff0000 100%); 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)); background: -webkit-linear-gradient(top, #ff0000 0%, #ffff00 17%, #00ff00 33%, #00ffff 50%, #0000ff 67%, #ff00ff 83%, #ff0000 100%); background: linear-gradient(to bottom, #ff0000 0%, #ffff00 17%, #00ff00 33%, #00ffff 50%, #0000ff 67%, #ff00ff 83%, #ff0000 100%); } /* IE filters do not support multiple color stops. Generate 6 divs, line them up, and do two color gradients for each. Yes, really. */ .sp-1 { height:17%; filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff0000', endColorstr='#ffff00'); } .sp-2 { height:16%; filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffff00', endColorstr='#00ff00'); } .sp-3 { height:17%; filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#00ff00', endColorstr='#00ffff'); } .sp-4 { height:17%; filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#00ffff', endColorstr='#0000ff'); } .sp-5 { height:16%; filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#0000ff', endColorstr='#ff00ff'); } .sp-6 { height:17%; filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff00ff', endColorstr='#ff0000'); } .sp-hidden { display: none !important; } /* Clearfix hack */ .sp-cf:before, .sp-cf:after { content: ""; display: table; } .sp-cf:after { clear: both; } .sp-cf { *zoom: 1; } /* Mobile devices, make hue slider bigger so it is easier to slide */ @media (max-device-width: 480px) { .sp-color { right: 40%; } .sp-hue { left: 63%; } .sp-fill { padding-top: 60%; } } .sp-dragger { border-radius: 5px; height: 5px; width: 5px; border: 1px solid #fff; background: #000; cursor: pointer; position:absolute; top:0; left: 0; } .sp-slider { position: absolute; top:0; cursor:pointer; height: 3px; left: -1px; right: -1px; border: 1px solid #000; background: white; opacity: .8; } /* Theme authors: Here are the basic themeable display options (colors, fonts, global widths). See http://bgrins.github.io/spectrum/themes/ for instructions. */ .sp-container { border-radius: 0; background-color: #f1f1f1; border: solid 1px #ddd; padding: 0; } .sp-container, .sp-container button, .sp-container input, .sp-color, .sp-hue, .sp-clear { font: normal 12px "Lucida Grande", "Lucida Sans Unicode", "Lucida Sans", Geneva, Verdana, sans-serif; -webkit-box-sizing: border-box; -moz-box-sizing: border-box; -ms-box-sizing: border-box; box-sizing: border-box; } .sp-top { margin-bottom: 3px; } .sp-color, .sp-hue, .sp-clear { border: solid 1px #666; } /* Input */ .sp-input-container { float:right; width: 100px; margin-bottom: 4px; } .sp-initial-disabled .sp-input-container { width: 100%; } .sp-input { font-size: 12px !important; border: 1px inset; padding: 4px 5px; margin: 0; width: 100%; background:transparent; border-radius: 3px; color: #222; } .sp-input:focus { border: 1px solid orange; } .sp-input.sp-validation-error { border: 1px solid red; background: #fdd; } .sp-picker-container , .sp-palette-container { float:left; position: relative; padding: 10px; padding-bottom: 300px; margin-bottom: -290px; } .sp-picker-container { width: 172px; border-left: solid 1px #fff; } /* Palettes */ .sp-palette-container { border-right: solid 1px #ccc; } .sp-palette-only .sp-palette-container { border: 0; } .sp-palette .sp-thumb-el { display: block; position:relative; float:left; width: 24px; height: 15px; margin: 3px; cursor: pointer; border:solid 2px transparent; } .sp-palette .sp-thumb-el:hover, .sp-palette .sp-thumb-el.sp-thumb-active { border-color: orange; } .sp-thumb-el { position:relative; } /* Initial */ .sp-initial { float: left; border: solid 1px #333; } .sp-initial span { width: 30px; height: 25px; border:none; display:block; float:left; margin:0; } .sp-initial .sp-clear-display { background-position: center; } /* Buttons */ .sp-palette-button-container, .sp-button-container { float: right; } /* Replacer (the little preview div that shows up instead of the ) */ .sp-replacer { margin:0; overflow:hidden; cursor:pointer; padding: 4px; display:inline-block; *zoom: 1; *display: inline; border: solid 1px #ddd; /*background: #eee;*/ color: #333; vertical-align: middle; } .sp-replacer:hover, .sp-replacer.sp-active { border-color: #ddd; color: #111; } .sp-replacer.sp-disabled { cursor:default; border-color: silver; color: silver; } .sp-dd { padding: 2px 0; height: 16px; line-height: 16px; float:left; font-size:10px; } .sp-preview { position:relative; width:25px; height: 20px; border: solid 1px #222; margin-right: 5px; float:left; z-index: 0; } .sp-palette { *width: 220px; max-width: 220px; } .sp-palette .sp-thumb-el { width:16px; height: 16px; margin:2px 1px; border: solid 1px #d0d0d0; } .sp-container { padding-bottom:0; } /* Buttons: http://hellohappy.org/css3-buttons/ */ .sp-container button { background-color: #eeeeee; background-image: -webkit-linear-gradient(top, #eeeeee, #cccccc); background-image: -moz-linear-gradient(top, #eeeeee, #cccccc); background-image: -ms-linear-gradient(top, #eeeeee, #cccccc); background-image: -o-linear-gradient(top, #eeeeee, #cccccc); background-image: linear-gradient(to bottom, #eeeeee, #cccccc); border: 1px solid #ccc; border-bottom: 1px solid #bbb; border-radius: 3px; color: #333; font-size: 14px; line-height: 1; padding: 5px 4px; text-align: center; text-shadow: 0 1px 0 #eee; vertical-align: middle; } .sp-container button:hover { background-color: #dddddd; background-image: -webkit-linear-gradient(top, #dddddd, #bbbbbb); background-image: -moz-linear-gradient(top, #dddddd, #bbbbbb); background-image: -ms-linear-gradient(top, #dddddd, #bbbbbb); background-image: -o-linear-gradient(top, #dddddd, #bbbbbb); background-image: linear-gradient(to bottom, #dddddd, #bbbbbb); border: 1px solid #bbb; border-bottom: 1px solid #999; cursor: pointer; text-shadow: 0 1px 0 #ddd; } .sp-container button:active { border: 1px solid #aaa; border-bottom: 1px solid #888; -webkit-box-shadow: inset 0 0 5px 2px #aaaaaa, 0 1px 0 0 #eeeeee; -moz-box-shadow: inset 0 0 5px 2px #aaaaaa, 0 1px 0 0 #eeeeee; -ms-box-shadow: inset 0 0 5px 2px #aaaaaa, 0 1px 0 0 #eeeeee; -o-box-shadow: inset 0 0 5px 2px #aaaaaa, 0 1px 0 0 #eeeeee; box-shadow: inset 0 0 5px 2px #aaaaaa, 0 1px 0 0 #eeeeee; } .sp-cancel { font-size: 11px; color: #d93f3f !important; margin:0; padding:2px; margin-right: 5px; vertical-align: middle; text-decoration:none; } .sp-cancel:hover { color: #d93f3f !important; text-decoration: underline; } .sp-palette span:hover, .sp-palette span.sp-thumb-active { border-color: #000; } .sp-preview, .sp-alpha, .sp-thumb-el { position:relative; background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAIAAADZF8uwAAAAGUlEQVQYV2M4gwH+YwCGIasIUwhT25BVBADtzYNYrHvv4gAAAABJRU5ErkJggg==); } .sp-preview-inner, .sp-alpha-inner, .sp-thumb-inner { display:block; position:absolute; top:0;left:0;bottom:0;right:0; } .sp-palette .sp-thumb-inner { background-position: 50% 50%; background-repeat: no-repeat; } .sp-palette .sp-thumb-light.sp-thumb-active .sp-thumb-inner { background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAASCAYAAABWzo5XAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAIVJREFUeNpiYBhsgJFMffxAXABlN5JruT4Q3wfi/0DsT64h8UD8HmpIPCWG/KemIfOJCUB+Aoacx6EGBZyHBqI+WsDCwuQ9mhxeg2A210Ntfo8klk9sOMijaURm7yc1UP2RNCMbKE9ODK1HM6iegYLkfx8pligC9lCD7KmRof0ZhjQACDAAceovrtpVBRkAAAAASUVORK5CYII=); } .sp-palette .sp-thumb-dark.sp-thumb-active .sp-thumb-inner { background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAASCAYAAABWzo5XAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAadEVYdFNvZnR3YXJlAFBhaW50Lk5FVCB2My41LjEwMPRyoQAAAMdJREFUOE+tkgsNwzAMRMugEAahEAahEAZhEAqlEAZhEAohEAYh81X2dIm8fKpEspLGvudPOsUYpxE2BIJCroJmEW9qJ+MKaBFhEMNabSy9oIcIPwrB+afvAUFoK4H0tMaQ3XtlrggDhOVVMuT4E5MMG0FBbCEYzjYT7OxLEvIHQLY2zWwQ3D+9luyOQTfKDiFD3iUIfPk8VqrKjgAiSfGFPecrg6HN6m/iBcwiDAo7WiBeawa+Kwh7tZoSCGLMqwlSAzVDhoK+6vH4G0P5wdkAAAAASUVORK5CYII=); } .sp-clear-display { background-repeat:no-repeat; background-position: center; background-image: url(data:image/gif;base64,R0lGODlhFAAUAPcAAAAAAJmZmZ2dnZ6enqKioqOjo6SkpKWlpaampqenp6ioqKmpqaqqqqurq/Hx8fLy8vT09PX19ff39/j4+Pn5+fr6+vv7+wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH5BAEAAP8ALAAAAAAUABQAAAihAP9FoPCvoMGDBy08+EdhQAIJCCMybCDAAYUEARBAlFiQQoMABQhKUJBxY0SPICEYHBnggEmDKAuoPMjS5cGYMxHW3IiT478JJA8M/CjTZ0GgLRekNGpwAsYABHIypcAgQMsITDtWJYBR6NSqMico9cqR6tKfY7GeBCuVwlipDNmefAtTrkSzB1RaIAoXodsABiZAEFB06gIBWC1mLVgBa0AAOw==); } .sp-cancel { line-height: 28px; } button.sp-choose { color: #555; border-color: #ccc; background: #f7f7f7; -webkit-box-shadow: 0 1px 0 #ccc; box-shadow: 0 1px 0 #ccc; vertical-align: top; display: inline-block; text-decoration: none; font-size: 13px; line-height: 26px; height: 28px; margin: 0; padding: 0 10px 1px; cursor: pointer; border-width: 1px; border-style: solid; -webkit-appearance: none; -webkit-border-radius: 3px; border-radius: 3px; white-space: nowrap; -webkit-box-sizing: border-box; -moz-box-sizing: border-box; box-sizing: border-box; } ================================================ FILE: js/admin/spectrum/spectrum.js ================================================ // Spectrum Colorpicker v1.8.0 // https://github.com/bgrins/spectrum // Author: Brian Grinstead // License: MIT (function (factory) { "use strict"; if (typeof define === 'function' && define.amd) { // AMD define(['jquery'], factory); } else if (typeof exports == "object" && typeof module == "object") { // CommonJS module.exports = factory(require('jquery')); } else { // Browser factory(jQuery); } })(function($, undefined) { "use strict"; var defaultOpts = { // Callbacks beforeShow: noop, move: noop, change: noop, show: noop, hide: noop, // Options color: false, flat: false, showInput: false, allowEmpty: false, showButtons: true, clickoutFiresChange: true, showInitial: false, showPalette: false, showPaletteOnly: false, hideAfterPaletteSelect: false, togglePaletteOnly: false, showSelectionPalette: true, localStorageKey: false, appendTo: "body", maxSelectionSize: 7, cancelText: "cancel", chooseText: "choose", togglePaletteMoreText: "more", togglePaletteLessText: "less", clearText: "Clear Color Selection", noColorSelectedText: "No Color Selected", preferredFormat: false, className: "", // Deprecated - use containerClassName and replacerClassName instead. containerClassName: "", replacerClassName: "", showAlpha: false, theme: "sp-light", palette: [["#ffffff", "#000000", "#ff0000", "#ff8000", "#ffff00", "#008000", "#0000ff", "#4b0082", "#9400d3"]], selectionPalette: [], disabled: false, offset: null }, spectrums = [], IE = !!/msie/i.exec( window.navigator.userAgent ), rgbaSupport = (function() { function contains( str, substr ) { return !!~('' + str).indexOf(substr); } var elem = document.createElement('div'); var style = elem.style; style.cssText = 'background-color:rgba(0,0,0,.5)'; return contains(style.backgroundColor, 'rgba') || contains(style.backgroundColor, 'hsla'); })(), replaceInput = [ "
", "
", "
", "
" ].join(''), markup = (function () { // IE does not support gradients with multiple stops, so we need to simulate // that for the rainbow slider with 8 divs that each have a single gradient var gradientFix = ""; if (IE) { for (var i = 1; i <= 6; i++) { gradientFix += "
"; } } return [ "
", "
", "
", "
", "", "
", "
", "
", "
", "
", "
", "
", "
", "
", "
", "
", "
", "
", "
", "
", "
", "
", gradientFix, "
", "
", "
", "
", "
", "", "
", "
", "
", "", "", "
", "
", "
" ].join(""); })(); function paletteTemplate (p, color, className, opts) { var html = []; for (var i = 0; i < p.length; i++) { var current = p[i]; if(current) { var tiny = tinycolor(current); var c = tiny.toHsl().l < 0.5 ? "sp-thumb-el sp-thumb-dark" : "sp-thumb-el sp-thumb-light"; c += (tinycolor.equals(color, current)) ? " sp-thumb-active" : ""; var formattedString = tiny.toString(opts.preferredFormat || "rgb"); var swatchStyle = rgbaSupport ? ("background-color:" + tiny.toRgbString()) : "filter:" + tiny.toFilter(); html.push(''); } else { var cls = 'sp-clear-display'; html.push($('
') .append($('') .attr('title', opts.noColorSelectedText) ) .html() ); } } return "
" + html.join('') + "
"; } function hideAll() { for (var i = 0; i < spectrums.length; i++) { if (spectrums[i]) { spectrums[i].hide(); } } } function instanceOptions(o, callbackContext) { var opts = $.extend({}, defaultOpts, o); opts.callbacks = { 'move': bind(opts.move, callbackContext), 'change': bind(opts.change, callbackContext), 'show': bind(opts.show, callbackContext), 'hide': bind(opts.hide, callbackContext), 'beforeShow': bind(opts.beforeShow, callbackContext) }; return opts; } function spectrum(element, o) { var opts = instanceOptions(o, element), flat = opts.flat, showSelectionPalette = opts.showSelectionPalette, localStorageKey = opts.localStorageKey, theme = opts.theme, callbacks = opts.callbacks, resize = throttle(reflow, 10), visible = false, isDragging = false, dragWidth = 0, dragHeight = 0, dragHelperHeight = 0, slideHeight = 0, slideWidth = 0, alphaWidth = 0, alphaSlideHelperWidth = 0, slideHelperHeight = 0, currentHue = 0, currentSaturation = 0, currentValue = 0, currentAlpha = 1, palette = [], paletteArray = [], paletteLookup = {}, selectionPalette = opts.selectionPalette.slice(0), maxSelectionSize = opts.maxSelectionSize, draggingClass = "sp-dragging", shiftMovementDirection = null; var doc = element.ownerDocument, body = doc.body, boundElement = $(element), disabled = false, container = $(markup, doc).addClass(theme), pickerContainer = container.find(".sp-picker-container"), dragger = container.find(".sp-color"), dragHelper = container.find(".sp-dragger"), slider = container.find(".sp-hue"), slideHelper = container.find(".sp-slider"), alphaSliderInner = container.find(".sp-alpha-inner"), alphaSlider = container.find(".sp-alpha"), alphaSlideHelper = container.find(".sp-alpha-handle"), textInput = container.find(".sp-input"), paletteContainer = container.find(".sp-palette"), initialColorContainer = container.find(".sp-initial"), cancelButton = container.find(".sp-cancel"), clearButton = container.find(".sp-clear"), chooseButton = container.find(".sp-choose"), toggleButton = container.find(".sp-palette-toggle"), isInput = boundElement.is("input"), isInputTypeColor = isInput && boundElement.attr("type") === "color" && inputTypeColorSupport(), shouldReplace = isInput && !flat, replacer = (shouldReplace) ? $(replaceInput).addClass(theme).addClass(opts.className).addClass(opts.replacerClassName) : $([]), offsetElement = (shouldReplace) ? replacer : boundElement, previewElement = replacer.find(".sp-preview-inner"), initialColor = opts.color || (isInput && boundElement.val()), colorOnShow = false, currentPreferredFormat = opts.preferredFormat, clickoutFiresChange = !opts.showButtons || opts.clickoutFiresChange, isEmpty = !initialColor, allowEmpty = opts.allowEmpty && !isInputTypeColor; function applyOptions() { if (opts.showPaletteOnly) { opts.showPalette = true; } toggleButton.text(opts.showPaletteOnly ? opts.togglePaletteMoreText : opts.togglePaletteLessText); if (opts.palette) { palette = opts.palette.slice(0); paletteArray = $.isArray(palette[0]) ? palette : [palette]; paletteLookup = {}; for (var i = 0; i < paletteArray.length; i++) { for (var j = 0; j < paletteArray[i].length; j++) { var rgb = tinycolor(paletteArray[i][j]).toRgbString(); paletteLookup[rgb] = true; } } } container.toggleClass("sp-flat", flat); container.toggleClass("sp-input-disabled", !opts.showInput); container.toggleClass("sp-alpha-enabled", opts.showAlpha); container.toggleClass("sp-clear-enabled", allowEmpty); container.toggleClass("sp-buttons-disabled", !opts.showButtons); container.toggleClass("sp-palette-buttons-disabled", !opts.togglePaletteOnly); container.toggleClass("sp-palette-disabled", !opts.showPalette); container.toggleClass("sp-palette-only", opts.showPaletteOnly); container.toggleClass("sp-initial-disabled", !opts.showInitial); container.addClass(opts.className).addClass(opts.containerClassName); reflow(); } function initialize() { if (IE) { container.find("*:not(input)").attr("unselectable", "on"); } applyOptions(); if (shouldReplace) { boundElement.after(replacer).hide(); } if (!allowEmpty) { clearButton.hide(); } if (flat) { boundElement.after(container).hide(); } else { var appendTo = opts.appendTo === "parent" ? boundElement.parent() : $(opts.appendTo); if (appendTo.length !== 1) { appendTo = $("body"); } appendTo.append(container); } updateSelectionPaletteFromStorage(); offsetElement.bind("click.spectrum touchstart.spectrum", function (e) { if (!disabled) { toggle(); } e.stopPropagation(); if (!$(e.target).is("input")) { e.preventDefault(); } }); if(boundElement.is(":disabled") || (opts.disabled === true)) { disable(); } // Prevent clicks from bubbling up to document. This would cause it to be hidden. container.click(stopPropagation); // Handle user typed input textInput.change(setFromTextInput); textInput.bind("paste", function () { setTimeout(setFromTextInput, 1); }); textInput.keydown(function (e) { if (e.keyCode == 13) { setFromTextInput(); } }); cancelButton.text(opts.cancelText); cancelButton.bind("click.spectrum", function (e) { e.stopPropagation(); e.preventDefault(); revert(); hide(); }); clearButton.attr("title", opts.clearText); clearButton.bind("click.spectrum", function (e) { e.stopPropagation(); e.preventDefault(); isEmpty = true; move(); if(flat) { //for the flat style, this is a change event updateOriginalInput(true); } }); chooseButton.text(opts.chooseText); chooseButton.bind("click.spectrum", function (e) { e.stopPropagation(); e.preventDefault(); if (IE && textInput.is(":focus")) { textInput.trigger('change'); } if (isValid()) { updateOriginalInput(true); hide(); } }); toggleButton.text(opts.showPaletteOnly ? opts.togglePaletteMoreText : opts.togglePaletteLessText); toggleButton.bind("click.spectrum", function (e) { e.stopPropagation(); e.preventDefault(); opts.showPaletteOnly = !opts.showPaletteOnly; // To make sure the Picker area is drawn on the right, next to the // Palette area (and not below the palette), first move the Palette // to the left to make space for the picker, plus 5px extra. // The 'applyOptions' function puts the whole container back into place // and takes care of the button-text and the sp-palette-only CSS class. if (!opts.showPaletteOnly && !flat) { container.css('left', '-=' + (pickerContainer.outerWidth(true) + 5)); } applyOptions(); }); draggable(alphaSlider, function (dragX, dragY, e) { currentAlpha = (dragX / alphaWidth); isEmpty = false; if (e.shiftKey) { currentAlpha = Math.round(currentAlpha * 10) / 10; } move(); }, dragStart, dragStop); draggable(slider, function (dragX, dragY) { currentHue = parseFloat(dragY / slideHeight); isEmpty = false; if (!opts.showAlpha) { currentAlpha = 1; } move(); }, dragStart, dragStop); draggable(dragger, function (dragX, dragY, e) { // shift+drag should snap the movement to either the x or y axis. if (!e.shiftKey) { shiftMovementDirection = null; } else if (!shiftMovementDirection) { var oldDragX = currentSaturation * dragWidth; var oldDragY = dragHeight - (currentValue * dragHeight); var furtherFromX = Math.abs(dragX - oldDragX) > Math.abs(dragY - oldDragY); shiftMovementDirection = furtherFromX ? "x" : "y"; } var setSaturation = !shiftMovementDirection || shiftMovementDirection === "x"; var setValue = !shiftMovementDirection || shiftMovementDirection === "y"; if (setSaturation) { currentSaturation = parseFloat(dragX / dragWidth); } if (setValue) { currentValue = parseFloat((dragHeight - dragY) / dragHeight); } isEmpty = false; if (!opts.showAlpha) { currentAlpha = 1; } move(); }, dragStart, dragStop); if (!!initialColor) { set(initialColor); // In case color was black - update the preview UI and set the format // since the set function will not run (default color is black). updateUI(); currentPreferredFormat = opts.preferredFormat || tinycolor(initialColor).format; addColorToSelectionPalette(initialColor); } else { updateUI(); } if (flat) { show(); } function paletteElementClick(e) { if (e.data && e.data.ignore) { set($(e.target).closest(".sp-thumb-el").data("color")); move(); } else { set($(e.target).closest(".sp-thumb-el").data("color")); move(); updateOriginalInput(true); if (opts.hideAfterPaletteSelect) { hide(); } } return false; } var paletteEvent = IE ? "mousedown.spectrum" : "click.spectrum touchstart.spectrum"; paletteContainer.delegate(".sp-thumb-el", paletteEvent, paletteElementClick); initialColorContainer.delegate(".sp-thumb-el:nth-child(1)", paletteEvent, { ignore: true }, paletteElementClick); } function updateSelectionPaletteFromStorage() { if (localStorageKey && window.localStorage) { // Migrate old palettes over to new format. May want to remove this eventually. try { var oldPalette = window.localStorage[localStorageKey].split(",#"); if (oldPalette.length > 1) { delete window.localStorage[localStorageKey]; $.each(oldPalette, function(i, c) { addColorToSelectionPalette(c); }); } } catch(e) { } try { selectionPalette = window.localStorage[localStorageKey].split(";"); } catch (e) { } } } function addColorToSelectionPalette(color) { if (showSelectionPalette) { var rgb = tinycolor(color).toRgbString(); if (!paletteLookup[rgb] && $.inArray(rgb, selectionPalette) === -1) { selectionPalette.push(rgb); while(selectionPalette.length > maxSelectionSize) { selectionPalette.shift(); } } if (localStorageKey && window.localStorage) { try { window.localStorage[localStorageKey] = selectionPalette.join(";"); } catch(e) { } } } } function getUniqueSelectionPalette() { var unique = []; if (opts.showPalette) { for (var i = 0; i < selectionPalette.length; i++) { var rgb = tinycolor(selectionPalette[i]).toRgbString(); if (!paletteLookup[rgb]) { unique.push(selectionPalette[i]); } } } return unique.reverse().slice(0, opts.maxSelectionSize); } function drawPalette() { var currentColor = get(); var html = $.map(paletteArray, function (palette, i) { return paletteTemplate(palette, currentColor, "sp-palette-row sp-palette-row-" + i, opts); }); updateSelectionPaletteFromStorage(); if (selectionPalette) { html.push(paletteTemplate(getUniqueSelectionPalette(), currentColor, "sp-palette-row sp-palette-row-selection", opts)); } paletteContainer.html(html.join("")); } function drawInitial() { if (opts.showInitial) { var initial = colorOnShow; var current = get(); initialColorContainer.html(paletteTemplate([initial, current], current, "sp-palette-row-initial", opts)); } } function dragStart() { if (dragHeight <= 0 || dragWidth <= 0 || slideHeight <= 0) { reflow(); } isDragging = true; container.addClass(draggingClass); shiftMovementDirection = null; boundElement.trigger('dragstart.spectrum', [ get() ]); } function dragStop() { isDragging = false; container.removeClass(draggingClass); boundElement.trigger('dragstop.spectrum', [ get() ]); } function setFromTextInput() { var value = textInput.val(); if ((value === null || value === "") && allowEmpty) { set(null); updateOriginalInput(true); } else { var tiny = tinycolor(value); if (tiny.isValid()) { set(tiny); updateOriginalInput(true); } else { textInput.addClass("sp-validation-error"); } } } function toggle() { if (visible) { hide(); } else { show(); } } function show() { var event = $.Event('beforeShow.spectrum'); if (visible) { reflow(); return; } boundElement.trigger(event, [ get() ]); if (callbacks.beforeShow(get()) === false || event.isDefaultPrevented()) { return; } hideAll(); visible = true; $(doc).bind("keydown.spectrum", onkeydown); $(doc).bind("click.spectrum", clickout); $(window).bind("resize.spectrum", resize); replacer.addClass("sp-active"); container.removeClass("sp-hidden"); reflow(); updateUI(); colorOnShow = get(); drawInitial(); callbacks.show(colorOnShow); boundElement.trigger('show.spectrum', [ colorOnShow ]); } function onkeydown(e) { // Close on ESC if (e.keyCode === 27) { hide(); } } function clickout(e) { // Return on right click. if (e.button == 2) { return; } // If a drag event was happening during the mouseup, don't hide // on click. if (isDragging) { return; } if (clickoutFiresChange) { updateOriginalInput(true); } else { revert(); } hide(); } function hide() { // Return if hiding is unnecessary if (!visible || flat) { return; } visible = false; $(doc).unbind("keydown.spectrum", onkeydown); $(doc).unbind("click.spectrum", clickout); $(window).unbind("resize.spectrum", resize); replacer.removeClass("sp-active"); container.addClass("sp-hidden"); callbacks.hide(get()); boundElement.trigger('hide.spectrum', [ get() ]); } function revert() { set(colorOnShow, true); } function set(color, ignoreFormatChange) { if (tinycolor.equals(color, get())) { // Update UI just in case a validation error needs // to be cleared. updateUI(); return; } var newColor, newHsv; if (!color && allowEmpty) { isEmpty = true; } else { isEmpty = false; newColor = tinycolor(color); newHsv = newColor.toHsv(); currentHue = (newHsv.h % 360) / 360; currentSaturation = newHsv.s; currentValue = newHsv.v; currentAlpha = newHsv.a; } updateUI(); if (newColor && newColor.isValid() && !ignoreFormatChange) { currentPreferredFormat = opts.preferredFormat || newColor.getFormat(); } } function get(opts) { opts = opts || { }; if (allowEmpty && isEmpty) { return null; } return tinycolor.fromRatio({ h: currentHue, s: currentSaturation, v: currentValue, a: Math.round(currentAlpha * 100) / 100 }, { format: opts.format || currentPreferredFormat }); } function isValid() { return !textInput.hasClass("sp-validation-error"); } function move() { updateUI(); callbacks.move(get()); boundElement.trigger('move.spectrum', [ get() ]); } function updateUI() { textInput.removeClass("sp-validation-error"); updateHelperLocations(); // Update dragger background color (gradients take care of saturation and value). var flatColor = tinycolor.fromRatio({ h: currentHue, s: 1, v: 1 }); dragger.css("background-color", flatColor.toHexString()); // Get a format that alpha will be included in (hex and names ignore alpha) var format = currentPreferredFormat; if (currentAlpha < 1 && !(currentAlpha === 0 && format === "name")) { if (format === "hex" || format === "hex3" || format === "hex6" || format === "name") { format = "rgb"; } } var realColor = get({ format: format }), displayColor = ''; //reset background info for preview element previewElement.removeClass("sp-clear-display"); previewElement.css('background-color', 'transparent'); if (!realColor && allowEmpty) { // Update the replaced elements background with icon indicating no color selection previewElement.addClass("sp-clear-display"); } else { var realHex = realColor.toHexString(), realRgb = realColor.toRgbString(); // Update the replaced elements background color (with actual selected color) if (rgbaSupport || realColor.alpha === 1) { previewElement.css("background-color", realRgb); } else { previewElement.css("background-color", "transparent"); previewElement.css("filter", realColor.toFilter()); } if (opts.showAlpha) { var rgb = realColor.toRgb(); rgb.a = 0; var realAlpha = tinycolor(rgb).toRgbString(); var gradient = "linear-gradient(left, " + realAlpha + ", " + realHex + ")"; if (IE) { alphaSliderInner.css("filter", tinycolor(realAlpha).toFilter({ gradientType: 1 }, realHex)); } else { alphaSliderInner.css("background", "-webkit-" + gradient); alphaSliderInner.css("background", "-moz-" + gradient); alphaSliderInner.css("background", "-ms-" + gradient); // Use current syntax gradient on unprefixed property. alphaSliderInner.css("background", "linear-gradient(to right, " + realAlpha + ", " + realHex + ")"); } } displayColor = realColor.toString(format); } // Update the text entry input as it changes happen if (opts.showInput) { textInput.val(displayColor); } if (opts.showPalette) { drawPalette(); } drawInitial(); } function updateHelperLocations() { var s = currentSaturation; var v = currentValue; if(allowEmpty && isEmpty) { //if selected color is empty, hide the helpers alphaSlideHelper.hide(); slideHelper.hide(); dragHelper.hide(); } else { //make sure helpers are visible alphaSlideHelper.show(); slideHelper.show(); dragHelper.show(); // Where to show the little circle in that displays your current selected color var dragX = s * dragWidth; var dragY = dragHeight - (v * dragHeight); dragX = Math.max( -dragHelperHeight, Math.min(dragWidth - dragHelperHeight, dragX - dragHelperHeight) ); dragY = Math.max( -dragHelperHeight, Math.min(dragHeight - dragHelperHeight, dragY - dragHelperHeight) ); dragHelper.css({ "top": dragY + "px", "left": dragX + "px" }); var alphaX = currentAlpha * alphaWidth; alphaSlideHelper.css({ "left": (alphaX - (alphaSlideHelperWidth / 2)) + "px" }); // Where to show the bar that displays your current selected hue var slideY = (currentHue) * slideHeight; slideHelper.css({ "top": (slideY - slideHelperHeight) + "px" }); } } function updateOriginalInput(fireCallback) { var color = get(), displayColor = '', hasChanged = !tinycolor.equals(color, colorOnShow); if (color) { displayColor = color.toString(currentPreferredFormat); // Update the selection palette with the current color addColorToSelectionPalette(color); } if (isInput) { boundElement.val(displayColor); } if (fireCallback && hasChanged) { callbacks.change(color); boundElement.trigger('change', [ color ]); } } function reflow() { if (!visible) { return; // Calculations would be useless and wouldn't be reliable anyways } dragWidth = dragger.width(); dragHeight = dragger.height(); dragHelperHeight = dragHelper.height(); slideWidth = slider.width(); slideHeight = slider.height(); slideHelperHeight = slideHelper.height(); alphaWidth = alphaSlider.width(); alphaSlideHelperWidth = alphaSlideHelper.width(); if (!flat) { container.css("position", "absolute"); if (opts.offset) { container.offset(opts.offset); } else { container.offset(getOffset(container, offsetElement)); } } updateHelperLocations(); if (opts.showPalette) { drawPalette(); } boundElement.trigger('reflow.spectrum'); } function destroy() { boundElement.show(); offsetElement.unbind("click.spectrum touchstart.spectrum"); container.remove(); replacer.remove(); spectrums[spect.id] = null; } function option(optionName, optionValue) { if (optionName === undefined) { return $.extend({}, opts); } if (optionValue === undefined) { return opts[optionName]; } opts[optionName] = optionValue; if (optionName === "preferredFormat") { currentPreferredFormat = opts.preferredFormat; } applyOptions(); } function enable() { disabled = false; boundElement.attr("disabled", false); offsetElement.removeClass("sp-disabled"); } function disable() { hide(); disabled = true; boundElement.attr("disabled", true); offsetElement.addClass("sp-disabled"); } function setOffset(coord) { opts.offset = coord; reflow(); } initialize(); var spect = { show: show, hide: hide, toggle: toggle, reflow: reflow, option: option, enable: enable, disable: disable, offset: setOffset, set: function (c) { set(c); updateOriginalInput(); }, get: get, destroy: destroy, container: container }; spect.id = spectrums.push(spect) - 1; return spect; } /** * checkOffset - get the offset below/above and left/right element depending on screen position * Thanks https://github.com/jquery/jquery-ui/blob/master/ui/jquery.ui.datepicker.js */ function getOffset(picker, input) { var extraY = 0; var dpWidth = picker.outerWidth(); var dpHeight = picker.outerHeight(); var inputHeight = input.outerHeight(); var doc = picker[0].ownerDocument; var docElem = doc.documentElement; var viewWidth = docElem.clientWidth + $(doc).scrollLeft(); var viewHeight = docElem.clientHeight + $(doc).scrollTop(); var offset = input.offset(); offset.top += inputHeight; offset.left -= Math.min(offset.left, (offset.left + dpWidth > viewWidth && viewWidth > dpWidth) ? Math.abs(offset.left + dpWidth - viewWidth) : 0); offset.top -= Math.min(offset.top, ((offset.top + dpHeight > viewHeight && viewHeight > dpHeight) ? Math.abs(dpHeight + inputHeight - extraY) : extraY)); return offset; } /** * noop - do nothing */ function noop() { } /** * stopPropagation - makes the code only doing this a little easier to read in line */ function stopPropagation(e) { e.stopPropagation(); } /** * Create a function bound to a given object * Thanks to underscore.js */ function bind(func, obj) { var slice = Array.prototype.slice; var args = slice.call(arguments, 2); return function () { return func.apply(obj, args.concat(slice.call(arguments))); }; } /** * Lightweight drag helper. Handles containment within the element, so that * when dragging, the x is within [0,element.width] and y is within [0,element.height] */ function draggable(element, onmove, onstart, onstop) { onmove = onmove || function () { }; onstart = onstart || function () { }; onstop = onstop || function () { }; var doc = document; var dragging = false; var offset = {}; var maxHeight = 0; var maxWidth = 0; var hasTouch = ('ontouchstart' in window); var duringDragEvents = {}; duringDragEvents["selectstart"] = prevent; duringDragEvents["dragstart"] = prevent; duringDragEvents["touchmove mousemove"] = move; duringDragEvents["touchend mouseup"] = stop; function prevent(e) { if (e.stopPropagation) { e.stopPropagation(); } if (e.preventDefault) { e.preventDefault(); } e.returnValue = false; } function move(e) { if (dragging) { // Mouseup happened outside of window if (IE && doc.documentMode < 9 && !e.button) { return stop(); } var t0 = e.originalEvent && e.originalEvent.touches && e.originalEvent.touches[0]; var pageX = t0 && t0.pageX || e.pageX; var pageY = t0 && t0.pageY || e.pageY; var dragX = Math.max(0, Math.min(pageX - offset.left, maxWidth)); var dragY = Math.max(0, Math.min(pageY - offset.top, maxHeight)); if (hasTouch) { // Stop scrolling in iOS prevent(e); } onmove.apply(element, [dragX, dragY, e]); } } function start(e) { var rightclick = (e.which) ? (e.which == 3) : (e.button == 2); if (!rightclick && !dragging) { if (onstart.apply(element, arguments) !== false) { dragging = true; maxHeight = $(element).height(); maxWidth = $(element).width(); offset = $(element).offset(); $(doc).bind(duringDragEvents); $(doc.body).addClass("sp-dragging"); move(e); prevent(e); } } } function stop() { if (dragging) { $(doc).unbind(duringDragEvents); $(doc.body).removeClass("sp-dragging"); // Wait a tick before notifying observers to allow the click event // to fire in Chrome. setTimeout(function() { onstop.apply(element, arguments); }, 0); } dragging = false; } $(element).bind("touchstart mousedown", start); } function throttle(func, wait, debounce) { var timeout; return function () { var context = this, args = arguments; var throttler = function () { timeout = null; func.apply(context, args); }; if (debounce) clearTimeout(timeout); if (debounce || !timeout) timeout = setTimeout(throttler, wait); }; } function inputTypeColorSupport() { return $.fn.spectrum.inputTypeColorSupport(); } /** * Define a jQuery plugin */ var dataID = "spectrum.id"; $.fn.spectrum = function (opts, extra) { if (typeof opts == "string") { var returnValue = this; var args = Array.prototype.slice.call( arguments, 1 ); this.each(function () { var spect = spectrums[$(this).data(dataID)]; if (spect) { var method = spect[opts]; if (!method) { throw new Error( "Spectrum: no such method: '" + opts + "'" ); } if (opts == "get") { returnValue = spect.get(); } else if (opts == "container") { returnValue = spect.container; } else if (opts == "option") { returnValue = spect.option.apply(spect, args); } else if (opts == "destroy") { spect.destroy(); $(this).removeData(dataID); } else { method.apply(spect, args); } } }); return returnValue; } // Initializing a new instance of spectrum return this.spectrum("destroy").each(function () { var options = $.extend({}, opts, $(this).data()); var spect = spectrum(this, options); $(this).data(dataID, spect.id); }); }; $.fn.spectrum.load = true; $.fn.spectrum.loadOpts = {}; $.fn.spectrum.draggable = draggable; $.fn.spectrum.defaults = defaultOpts; $.fn.spectrum.inputTypeColorSupport = function inputTypeColorSupport() { if (typeof inputTypeColorSupport._cachedResult === "undefined") { var colorInput = $("")[0]; // if color element is supported, value will default to not null inputTypeColorSupport._cachedResult = colorInput.type === "color" && colorInput.value !== ""; } return inputTypeColorSupport._cachedResult; }; $.spectrum = { }; $.spectrum.localization = { }; $.spectrum.palettes = { }; $.fn.spectrum.processNativeColorInputs = function () { var colorInputs = $("input[type=color]"); if (colorInputs.length && !inputTypeColorSupport()) { colorInputs.spectrum({ preferredFormat: "hex6" }); } }; // TinyColor v1.1.2 // https://github.com/bgrins/TinyColor // Brian Grinstead, MIT License (function() { var trimLeft = /^[\s,#]+/, trimRight = /\s+$/, tinyCounter = 0, math = Math, mathRound = math.round, mathMin = math.min, mathMax = math.max, mathRandom = math.random; var tinycolor = function(color, opts) { color = (color) ? color : ''; opts = opts || { }; // If input is already a tinycolor, return itself if (color instanceof tinycolor) { return color; } // If we are called as a function, call using new instead if (!(this instanceof tinycolor)) { return new tinycolor(color, opts); } var rgb = inputToRGB(color); this._originalInput = color, this._r = rgb.r, this._g = rgb.g, this._b = rgb.b, this._a = rgb.a, this._roundA = mathRound(100*this._a) / 100, this._format = opts.format || rgb.format; this._gradientType = opts.gradientType; // Don't let the range of [0,255] come back in [0,1]. // Potentially lose a little bit of precision here, but will fix issues where // .5 gets interpreted as half of the total, instead of half of 1 // If it was supposed to be 128, this was already taken care of by `inputToRgb` if (this._r < 1) { this._r = mathRound(this._r); } if (this._g < 1) { this._g = mathRound(this._g); } if (this._b < 1) { this._b = mathRound(this._b); } this._ok = rgb.ok; this._tc_id = tinyCounter++; }; tinycolor.prototype = { isDark: function() { return this.getBrightness() < 128; }, isLight: function() { return !this.isDark(); }, isValid: function() { return this._ok; }, getOriginalInput: function() { return this._originalInput; }, getFormat: function() { return this._format; }, getAlpha: function() { return this._a; }, getBrightness: function() { var rgb = this.toRgb(); return (rgb.r * 299 + rgb.g * 587 + rgb.b * 114) / 1000; }, setAlpha: function(value) { this._a = boundAlpha(value); this._roundA = mathRound(100*this._a) / 100; return this; }, toHsv: function() { var hsv = rgbToHsv(this._r, this._g, this._b); return { h: hsv.h * 360, s: hsv.s, v: hsv.v, a: this._a }; }, toHsvString: function() { var hsv = rgbToHsv(this._r, this._g, this._b); var h = mathRound(hsv.h * 360), s = mathRound(hsv.s * 100), v = mathRound(hsv.v * 100); return (this._a == 1) ? "hsv(" + h + ", " + s + "%, " + v + "%)" : "hsva(" + h + ", " + s + "%, " + v + "%, "+ this._roundA + ")"; }, toHsl: function() { var hsl = rgbToHsl(this._r, this._g, this._b); return { h: hsl.h * 360, s: hsl.s, l: hsl.l, a: this._a }; }, toHslString: function() { var hsl = rgbToHsl(this._r, this._g, this._b); var h = mathRound(hsl.h * 360), s = mathRound(hsl.s * 100), l = mathRound(hsl.l * 100); return (this._a == 1) ? "hsl(" + h + ", " + s + "%, " + l + "%)" : "hsla(" + h + ", " + s + "%, " + l + "%, "+ this._roundA + ")"; }, toHex: function(allow3Char) { return rgbToHex(this._r, this._g, this._b, allow3Char); }, toHexString: function(allow3Char) { return '#' + this.toHex(allow3Char); }, toHex8: function() { return rgbaToHex(this._r, this._g, this._b, this._a); }, toHex8String: function() { return '#' + this.toHex8(); }, toRgb: function() { return { r: mathRound(this._r), g: mathRound(this._g), b: mathRound(this._b), a: this._a }; }, toRgbString: function() { return (this._a == 1) ? "rgb(" + mathRound(this._r) + ", " + mathRound(this._g) + ", " + mathRound(this._b) + ")" : "rgba(" + mathRound(this._r) + ", " + mathRound(this._g) + ", " + mathRound(this._b) + ", " + this._roundA + ")"; }, toPercentageRgb: function() { 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 }; }, toPercentageRgbString: function() { return (this._a == 1) ? "rgb(" + mathRound(bound01(this._r, 255) * 100) + "%, " + mathRound(bound01(this._g, 255) * 100) + "%, " + mathRound(bound01(this._b, 255) * 100) + "%)" : "rgba(" + mathRound(bound01(this._r, 255) * 100) + "%, " + mathRound(bound01(this._g, 255) * 100) + "%, " + mathRound(bound01(this._b, 255) * 100) + "%, " + this._roundA + ")"; }, toName: function() { if (this._a === 0) { return "transparent"; } if (this._a < 1) { return false; } return hexNames[rgbToHex(this._r, this._g, this._b, true)] || false; }, toFilter: function(secondColor) { var hex8String = '#' + rgbaToHex(this._r, this._g, this._b, this._a); var secondHex8String = hex8String; var gradientType = this._gradientType ? "GradientType = 1, " : ""; if (secondColor) { var s = tinycolor(secondColor); secondHex8String = s.toHex8String(); } return "progid:DXImageTransform.Microsoft.gradient("+gradientType+"startColorstr="+hex8String+",endColorstr="+secondHex8String+")"; }, toString: function(format) { var formatSet = !!format; format = format || this._format; var formattedString = false; var hasAlpha = this._a < 1 && this._a >= 0; var needsAlphaFormat = !formatSet && hasAlpha && (format === "hex" || format === "hex6" || format === "hex3" || format === "name"); if (needsAlphaFormat) { // Special case for "transparent", all other non-alpha formats // will return rgba when there is transparency. if (format === "name" && this._a === 0) { return this.toName(); } return this.toRgbString(); } if (format === "rgb") { formattedString = this.toRgbString(); } if (format === "prgb") { formattedString = this.toPercentageRgbString(); } if (format === "hex" || format === "hex6") { formattedString = this.toHexString(); } if (format === "hex3") { formattedString = this.toHexString(true); } if (format === "hex8") { formattedString = this.toHex8String(); } if (format === "name") { formattedString = this.toName(); } if (format === "hsl") { formattedString = this.toHslString(); } if (format === "hsv") { formattedString = this.toHsvString(); } return formattedString || this.toHexString(); }, _applyModification: function(fn, args) { var color = fn.apply(null, [this].concat([].slice.call(args))); this._r = color._r; this._g = color._g; this._b = color._b; this.setAlpha(color._a); return this; }, lighten: function() { return this._applyModification(lighten, arguments); }, brighten: function() { return this._applyModification(brighten, arguments); }, darken: function() { return this._applyModification(darken, arguments); }, desaturate: function() { return this._applyModification(desaturate, arguments); }, saturate: function() { return this._applyModification(saturate, arguments); }, greyscale: function() { return this._applyModification(greyscale, arguments); }, spin: function() { return this._applyModification(spin, arguments); }, _applyCombination: function(fn, args) { return fn.apply(null, [this].concat([].slice.call(args))); }, analogous: function() { return this._applyCombination(analogous, arguments); }, complement: function() { return this._applyCombination(complement, arguments); }, monochromatic: function() { return this._applyCombination(monochromatic, arguments); }, splitcomplement: function() { return this._applyCombination(splitcomplement, arguments); }, triad: function() { return this._applyCombination(triad, arguments); }, tetrad: function() { return this._applyCombination(tetrad, arguments); } }; // If input is an object, force 1 into "1.0" to handle ratios properly // String input requires "1.0" as input, so 1 will be treated as 1 tinycolor.fromRatio = function(color, opts) { if (typeof color == "object") { var newColor = {}; for (var i in color) { if (color.hasOwnProperty(i)) { if (i === "a") { newColor[i] = color[i]; } else { newColor[i] = convertToPercentage(color[i]); } } } color = newColor; } return tinycolor(color, opts); }; // Given a string or object, convert that input to RGB // Possible string inputs: // // "red" // "#f00" or "f00" // "#ff0000" or "ff0000" // "#ff000000" or "ff000000" // "rgb 255 0 0" or "rgb (255, 0, 0)" // "rgb 1.0 0 0" or "rgb (1, 0, 0)" // "rgba (255, 0, 0, 1)" or "rgba 255, 0, 0, 1" // "rgba (1.0, 0, 0, 1)" or "rgba 1.0, 0, 0, 1" // "hsl(0, 100%, 50%)" or "hsl 0 100% 50%" // "hsla(0, 100%, 50%, 1)" or "hsla 0 100% 50%, 1" // "hsv(0, 100%, 100%)" or "hsv 0 100% 100%" // function inputToRGB(color) { var rgb = { r: 0, g: 0, b: 0 }; var a = 1; var ok = false; var format = false; if (typeof color == "string") { color = stringInputToObject(color); } if (typeof color == "object") { if (color.hasOwnProperty("r") && color.hasOwnProperty("g") && color.hasOwnProperty("b")) { rgb = rgbToRgb(color.r, color.g, color.b); ok = true; format = String(color.r).substr(-1) === "%" ? "prgb" : "rgb"; } else if (color.hasOwnProperty("h") && color.hasOwnProperty("s") && color.hasOwnProperty("v")) { color.s = convertToPercentage(color.s); color.v = convertToPercentage(color.v); rgb = hsvToRgb(color.h, color.s, color.v); ok = true; format = "hsv"; } else if (color.hasOwnProperty("h") && color.hasOwnProperty("s") && color.hasOwnProperty("l")) { color.s = convertToPercentage(color.s); color.l = convertToPercentage(color.l); rgb = hslToRgb(color.h, color.s, color.l); ok = true; format = "hsl"; } if (color.hasOwnProperty("a")) { a = color.a; } } a = boundAlpha(a); return { ok: ok, format: color.format || format, r: mathMin(255, mathMax(rgb.r, 0)), g: mathMin(255, mathMax(rgb.g, 0)), b: mathMin(255, mathMax(rgb.b, 0)), a: a }; } // Conversion Functions // -------------------- // `rgbToHsl`, `rgbToHsv`, `hslToRgb`, `hsvToRgb` modified from: // // `rgbToRgb` // Handle bounds / percentage checking to conform to CSS color spec // // *Assumes:* r, g, b in [0, 255] or [0, 1] // *Returns:* { r, g, b } in [0, 255] function rgbToRgb(r, g, b){ return { r: bound01(r, 255) * 255, g: bound01(g, 255) * 255, b: bound01(b, 255) * 255 }; } // `rgbToHsl` // Converts an RGB color value to HSL. // *Assumes:* r, g, and b are contained in [0, 255] or [0, 1] // *Returns:* { h, s, l } in [0,1] function rgbToHsl(r, g, b) { r = bound01(r, 255); g = bound01(g, 255); b = bound01(b, 255); var max = mathMax(r, g, b), min = mathMin(r, g, b); var h, s, l = (max + min) / 2; if(max == min) { h = s = 0; // achromatic } else { var d = max - min; s = l > 0.5 ? d / (2 - max - min) : d / (max + min); switch(max) { case r: h = (g - b) / d + (g < b ? 6 : 0); break; case g: h = (b - r) / d + 2; break; case b: h = (r - g) / d + 4; break; } h /= 6; } return { h: h, s: s, l: l }; } // `hslToRgb` // Converts an HSL color value to RGB. // *Assumes:* h is contained in [0, 1] or [0, 360] and s and l are contained [0, 1] or [0, 100] // *Returns:* { r, g, b } in the set [0, 255] function hslToRgb(h, s, l) { var r, g, b; h = bound01(h, 360); s = bound01(s, 100); l = bound01(l, 100); function hue2rgb(p, q, t) { if(t < 0) t += 1; if(t > 1) t -= 1; if(t < 1/6) return p + (q - p) * 6 * t; if(t < 1/2) return q; if(t < 2/3) return p + (q - p) * (2/3 - t) * 6; return p; } if(s === 0) { r = g = b = l; // achromatic } else { var q = l < 0.5 ? l * (1 + s) : l + s - l * s; var p = 2 * l - q; r = hue2rgb(p, q, h + 1/3); g = hue2rgb(p, q, h); b = hue2rgb(p, q, h - 1/3); } return { r: r * 255, g: g * 255, b: b * 255 }; } // `rgbToHsv` // Converts an RGB color value to HSV // *Assumes:* r, g, and b are contained in the set [0, 255] or [0, 1] // *Returns:* { h, s, v } in [0,1] function rgbToHsv(r, g, b) { r = bound01(r, 255); g = bound01(g, 255); b = bound01(b, 255); var max = mathMax(r, g, b), min = mathMin(r, g, b); var h, s, v = max; var d = max - min; s = max === 0 ? 0 : d / max; if(max == min) { h = 0; // achromatic } else { switch(max) { case r: h = (g - b) / d + (g < b ? 6 : 0); break; case g: h = (b - r) / d + 2; break; case b: h = (r - g) / d + 4; break; } h /= 6; } return { h: h, s: s, v: v }; } // `hsvToRgb` // Converts an HSV color value to RGB. // *Assumes:* h is contained in [0, 1] or [0, 360] and s and v are contained in [0, 1] or [0, 100] // *Returns:* { r, g, b } in the set [0, 255] function hsvToRgb(h, s, v) { h = bound01(h, 360) * 6; s = bound01(s, 100); v = bound01(v, 100); var i = math.floor(h), f = h - i, p = v * (1 - s), q = v * (1 - f * s), t = v * (1 - (1 - f) * s), mod = i % 6, r = [v, q, p, p, t, v][mod], g = [t, v, v, q, p, p][mod], b = [p, p, t, v, v, q][mod]; return { r: r * 255, g: g * 255, b: b * 255 }; } // `rgbToHex` // Converts an RGB color to hex // Assumes r, g, and b are contained in the set [0, 255] // Returns a 3 or 6 character hex function rgbToHex(r, g, b, allow3Char) { var hex = [ pad2(mathRound(r).toString(16)), pad2(mathRound(g).toString(16)), pad2(mathRound(b).toString(16)) ]; // Return a 3 character hex if possible 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)) { return hex[0].charAt(0) + hex[1].charAt(0) + hex[2].charAt(0); } return hex.join(""); } // `rgbaToHex` // Converts an RGBA color plus alpha transparency to hex // Assumes r, g, b and a are contained in the set [0, 255] // Returns an 8 character hex function rgbaToHex(r, g, b, a) { var hex = [ pad2(convertDecimalToHex(a)), pad2(mathRound(r).toString(16)), pad2(mathRound(g).toString(16)), pad2(mathRound(b).toString(16)) ]; return hex.join(""); } // `equals` // Can be called with any tinycolor input tinycolor.equals = function (color1, color2) { if (!color1 || !color2) { return false; } return tinycolor(color1).toRgbString() == tinycolor(color2).toRgbString(); }; tinycolor.random = function() { return tinycolor.fromRatio({ r: mathRandom(), g: mathRandom(), b: mathRandom() }); }; // Modification Functions // ---------------------- // Thanks to less.js for some of the basics here // function desaturate(color, amount) { amount = (amount === 0) ? 0 : (amount || 10); var hsl = tinycolor(color).toHsl(); hsl.s -= amount / 100; hsl.s = clamp01(hsl.s); return tinycolor(hsl); } function saturate(color, amount) { amount = (amount === 0) ? 0 : (amount || 10); var hsl = tinycolor(color).toHsl(); hsl.s += amount / 100; hsl.s = clamp01(hsl.s); return tinycolor(hsl); } function greyscale(color) { return tinycolor(color).desaturate(100); } function lighten (color, amount) { amount = (amount === 0) ? 0 : (amount || 10); var hsl = tinycolor(color).toHsl(); hsl.l += amount / 100; hsl.l = clamp01(hsl.l); return tinycolor(hsl); } function brighten(color, amount) { amount = (amount === 0) ? 0 : (amount || 10); var rgb = tinycolor(color).toRgb(); rgb.r = mathMax(0, mathMin(255, rgb.r - mathRound(255 * - (amount / 100)))); rgb.g = mathMax(0, mathMin(255, rgb.g - mathRound(255 * - (amount / 100)))); rgb.b = mathMax(0, mathMin(255, rgb.b - mathRound(255 * - (amount / 100)))); return tinycolor(rgb); } function darken (color, amount) { amount = (amount === 0) ? 0 : (amount || 10); var hsl = tinycolor(color).toHsl(); hsl.l -= amount / 100; hsl.l = clamp01(hsl.l); return tinycolor(hsl); } // Spin takes a positive or negative amount within [-360, 360] indicating the change of hue. // Values outside of this range will be wrapped into this range. function spin(color, amount) { var hsl = tinycolor(color).toHsl(); var hue = (mathRound(hsl.h) + amount) % 360; hsl.h = hue < 0 ? 360 + hue : hue; return tinycolor(hsl); } // Combination Functions // --------------------- // Thanks to jQuery xColor for some of the ideas behind these // function complement(color) { var hsl = tinycolor(color).toHsl(); hsl.h = (hsl.h + 180) % 360; return tinycolor(hsl); } function triad(color) { var hsl = tinycolor(color).toHsl(); var h = hsl.h; return [ tinycolor(color), tinycolor({ h: (h + 120) % 360, s: hsl.s, l: hsl.l }), tinycolor({ h: (h + 240) % 360, s: hsl.s, l: hsl.l }) ]; } function tetrad(color) { var hsl = tinycolor(color).toHsl(); var h = hsl.h; return [ tinycolor(color), tinycolor({ h: (h + 90) % 360, s: hsl.s, l: hsl.l }), tinycolor({ h: (h + 180) % 360, s: hsl.s, l: hsl.l }), tinycolor({ h: (h + 270) % 360, s: hsl.s, l: hsl.l }) ]; } function splitcomplement(color) { var hsl = tinycolor(color).toHsl(); var h = hsl.h; return [ tinycolor(color), tinycolor({ h: (h + 72) % 360, s: hsl.s, l: hsl.l}), tinycolor({ h: (h + 216) % 360, s: hsl.s, l: hsl.l}) ]; } function analogous(color, results, slices) { results = results || 6; slices = slices || 30; var hsl = tinycolor(color).toHsl(); var part = 360 / slices; var ret = [tinycolor(color)]; for (hsl.h = ((hsl.h - (part * results >> 1)) + 720) % 360; --results; ) { hsl.h = (hsl.h + part) % 360; ret.push(tinycolor(hsl)); } return ret; } function monochromatic(color, results) { results = results || 6; var hsv = tinycolor(color).toHsv(); var h = hsv.h, s = hsv.s, v = hsv.v; var ret = []; var modification = 1 / results; while (results--) { ret.push(tinycolor({ h: h, s: s, v: v})); v = (v + modification) % 1; } return ret; } // Utility Functions // --------------------- tinycolor.mix = function(color1, color2, amount) { amount = (amount === 0) ? 0 : (amount || 50); var rgb1 = tinycolor(color1).toRgb(); var rgb2 = tinycolor(color2).toRgb(); var p = amount / 100; var w = p * 2 - 1; var a = rgb2.a - rgb1.a; var w1; if (w * a == -1) { w1 = w; } else { w1 = (w + a) / (1 + w * a); } w1 = (w1 + 1) / 2; var w2 = 1 - w1; var rgba = { r: rgb2.r * w1 + rgb1.r * w2, g: rgb2.g * w1 + rgb1.g * w2, b: rgb2.b * w1 + rgb1.b * w2, a: rgb2.a * p + rgb1.a * (1 - p) }; return tinycolor(rgba); }; // Readability Functions // --------------------- // // `readability` // Analyze the 2 colors and returns an object with the following properties: // `brightness`: difference in brightness between the two colors // `color`: difference in color/hue between the two colors tinycolor.readability = function(color1, color2) { var c1 = tinycolor(color1); var c2 = tinycolor(color2); var rgb1 = c1.toRgb(); var rgb2 = c2.toRgb(); var brightnessA = c1.getBrightness(); var brightnessB = c2.getBrightness(); var colorDiff = ( Math.max(rgb1.r, rgb2.r) - Math.min(rgb1.r, rgb2.r) + Math.max(rgb1.g, rgb2.g) - Math.min(rgb1.g, rgb2.g) + Math.max(rgb1.b, rgb2.b) - Math.min(rgb1.b, rgb2.b) ); return { brightness: Math.abs(brightnessA - brightnessB), color: colorDiff }; }; // `readable` // http://www.w3.org/TR/AERT#color-contrast // Ensure that foreground and background color combinations provide sufficient contrast. // *Example* // tinycolor.isReadable("#000", "#111") => false tinycolor.isReadable = function(color1, color2) { var readability = tinycolor.readability(color1, color2); return readability.brightness > 125 && readability.color > 500; }; // `mostReadable` // Given a base color and a list of possible foreground or background // colors for that base, returns the most readable color. // *Example* // tinycolor.mostReadable("#123", ["#fff", "#000"]) => "#000" tinycolor.mostReadable = function(baseColor, colorList) { var bestColor = null; var bestScore = 0; var bestIsReadable = false; for (var i=0; i < colorList.length; i++) { // We normalize both around the "acceptable" breaking point, // but rank brightness constrast higher than hue. var readability = tinycolor.readability(baseColor, colorList[i]); var readable = readability.brightness > 125 && readability.color > 500; var score = 3 * (readability.brightness / 125) + (readability.color / 500); if ((readable && ! bestIsReadable) || (readable && bestIsReadable && score > bestScore) || ((! readable) && (! bestIsReadable) && score > bestScore)) { bestIsReadable = readable; bestScore = score; bestColor = tinycolor(colorList[i]); } } return bestColor; }; // Big List of Colors // ------------------ // var names = tinycolor.names = { aliceblue: "f0f8ff", antiquewhite: "faebd7", aqua: "0ff", aquamarine: "7fffd4", azure: "f0ffff", beige: "f5f5dc", bisque: "ffe4c4", black: "000", blanchedalmond: "ffebcd", blue: "00f", blueviolet: "8a2be2", brown: "a52a2a", burlywood: "deb887", burntsienna: "ea7e5d", cadetblue: "5f9ea0", chartreuse: "7fff00", chocolate: "d2691e", coral: "ff7f50", cornflowerblue: "6495ed", cornsilk: "fff8dc", crimson: "dc143c", cyan: "0ff", darkblue: "00008b", darkcyan: "008b8b", darkgoldenrod: "b8860b", darkgray: "a9a9a9", darkgreen: "006400", darkgrey: "a9a9a9", darkkhaki: "bdb76b", darkmagenta: "8b008b", darkolivegreen: "556b2f", darkorange: "ff8c00", darkorchid: "9932cc", darkred: "8b0000", darksalmon: "e9967a", darkseagreen: "8fbc8f", darkslateblue: "483d8b", darkslategray: "2f4f4f", darkslategrey: "2f4f4f", darkturquoise: "00ced1", darkviolet: "9400d3", deeppink: "ff1493", deepskyblue: "00bfff", dimgray: "696969", dimgrey: "696969", dodgerblue: "1e90ff", firebrick: "b22222", floralwhite: "fffaf0", forestgreen: "228b22", fuchsia: "f0f", gainsboro: "dcdcdc", ghostwhite: "f8f8ff", gold: "ffd700", goldenrod: "daa520", gray: "808080", green: "008000", greenyellow: "adff2f", grey: "808080", honeydew: "f0fff0", hotpink: "ff69b4", indianred: "cd5c5c", indigo: "4b0082", ivory: "fffff0", khaki: "f0e68c", lavender: "e6e6fa", lavenderblush: "fff0f5", lawngreen: "7cfc00", lemonchiffon: "fffacd", lightblue: "add8e6", lightcoral: "f08080", lightcyan: "e0ffff", lightgoldenrodyellow: "fafad2", lightgray: "d3d3d3", lightgreen: "90ee90", lightgrey: "d3d3d3", lightpink: "ffb6c1", lightsalmon: "ffa07a", lightseagreen: "20b2aa", lightskyblue: "87cefa", lightslategray: "789", lightslategrey: "789", lightsteelblue: "b0c4de", lightyellow: "ffffe0", lime: "0f0", limegreen: "32cd32", linen: "faf0e6", magenta: "f0f", maroon: "800000", mediumaquamarine: "66cdaa", mediumblue: "0000cd", mediumorchid: "ba55d3", mediumpurple: "9370db", mediumseagreen: "3cb371", mediumslateblue: "7b68ee", mediumspringgreen: "00fa9a", mediumturquoise: "48d1cc", mediumvioletred: "c71585", midnightblue: "191970", mintcream: "f5fffa", mistyrose: "ffe4e1", moccasin: "ffe4b5", navajowhite: "ffdead", navy: "000080", oldlace: "fdf5e6", olive: "808000", olivedrab: "6b8e23", orange: "ffa500", orangered: "ff4500", orchid: "da70d6", palegoldenrod: "eee8aa", palegreen: "98fb98", paleturquoise: "afeeee", palevioletred: "db7093", papayawhip: "ffefd5", peachpuff: "ffdab9", peru: "cd853f", pink: "ffc0cb", plum: "dda0dd", powderblue: "b0e0e6", purple: "800080", rebeccapurple: "663399", red: "f00", rosybrown: "bc8f8f", royalblue: "4169e1", saddlebrown: "8b4513", salmon: "fa8072", sandybrown: "f4a460", seagreen: "2e8b57", seashell: "fff5ee", sienna: "a0522d", silver: "c0c0c0", skyblue: "87ceeb", slateblue: "6a5acd", slategray: "708090", slategrey: "708090", snow: "fffafa", springgreen: "00ff7f", steelblue: "4682b4", tan: "d2b48c", teal: "008080", thistle: "d8bfd8", tomato: "ff6347", turquoise: "40e0d0", violet: "ee82ee", wheat: "f5deb3", white: "fff", whitesmoke: "f5f5f5", yellow: "ff0", yellowgreen: "9acd32" }; // Make it easy to access colors via `hexNames[hex]` var hexNames = tinycolor.hexNames = flip(names); // Utilities // --------- // `{ 'name1': 'val1' }` becomes `{ 'val1': 'name1' }` function flip(o) { var flipped = { }; for (var i in o) { if (o.hasOwnProperty(i)) { flipped[o[i]] = i; } } return flipped; } // Return a valid alpha value [0,1] with all invalid values being set to 1 function boundAlpha(a) { a = parseFloat(a); if (isNaN(a) || a < 0 || a > 1) { a = 1; } return a; } // Take input from [0, n] and return it as [0, 1] function bound01(n, max) { if (isOnePointZero(n)) { n = "100%"; } var processPercent = isPercentage(n); n = mathMin(max, mathMax(0, parseFloat(n))); // Automatically convert percentage into number if (processPercent) { n = parseInt(n * max, 10) / 100; } // Handle floating point rounding errors if ((math.abs(n - max) < 0.000001)) { return 1; } // Convert into [0, 1] range if it isn't already return (n % max) / parseFloat(max); } // Force a number between 0 and 1 function clamp01(val) { return mathMin(1, mathMax(0, val)); } // Parse a base-16 hex value into a base-10 integer function parseIntFromHex(val) { return parseInt(val, 16); } // Need to handle 1.0 as 100%, since once it is a number, there is no difference between it and 1 // function isOnePointZero(n) { return typeof n == "string" && n.indexOf('.') != -1 && parseFloat(n) === 1; } // Check to see if string passed in is a percentage function isPercentage(n) { return typeof n === "string" && n.indexOf('%') != -1; } // Force a hex value to have 2 characters function pad2(c) { return c.length == 1 ? '0' + c : '' + c; } // Replace a decimal with it's percentage value function convertToPercentage(n) { if (n <= 1) { n = (n * 100) + "%"; } return n; } // Converts a decimal to a hex value function convertDecimalToHex(d) { return Math.round(parseFloat(d) * 255).toString(16); } // Converts a hex value to a decimal function convertHexToDecimal(h) { return (parseIntFromHex(h) / 255); } var matchers = (function() { // var CSS_INTEGER = "[-\\+]?\\d+%?"; // var CSS_NUMBER = "[-\\+]?\\d*\\.\\d+%?"; // Allow positive/negative integer/number. Don't capture the either/or, just the entire outcome. var CSS_UNIT = "(?:" + CSS_NUMBER + ")|(?:" + CSS_INTEGER + ")"; // Actual matching. // Parentheses and commas are optional, but not required. // Whitespace can take the place of commas or opening paren var PERMISSIVE_MATCH3 = "[\\s|\\(]+(" + CSS_UNIT + ")[,|\\s]+(" + CSS_UNIT + ")[,|\\s]+(" + CSS_UNIT + ")\\s*\\)?"; var PERMISSIVE_MATCH4 = "[\\s|\\(]+(" + CSS_UNIT + ")[,|\\s]+(" + CSS_UNIT + ")[,|\\s]+(" + CSS_UNIT + ")[,|\\s]+(" + CSS_UNIT + ")\\s*\\)?"; return { rgb: new RegExp("rgb" + PERMISSIVE_MATCH3), rgba: new RegExp("rgba" + PERMISSIVE_MATCH4), hsl: new RegExp("hsl" + PERMISSIVE_MATCH3), hsla: new RegExp("hsla" + PERMISSIVE_MATCH4), hsv: new RegExp("hsv" + PERMISSIVE_MATCH3), hsva: new RegExp("hsva" + PERMISSIVE_MATCH4), hex3: /^([0-9a-fA-F]{1})([0-9a-fA-F]{1})([0-9a-fA-F]{1})$/, hex6: /^([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/, hex8: /^([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/ }; })(); // `stringInputToObject` // Permissive string parsing. Take in a number of formats, and output an object // based on detected format. Returns `{ r, g, b }` or `{ h, s, l }` or `{ h, s, v}` function stringInputToObject(color) { color = color.replace(trimLeft,'').replace(trimRight, '').toLowerCase(); var named = false; if (names[color]) { color = names[color]; named = true; } else if (color == 'transparent') { return { r: 0, g: 0, b: 0, a: 0, format: "name" }; } // Try to match string input using regular expressions. // Keep most of the number bounding out of this function - don't worry about [0,1] or [0,100] or [0,360] // Just return an object and let the conversion functions handle that. // This way the result will be the same whether the tinycolor is initialized with string or object. var match; if ((match = matchers.rgb.exec(color))) { return { r: match[1], g: match[2], b: match[3] }; } if ((match = matchers.rgba.exec(color))) { return { r: match[1], g: match[2], b: match[3], a: match[4] }; } if ((match = matchers.hsl.exec(color))) { return { h: match[1], s: match[2], l: match[3] }; } if ((match = matchers.hsla.exec(color))) { return { h: match[1], s: match[2], l: match[3], a: match[4] }; } if ((match = matchers.hsv.exec(color))) { return { h: match[1], s: match[2], v: match[3] }; } if ((match = matchers.hsva.exec(color))) { return { h: match[1], s: match[2], v: match[3], a: match[4] }; } if ((match = matchers.hex8.exec(color))) { return { a: convertHexToDecimal(match[1]), r: parseIntFromHex(match[2]), g: parseIntFromHex(match[3]), b: parseIntFromHex(match[4]), format: named ? "name" : "hex8" }; } if ((match = matchers.hex6.exec(color))) { return { r: parseIntFromHex(match[1]), g: parseIntFromHex(match[2]), b: parseIntFromHex(match[3]), format: named ? "name" : "hex" }; } if ((match = matchers.hex3.exec(color))) { return { r: parseIntFromHex(match[1] + '' + match[1]), g: parseIntFromHex(match[2] + '' + match[2]), b: parseIntFromHex(match[3] + '' + match[3]), format: named ? "name" : "hex" }; } return false; } window.tinycolor = tinycolor; })(); $(function () { if ($.fn.spectrum.load) { $.fn.spectrum.processNativeColorInputs(); } }); }); ================================================ FILE: js/admin/template.js ================================================ (function($) { $(document).ready(function() { var $editor = $("#template-editor"); var $title = $(".editor .title input", $editor); var $toolbar = $(".toolbar", $editor); var $footer = $("footer", $editor); var $navigation = $(".navigation", $editor); var $preview = $("#podlove_template_shortcode_preview"); var editor = ace.edit("ace-editor"); var isNetwork = !!podlove_admin_network_global.is_network_admin ? "yes" : "no"; $("#fullscreen").on( 'click', function () { $(document.body).toggleClass("fullScreen"); $("#ace-editor").toggleClass("fullScreen-editor"); $(this).toggleClass("fullscreen-on").toggleClass("fullscreen-off"); editor.resize(); window.scroll(0,0); // reset window scrolling to avoid fullscreen-button positioning issues } ); // local cache var templates = []; var template = function (id, title, content) { var $navigationItem = $("li a[data-id=" + id + "]", $navigation); var isMarked = false; var markAsUnsaved = function () { if (!isMarked) { isMarked = true; $navigationItem.html($navigationItem.html() + ''); } }; var markAsSaved = function () { if (isMarked) { isMarked = false; $navigationItem.find(".unsaved").remove(); $preview.val('[podlove-template template="' + this.title + '"]'); } }; var activate = function () { $title.val(this.title); $preview.val('[podlove-template template="' + this.title + '"]'); editor.getSession().setValue(this.content ? this.content : ""); }; return { id: id, title: title, content: content, markAsUnsaved: markAsUnsaved, markAsSaved: markAsSaved, activate: activate } }; editor.setTheme("ace/theme/chrome"); editor.getSession().setMode("ace/mode/twig"); editor.getSession().setUseWrapMode(true); var activate_template = function(e) { var $this = $(this); var template_id = $this.data('id'); $this.closest("li") .addClass("active") .siblings().removeClass("active") ; if (templates[template_id]) { templates[template_id].activate(); } else { $.getJSON(ajaxurl, { id: template_id, is_network: isNetwork, action: 'podlove-template-get' }, function(data) { templates[template_id] = template(template_id, data.title, data.content); templates[template_id].activate(); }); } $this.blur(); // removes link outline if (e) { e.preventDefault(); } }; var save_template = function(e) { var save_button = $(this); var template_id = $("li.active a", $navigation).data("id"); var template_title = $title.val(); var template_content = editor.getSession().getValue(); var saving_icon = ''; $("li.active a", $navigation).append(saving_icon); $.ajax(ajaxurl, { dataType: 'json', type: 'POST', data: { id: template_id, title: template_title, content: template_content, is_network: isNetwork, action: 'podlove-template-update', nonce: podlove_admin_global.nonce_ajax }, success: function(data, status, xhr) { save_button.blur(); $("li.active a i", $navigation).remove(); if (!data.success) { console.log("Error: Could not save template."); } else { templates[template_id].markAsSaved(); } } }); e.preventDefault(); }; var update_title = function(e) { var $active_item = $("li.active a", $navigation); var template_id = $active_item.data("id"); var new_title = $(this).val(); // update cache templates[template_id].title = new_title; templates[template_id].markAsUnsaved(); // update navigation element $(".filename", $active_item).html(new_title); }; var update_editor_cache = function () { var $active_item = $("li.active a", $navigation); var template_id = $active_item.data("id"); var new_content = editor.getSession().getValue(); // update cache if (templates[template_id]) { templates[template_id].content = new_content; templates[template_id].markAsUnsaved(); } }; var handle_editor_change = function () { // only track user input, *not* programmatical change // @see https://github.com/ajaxorg/ace/issues/503#issuecomment-44525640 if (editor.curOp && editor.curOp.command.name) { update_editor_cache(); } }; var add_template = function(e) { $.ajax(ajaxurl, { dataType: 'json', type: 'POST', data: { action: 'podlove-template-create', is_network: isNetwork, nonce: podlove_admin_global.nonce_ajax }, success: function(data, status, xhr) { $("ul", $navigation) .append("
  • new template 
  • "); $.proxy(activate_template, $("ul li:last a", $navigation))(); $title.focus(); } }); e.preventDefault(); }; var delete_template = function(e) { var template_id = $("li.active a", $navigation).data('id'); if (window.confirm("Delete template?")) { $.ajax(ajaxurl, { dataType: 'json', type: 'POST', data: { id: template_id, is_network: isNetwork, action: 'podlove-template-delete', nonce: podlove_admin_global.nonce_ajax }, success: function(data, status, xhr) { if (data.success) { // delete navigation entry $("li a[data-id=" + template_id + "]", $navigation) .closest("li") .remove(); // clear out editor $title.val(""); editor.getSession().setValue(""); // select other template, if available $("li:first a", $navigation).click(); } else { console.log("Error: Could not delete template."); } } }); } e.preventDefault(); }; $title.keyup(update_title); editor.on("change", handle_editor_change); editor.on("paste", update_editor_cache); $navigation.on("click", "a[data-id]", activate_template); $navigation.on("click", ".add a", add_template); $footer.on("click", "a.save", save_template); $footer.on("click", ".delete", delete_template); // select first template on page load $("li:first a", $navigation).click(); }); }(jQuery)); ================================================ FILE: js/admin/tools/useragent.js ================================================ (function($){ var UserAgentRecalculator = function(id) { var that = this; this.button = $(id); this.status = this.button.parent().find('.status'); this.button.on('click', function(e) { e.preventDefault(); that.start(); }) }; UserAgentRecalculator.prototype.setStatus = function(status) { this.progressbar.progressbar("value", status); } UserAgentRecalculator.prototype.start = function() { $(window).bind('beforeunload', function(){ return "If you leave, \"User Agent Refresh\" will abort."; }); var label = $("#progressbar .progress-label"); progressbar = $("#progressbar"); progressbar.progressbar({ value: false, change: function() { label.text( progressbar.progressbar("value") + "%" ); }, complete: function() { label.text("Complete!"); } }); this.progressbar = progressbar; this.setStatus("0"); this.refresh_some(0); } UserAgentRecalculator.prototype.refresh_some = function(offset) { var that = this; $.ajax({ url: ajaxurl, data: { action: 'podlove-useragentrefresh', offset: offset }, dataType: 'json', success: function(result) { if (result.offset && result.offset < result.total) { var percent = result.offset / result.total * 100; that.setStatus(Math.round(percent)); that.refresh_some(result.offset); } else { that.setStatus(100); $(window).unbind("beforeunload"); } } }); } $(document).ready(function() { var calc = new UserAgentRecalculator('#recalculate_useragents'); }); }(jQuery)); ================================================ FILE: js/package.json ================================================ { "scripts": { "serve": "mix watch --mix-config=webpack.mix.js", "build": "mix --mix-config=webpack.mix.js --production" }, "devDependencies": { "elliptic": ">=6.6.1", "laravel-mix": "6.0.49", "node-forge": "^1.4.0", "normalplaytime": "^1.0.4", "vue": "^2.7.16", "vue-loader": "^15.11.1" }, "dependencies": { "@popperjs/core": "2.11.8", "axios": "^1.14.0", "clipboard": "2.0.11", "file-saver": "^2.0.5", "v2-datepicker": "^3.1.1", "vue-axios": "^2.1.5", "vue-template-compiler": "^2.7.16", "vuedraggable": "^2.24.3" }, "overrides": { "brace-expansion": "1.1.13", "path-to-regexp": "0.1.13" } } ================================================ FILE: js/src/admin/dashboard_asset_validation.js ================================================ var PODLOVE = PODLOVE || {}; /** * Handles all logic in Dashboard Validation box. */ (function($) { PODLOVE.DashboardAssetValidation = function(container) { // private var o = {}; function enable_validation() { $("#asset_status_dashboard td[data-media-file-id]").click(function() { var media_file_id = $(this).data("media-file-id"); if (!media_file_id) return; var $that = $(this); var data = { action: 'podlove-file-update', file_id: media_file_id }; $(this).html(''); // TODO: use REST API instead, then FileController can be deleted $.ajax({ url: ajaxurl, data: data, dataType: 'json', success: function(result) { if (!result.active) { $that.html(''); } else { if (result.file_size > 0) { $that.html(''); } else { $that.html(''); } } } }); }); $("#revalidate_assets").click(function(e) { e.preventDefault(); $("#asset_status_dashboard td[data-media-file-id]").each(function() { $(this).click(); }); return false; }); } // public enable_validation(); return o; } }(jQuery)); ================================================ FILE: js/src/admin/dashboard_feed_validation.js ================================================ var PODLOVE = PODLOVE || {}; /** * Handles all logic in Dashboard Validation box. */ (function($) { PODLOVE.DashboardFeedValidation = function(container) { // private var o = {}; function enable_validation() { $("#dashboard_feed_info").on('click', 'td[data-feed-id]', function() { var feed_id = $(this).data("feed-id"); var redirect = $(this).data("feed-redirect"); if (!feed_id) return; var $that = $(this); var data = { action: 'podlove-validate-feed', feed_id: feed_id, redirect: redirect }; $(this).html(''); $.ajax({ url: ajaxurl, data: data, dataType: 'json', success: function(result) { $that.html(result.validation_icon); } }); }); $("#revalidate_feeds").click(function(e) { e.preventDefault(); $("#dashboard_feed_info td[data-feed-id]").each(function() { $(this).click(); }); }); } function enable_information() { $("#dashboard_feed_info").on('click', 'td[data-feed-id]', function() { var feed_id = $(this).data("feed-id"); var redirect = $(this).data("feed-redirect"); if (!feed_id) return; var column_latest_item = $(this).prev(); var column_size = column_latest_item.prev(); var column_modification = column_size.prev(); var data = { action: 'podlove-feed-info', feed_id: feed_id, redirect: redirect }; column_latest_item.html(''); column_size.html(''); column_modification.html(''); $.ajax({ url: ajaxurl, data: data, dataType: 'json', success: function(result) { column_size.html(result.size); column_modification.html(result.last_modification); column_latest_item.html(result.latest_item); } }); }); } enable_validation(); enable_information(); // fetch missing data on page load $("#dashboard_feed_info [data-needs-validation]").each(function() { $(this).removeAttr('data-needs-validation').click(); }); return o; } }(jQuery)); ================================================ FILE: js/src/admin/episode.js ================================================ var PODLOVE = PODLOVE || {} /** * Handles all logic in Create/Edit Episode screen. */ ;(function ($) { PODLOVE.Episode = function (container) { var o = {} o.slug_field = container.find('[name*=slug]') $('#_podlove_meta_subtitle').count_characters({ limit: 255, title: 'recommended maximum length: 255', }) $('#_podlove_meta_summary').count_characters({ limit: 4000, title: 'recommended maximum length: 4000', }) $(document).on('click', '.subtitle_warning .close', function () { $(this).closest('.subtitle_warning').remove() }) $('#_podlove_meta_subtitle').keydown(function (e) { // forbid return key if (e.keyCode == 13) { e.preventDefault() if (!$('.subtitle_warning').length) { $(this).after( 'The subtitle has to be a single line. (hide)' ) } return false } }) var typewatch = (function () { var timer = 0 return function (callback, ms) { clearTimeout(timer) timer = setTimeout(callback, ms) } })() $.subscribe('/auphonic/production/status/results_imported', function (e, production) { o.slug_field.trigger('slugHasChanged').data('auto-update', false) }) var title_input = $('#titlewrap input') title_input .on('blur', function () { title_input.trigger('titleHasChanged') }) .on('keyup', function () { typewatch(function () { title_input.trigger('titleHasChanged') }, 500) }) .on('titleHasChanged', function () { var title = $(this).val() // update episode title $('#_podlove_meta_title').attr('placeholder', title) }) .trigger('titleHasChanged') o.slug_field .on('blur', function () { o.slug_field.trigger('slugHasChanged') }) .on('keyup', function () { typewatch(function () { o.slug_field.trigger('slugHasChanged') }, 500) }) return o } })(jQuery) ================================================ FILE: js/src/admin/episode_asset_settings.js ================================================ var PODLOVE = PODLOVE || {}; /** * Handles all logic in Show Settings Screen. */ (function($) { PODLOVE.EpisodeAssetSettings = function(container) { // private var o = {}; function make_asset_list_table_sortable() { $("table.episode_assets tbody").sortable({ handle: '.reorder-handle', helper: function(event, el) { helper = $("
    "); helper.append( el.find(".title").html() ); helper.css({ width: $("table.episode_assets").width(), background: 'rgba(255,255,255,0.66)', boxSizing: 'border-box', padding: 5 }); return helper; }, update: function( event, ui ) { // console.log(ui); var prev = parseFloat(ui.item.prev().find(".position").val()), next = parseFloat(ui.item.next().find(".position").val()), new_position = 0; if ( ! prev ) { new_position = next / 2; } else if ( ! next ) { new_position = prev + 1; } else { new_position = prev + (next - prev) / 2 } // update UI ui.item.find(".position").val(new_position); // persist var data = { action: 'podlove-update-asset-position', asset_id: ui.item.find(".asset_id").val(), position: new_position }; $.ajax({ url: ajaxurl, data: data, dataType: 'json' }); } }); } function filter_file_formats_by_asset_type() { $('select[name=podlove_episode_asset_type]', container).on('change', function() { var assetType = $(this).val(); var $fileTypeSelect = $("#podlove_episode_asset_file_type_id"); var selectedValue = $fileTypeSelect.val(); $("#option_storage option").remove().appendTo($fileTypeSelect); $fileTypeSelect.find("option[data-type!='" + assetType + "']").remove().appendTo($("#option_storage")); if (!$fileTypeSelect.find("option[value='" + selectedValue + "']").length) { var $preferredOption = $fileTypeSelect.find("option[data-default-for-type='" + assetType + "']").first(); if (!$preferredOption.length) { $preferredOption = $fileTypeSelect.find("option").first(); } $fileTypeSelect.val($preferredOption.val()); } $fileTypeSelect.change(); }).change(); } function slugify(text) { text = text.trim(); // replace non letter or digits by - text = text.replace(/[^-\w\.\~]/g, '-'); text = text.toLowerCase(); return text ? text : 'n-a'; } // set default asset title function generate_default_episode_asset_title() { $('select[name*=file_type_id]', container).on('change', function() { var $container = $(this).closest('table'); var $title = $container.find('[name*="title"]'); var $name = $container.find('[name*="name"]'); var fileFormatTitle = $("option:selected", this).data('name'); var isCreateAction = ($container.closest("form").find("input[name='action']").val() === 'create'); if (!fileFormatTitle) return; // only prefill on unsaved assets if (!isCreateAction) return; $title.val($("option:selected", this).data('name')); $name.val(slugify($("option:selected", this).data('name'))); }); } function generate_live_preview() { // handle preview updates $('input[name*="url_template"]', container).on( 'keyup', o.update_preview ); $('input[name*="suffix"]', container).on( 'keyup', o.update_preview ); $('#podlove_show_media_file_base_uri', container).on( 'keyup', o.update_preview ); $('select[name="podlove_episode_asset_type"]', container).on( 'change', o.update_preview ); $('[name*="file_type_id"]', container).on( 'change', o.update_preview ); o.update_preview(); } // public o.update_preview = function () { $('#url_preview', container).each(function() { var template = $("#url_template").html(); var $preview = $("#url_preview"); var $container = $(this).closest('table'); var media_file_base_uri = $('#podlove_show_media_file_base_uri').val(); var episode_slug = 'episode-slug'; var suffix = $('input[name*="suffix"]').val(); var selected_file_type = $container.find('[name*="file_type_id"] option:selected').text(); var format_extension = $container.find('[name*="file_type_id"] option:selected').data('extension'); if (!format_extension) { $preview.html('Please select file format'); return; } template = template.replace( '%media_file_base_url%', '' + media_file_base_uri ); template = template.replace( '%episode_slug%', episode_slug + "" ); template = template.replace( '%suffix%', suffix ); template = template.replace( '%format_extension%', format_extension ); $preview.html(template); }); } generate_default_episode_asset_title(); filter_file_formats_by_asset_type(); generate_live_preview(); make_asset_list_table_sortable(); return o; }; }(jQuery)); ================================================ FILE: js/src/admin/feed_settings.js ================================================ var PODLOVE = PODLOVE || {}; /** * Handles all logic in Feed Settings Screen. */ (function($) { PODLOVE.FeedSettings = function(container) { // private var o = {}; function make_feed_list_table_sortable() { $("table.feeds tbody").sortable({ handle: '.reorder-handle', helper: function(event, el) { helper = $("
    "); helper.append( el.find(".title").html() ); helper.css({ width: $("table.feeds").width(), background: 'rgba(255,255,255,0.66)', boxSizing: 'border-box', padding: 5 }); return helper; }, update: function( event, ui ) { // console.log(ui); var prev = parseFloat(ui.item.prev().find(".position").val()), next = parseFloat(ui.item.next().find(".position").val()), new_position = 0; if ( ! prev ) { new_position = next / 2; } else if ( ! next ) { new_position = prev + 1; } else { new_position = prev + (next - prev) / 2 } // update UI ui.item.find(".position").val(new_position); // persist var data = { action: 'podlove-update-feed-position', feed_id: ui.item.find(".feed_id").val(), position: new_position }; $.ajax({ url: ajaxurl, data: data, dataType: 'json' }); } }); } function generate_slug_live_preview() { // handle preview updates $('#podlove_feed_slug', container).on( 'keyup', o.update_url_preview ); o.update_url_preview(); } function generate_title_live_preview() { // handle preview updates $('#podlove_feed_append_name_to_podcast_title', container).change( function () { o.update_title_preview(); }); $('#podlove_feed_name', container).change( function () { o.update_title_preview(); }); o.update_title_preview(); } function manage_redirect_url_display() { var http_status = $("#podlove_feed_redirect_http_status").val(); if (http_status > 0) { $(".row_podlove_feed_redirect_url").show(); } else { $(".row_podlove_feed_redirect_url").hide(); } } function slugify(text) { text = text.trim(); // replace non letter or digits by - text = text.replace(/[^-\w\.\~]/g, '-'); return text ? text : 'n-a'; } // public o.update_url_preview = function () { // remove trailing slash var url = $("#feed_subscribe_url_preview").data('url').substr(0, $("#feed_subscribe_url_preview").data('url').length - 1); // remove slug if there is one if (url.substr(-4) !== "feed") { url = url.substr(0, url.lastIndexOf("/")); } var slug = slugify($("#podlove_feed_slug").val()); var preview = "" if (slug == "n-a") { preview = "enter slug for preview" } else { preview = url + "/" + slug + "/" } $("#feed_subscribe_url_preview").html(preview); } o.update_title_preview = function () { if( $("#podlove_feed_append_name_to_podcast_title").prop('checked') ) { $("#feed_title_preview_append").html( ' (' + $("#podlove_feed_name").val() + ')' ); } else { $("#feed_title_preview_append").html(''); } } if ($("#feed_title_preview_append").length && $("#podlove_feed_append_name_to_podcast_title").length) { generate_title_live_preview(); } if ($("#feed_subscribe_url_preview").length && $("#podlove_feed_slug").length) { generate_slug_live_preview(); } $("#podlove_feed_redirect_http_status").on("change", function(){ manage_redirect_url_display(); }); manage_redirect_url_display(); make_feed_list_table_sortable(); return o; }; }(jQuery)); ================================================ FILE: js/src/admin/jobs.js ================================================ var PODLOVE = PODLOVE || {}; /** * Handles all logic in Create/Edit Episode screen. * * @todo investigate: looks like there is trouble when a second UARJob is started while the first is still running. */ (function($){ PODLOVE.Jobs = function() {}; PODLOVE.Jobs.create = function(name, args, callback) { $.post(ajaxurl, { action: 'podlove-job-create', name: name, args: args, nonce: podlove_admin_global.nonce_ajax }, 'json').done(function(job) { // console.log("create job done", job); if (callback) { callback(job); } }); }; PODLOVE.Jobs.getStatus = function(job_id, callback) { $.getJSON(ajaxurl, { action: 'podlove-job-get', job_id: job_id }).done(function(status) { // console.log("job status", job); if (callback) { callback(status); } }); } PODLOVE.Jobs.Tools = function() {}; PODLOVE.Jobs.Tools.init = function() { var wrapper = $(this) var job_name = wrapper.data('job') var button_text = wrapper.data('button-text') var job_id = null; var recent_job_id = wrapper.data('recent-job-id') var job_args = wrapper.data('args') || {} var timer = null; var spinner = $(""); var button = $("