From 55123c386d51426c58696289f65c69bd31764322 Mon Sep 17 00:00:00 2001 From: Chris Tactic Date: Sun, 28 Apr 2024 22:02:09 +0200 Subject: [PATCH] #132 | #121: backoffice / dev-1.0-121 (#131) cfr #121 Co-authored-by: Christophe Siraut Co-authored-by: bkfox Co-authored-by: Thomas Kairos Reviewed-on: https://git.radiocampus.be/rc/aircox/pulls/131 Co-authored-by: Chris Tactic Co-committed-by: Chris Tactic --- README.md | 0 aircox/admin/__init__.py | 3 +- aircox/admin/diffusion.py | 6 +- aircox/admin/episode.py | 39 +- aircox/admin/page.py | 75 +- aircox/admin/program.py | 11 +- aircox/admin/schedule.py | 6 +- aircox/admin/sound.py | 46 +- aircox/admin_site.py | 67 - aircox/apps.py | 5 - aircox/conf.py | 9 +- aircox/context_processors/__init__.py | 4 + aircox/controllers/sound_file.py | 178 +- aircox/controllers/sound_monitor.py | 25 +- aircox/filters.py | 95 +- aircox/forms.py | 18 - aircox/forms/__init__.py | 23 + aircox/forms/episode.py | 34 + aircox/forms/page.py | 37 + aircox/forms/program.py | 11 + aircox/forms/sound.py | 26 + aircox/forms/track.py | 23 + aircox/forms/widgets.py | 89 + aircox/locale/fr/LC_MESSAGES/django.mo | Bin 14556 -> 17225 bytes aircox/locale/fr/LC_MESSAGES/django.po | 1582 +- aircox/middleware.py | 7 + ...ule_timezone_alter_staticpage_attach_to.py | 641 + aircox/migrations/0015_program_editors.py | 25 + .../0016_alter_staticpage_attach_to.py | 32 + ...navitem_text_alter_staticpage_attach_to.py | 36 + .../0018_alter_staticpage_attach_to.py | 32 + aircox/migrations/0019_merge_20240119_1022.py | 12 + ..._station_program_streams_title_and_more.py | 42 + aircox/migrations/0020_merge_20240205_1027.py | 12 + .../0021_alter_schedule_timezone.py | 623 + aircox/migrations/0022_set_group_ownership.py | 31 + ...ion_legal_label_alter_schedule_timezone.py | 634 + ...tings_tracklist_editor_columns_and_more.py | 22 + ...ed_alter_sound_is_downloadable_and_more.py | 36 + ...d_options_remove_sound_episode_and_more.py | 162 + ...arent_remove_staticpage_parent_and_more.py | 78 + aircox/models/__init__.py | 12 +- aircox/models/article.py | 5 +- aircox/models/diffusion.py | 39 +- aircox/models/episode.py | 119 +- aircox/models/file.py | 153 + aircox/models/log.py | 73 +- aircox/models/page.py | 189 +- aircox/models/program.py | 55 +- aircox/models/rerun.py | 8 +- aircox/models/schedule.py | 50 +- aircox/models/signals.py | 65 +- aircox/models/sound.py | 371 +- aircox/models/station.py | 26 +- aircox/models/track.py | 72 + aircox/models/user_settings.py | 4 +- aircox/permissions.py | 89 + aircox/serializers/__init__.py | 14 +- aircox/serializers/admin.py | 14 +- aircox/serializers/auth.py | 28 + aircox/serializers/episode.py | 36 + aircox/serializers/page.py | 12 + aircox/serializers/sound.py | 29 +- aircox/static/aircox/admin.css | 1 + aircox/static/aircox/admin.html | 12 - aircox/static/aircox/admin.js | 30 + aircox/static/aircox/admin.js.map | 1 + .../static/aircox/assets/admin-BkECowH5.css | 1 + .../static/aircox/assets/admin-BoR3j_Hw.css | 1 + aircox/static/aircox/assets/index-BHp9xxGn.js | 46 + .../aircox/assets/index-BHp9xxGn.js.map | 1 + aircox/static/aircox/assets/index-BZUnmcIM.js | 30 + .../aircox/assets/index-BZUnmcIM.js.map | 1 + aircox/static/aircox/assets/index-BlOTjzEl.js | 18 + .../aircox/assets/index-BlOTjzEl.js.map | 1 + aircox/static/aircox/assets/index-CKSgqJ6k.js | 46 + .../aircox/assets/index-CKSgqJ6k.js.map | 1 + .../static/aircox/assets/index-CQSUXIlM.css | 1 + aircox/static/aircox/assets/index-DYrJKfQw.js | 46 + .../aircox/assets/index-DYrJKfQw.js.map | 1 + .../static/aircox/assets/index-QB2VsSsQ.css | 1 + .../static/aircox/assets/public-D1AeeZhP.css | 1 + aircox/static/aircox/core.html | 12 - aircox/static/aircox/css/admin.css | 24 - aircox/static/aircox/css/chunk-common.css | 10896 ---------- aircox/static/aircox/css/chunk-vendors.css | 9 - aircox/static/aircox/index-BtiUbLPj.cjs | 29 + aircox/static/aircox/index-ByevNFTS.js | 13537 +++++++++++++ aircox/static/aircox/index.css | 1 + aircox/static/aircox/index.js | 2 + aircox/static/aircox/index.js.map | 1 + aircox/static/aircox/js/admin.js | 225 - aircox/static/aircox/js/chunk-common.js | 822 - aircox/static/aircox/js/chunk-vendors.js | 845 - aircox/static/aircox/js/core.js | 215 - aircox/static/aircox/public.css | 1 + aircox/static/aircox/public.js | 2 + aircox/static/aircox/public.js.map | 1 + .../static/aircox/webfonts/fa-brands-400.eot | Bin 0 -> 136822 bytes .../static/aircox/webfonts/fa-brands-400.svg | 3717 ++++ .../{fonts => webfonts}/fa-brands-400.ttf | Bin .../static/aircox/webfonts/fa-brands-400.woff | Bin 0 -> 92136 bytes .../{fonts => webfonts}/fa-brands-400.woff2 | Bin .../static/aircox/webfonts/fa-regular-400.eot | Bin 0 -> 34350 bytes .../static/aircox/webfonts/fa-regular-400.svg | 801 + .../{fonts => webfonts}/fa-regular-400.ttf | Bin .../aircox/webfonts/fa-regular-400.woff | Bin 0 -> 16772 bytes .../{fonts => webfonts}/fa-regular-400.woff2 | Bin .../static/aircox/webfonts/fa-solid-900.eot | Bin 0 -> 204814 bytes .../static/aircox/webfonts/fa-solid-900.svg | 5028 +++++ .../{fonts => webfonts}/fa-solid-900.ttf | Bin .../static/aircox/webfonts/fa-solid-900.woff | Bin 0 -> 104280 bytes .../{fonts => webfonts}/fa-solid-900.woff2 | Bin .../fa-v4compatibility.ttf | Bin aircox/static/fontawesome-free/LICENSE.txt | 165 + aircox/static/fontawesome-free/css/all.css | 8030 ++++++++ .../static/fontawesome-free/css/all.min.css | 9 + aircox/static/fontawesome-free/css/brands.css | 1594 ++ .../fontawesome-free/css/brands.min.css | 6 + .../fontawesome-free/css/fontawesome.css | 6375 ++++++ .../fontawesome-free/css/fontawesome.min.css | 9 + .../static/fontawesome-free/css/regular.css | 19 + .../fontawesome-free/css/regular.min.css | 6 + aircox/static/fontawesome-free/css/solid.css | 19 + .../static/fontawesome-free/css/solid.min.css | 6 + .../fontawesome-free/css/svg-with-js.css | 640 + .../fontawesome-free/css/svg-with-js.min.css | 6 + .../fontawesome-free/css/v4-font-face.css | 26 + .../fontawesome-free/css/v4-font-face.min.css | 6 + .../static/fontawesome-free/css/v4-shims.css | 2194 ++ .../fontawesome-free/css/v4-shims.min.css | 6 + .../fontawesome-free/css/v5-font-face.css | 22 + .../fontawesome-free/css/v5-font-face.min.css | 6 + aircox/static/fontawesome-free/js/all.js | 6034 ++++++ aircox/static/fontawesome-free/js/all.min.js | 6 + aircox/static/fontawesome-free/js/brands.js | 780 + .../static/fontawesome-free/js/brands.min.js | 6 + .../fontawesome-free/js/conflict-detection.js | 1146 ++ .../js/conflict-detection.min.js | 6 + .../static/fontawesome-free/js/fontawesome.js | 3134 +++ .../fontawesome-free/js/fontawesome.min.js | 6 + aircox/static/fontawesome-free/js/regular.js | 453 + .../static/fontawesome-free/js/regular.min.js | 6 + aircox/static/fontawesome-free/js/solid.js | 1682 ++ .../static/fontawesome-free/js/solid.min.js | 6 + aircox/static/fontawesome-free/js/v4-shims.js | 233 + .../fontawesome-free/js/v4-shims.min.js | 6 + aircox/static/fontawesome-free/package.json | 32 + .../webfonts/fa-brands-400.ttf | Bin 0 -> 209128 bytes .../webfonts/fa-brands-400.woff2 | Bin 0 -> 117852 bytes .../webfonts/fa-regular-400.ttf | Bin 0 -> 67860 bytes .../webfonts/fa-regular-400.woff2 | Bin 0 -> 25392 bytes .../webfonts/fa-solid-900.ttf | Bin 0 -> 420332 bytes .../webfonts/fa-solid-900.woff2 | Bin 0 -> 156400 bytes .../webfonts/fa-v4compatibility.ttf | Bin 0 -> 10832 bytes .../webfonts/fa-v4compatibility.woff2 | Bin 0 -> 4792 bytes aircox/static/vue/vue.cjs.js | 89 + aircox/static/vue/vue.cjs.prod.js | 75 + aircox/static/vue/vue.d.mts | 7 + aircox/static/vue/vue.d.ts | 7 + aircox/static/vue/vue.esm-browser.js | 16740 ++++++++++++++++ aircox/static/vue/vue.esm-browser.prod.js | 12 + aircox/static/vue/vue.esm-bundler.js | 80 + aircox/static/vue/vue.global.js | 16725 +++++++++++++++ aircox/static/vue/vue.global.prod.js | 12 + aircox/static/vue/vue.runtime.esm-browser.js | 11155 ++++++++++ .../vue/vue.runtime.esm-browser.prod.js | 12 + aircox/static/vue/vue.runtime.esm-bundler.js | 26 + aircox/static/vue/vue.runtime.global.js | 11302 +++++++++++ aircox/static/vue/vue.runtime.global.prod.js | 12 + .../admin/aircox/page_change_form.html | 44 - .../admin/aircox/page_change_list.html | 19 - .../admin/aircox/playlist_inline.html | 85 - aircox/templates/admin/aircox/statistics.html | 82 - aircox/templates/admin/base.html | 224 - aircox/templates/admin/base_site.html | 8 - aircox/templates/admin/change_form.html | 5 - aircox/templates/admin/change_list.html | 89 - aircox/templates/admin/index.html | 94 - aircox/templates/aircox/article_detail.html | 28 - aircox/templates/aircox/base.html | 255 +- aircox/templates/aircox/basepage_detail.html | 8 - aircox/templates/aircox/basepage_list.html | 93 +- aircox/templates/aircox/dashboard/base.html | 16 + .../templates/aircox/dashboard/dashboard.html | 51 + .../aircox/dashboard/statistics.html | 104 + .../templates/aircox/dashboard/user_list.html | 77 + .../aircox/dashboard/widgets/form_field.html | 25 + .../aircox/dashboard/widgets/group_users.html | 34 + .../aircox/dashboard/widgets/list_editor.html | 54 + .../dashboard/widgets/soundlist_editor.html | 46 + .../dashboard/widgets/tracklist_editor.html | 34 + .../aircox/dashboard/widgets/user_groups.html | 30 + .../dashboard/widgets/v_form_field.html | 24 + aircox/templates/aircox/diffusion_list.html | 14 +- aircox/templates/aircox/episode_detail.html | 110 +- aircox/templates/aircox/episode_form.html | 15 + aircox/templates/aircox/forms/form_field.html | 25 + aircox/templates/aircox/forms/formset.html | 53 + .../templates/aircox/forms/v_form_field.html | 24 + aircox/templates/aircox/home.html | 129 +- aircox/templates/aircox/log_list.html | 29 - aircox/templates/aircox/page_detail.html | 113 +- aircox/templates/aircox/page_form.html | 129 + aircox/templates/aircox/page_list.html | 109 +- aircox/templates/aircox/program_detail.html | 102 +- aircox/templates/aircox/program_form.html | 26 + aircox/templates/aircox/program_sidebar.html | 6 - aircox/templates/aircox/public.html | 18 + aircox/templates/aircox/timetable_list.html | 28 + aircox/templates/aircox/widgets/article.html | 4 + .../aircox/widgets/autocomplete.html | 4 + .../aircox/widgets/basepage_item.html | 83 +- .../templates/aircox/widgets/breadcrumbs.html | 15 + aircox/templates/aircox/widgets/card.html | 23 + aircox/templates/aircox/widgets/carousel.html | 28 + aircox/templates/aircox/widgets/comment.html | 55 + .../templates/aircox/widgets/dates_menu.html | 55 +- .../aircox/widgets/diffusion_list.html | 17 +- .../aircox/widgets/diffusion_tags.html | 26 + aircox/templates/aircox/widgets/episode.html | 71 + .../aircox/widgets/episode_item.html | 68 +- aircox/templates/aircox/widgets/item.html | 34 + .../aircox/widgets/list_pagination.html | 39 + aircox/templates/aircox/widgets/log.html | 23 + aircox/templates/aircox/widgets/log_item.html | 22 - aircox/templates/aircox/widgets/log_list.html | 30 - aircox/templates/aircox/widgets/logs.html | 35 + aircox/templates/aircox/widgets/nav.html | 50 + aircox/templates/aircox/widgets/page.html | 38 + .../aircox/widgets/page_actions.html | 33 + .../templates/aircox/widgets/page_card.html | 13 + .../templates/aircox/widgets/page_item.html | 8 + .../templates/aircox/widgets/page_list.html | 4 +- aircox/templates/aircox/widgets/player.html | 26 +- aircox/templates/aircox/widgets/preview.html | 70 + .../templates/aircox/widgets/track_item.html | 21 +- aircox/templates/aircox/widgets/wide.html | 43 + aircox/templates/registration/login.html | 18 + aircox/templatetags/aircox.py | 109 +- aircox/templatetags/aircox_admin.py | 61 +- aircox/tests/_test_permissions.py | 46 + aircox/tests/conftest.py | 52 +- aircox/tests/controllers/test_sound_file.py | 50 +- .../tests/controllers/test_sound_monitor.py | 13 +- aircox/tests/image.png | Bin 0 -> 69 bytes aircox/tests/models/test_sound.py | 122 + aircox/tests/test_admin_site.py | 45 - aircox/tests/views/conftest.py | 3 + aircox/tests/views/test_base.py | 19 +- aircox/tests/views/test_mixins.py | 10 +- aircox/urls.py | 107 +- aircox/views/__init__.py | 38 +- aircox/views/admin.py | 4 +- aircox/views/article.py | 15 +- aircox/views/auth.py | 28 + aircox/views/base.py | 61 +- aircox/views/dashboard.py | 57 + aircox/views/diffusion.py | 47 +- aircox/views/episode.py | 129 +- aircox/views/home.py | 75 +- aircox/views/log.py | 71 +- aircox/views/mixins.py | 62 +- aircox/views/page.py | 158 +- aircox/views/program.py | 104 +- aircox/viewsets.py | 180 +- aircox_streamer/conf.py | 19 + aircox_streamer/connector.py | 14 +- aircox_streamer/controllers/metadata.py | 5 +- aircox_streamer/controllers/monitor.py | 10 +- aircox_streamer/controllers/sources.py | 12 +- aircox_streamer/controllers/streamer.py | 10 +- .../locale/fr/LC_MESSAGES/django.mo | Bin 1495 -> 1436 bytes .../locale/fr/LC_MESSAGES/django.po | 124 +- aircox_streamer/serializers.py | 6 +- .../templates/aircox/widgets/nav.html | 9 + .../aircox_streamer/scripts/station.liq | 37 +- .../aircox_streamer/source_item.html | 111 +- .../templates/aircox_streamer/streamer.html | 15 +- aircox_streamer/tests/conftest.py | 19 +- .../tests/test_controllers_monitor.py | 10 +- .../tests/test_controllers_sources.py | 2 +- aircox_streamer/urls.py | 42 +- aircox_streamer/views.py | 6 +- aircox_streamer/viewsets.py | 16 +- assets/README.md | 37 +- assets/babel.config.js | 5 - assets/jsconfig.json | 19 +- assets/package.json | 33 +- assets/public/logo.png | Bin 7275 -> 0 bytes assets/public/vue.esm-browser.js | 1 - assets/public/vue.esm-browser.prod.js | 1 - assets/src/admin.js | 21 +- assets/src/app.js | 28 +- assets/src/appBuilder.js | 139 - assets/src/assets/admin.scss | 30 - assets/src/assets/styles.scss | 327 - assets/src/components/AActionButton.vue | 17 +- assets/src/components/AAutocomplete.vue | 78 +- assets/src/components/ACarousel.vue | 242 + assets/src/components/ADropdown.vue | 49 + assets/src/components/AEpisode.vue | 10 +- assets/src/components/AFileUpload.vue | 110 + assets/src/components/AFormSet.vue | 193 + assets/src/components/AManyToManyEdit.vue | 109 + assets/src/components/AModal.vue | 53 + assets/src/components/APlayer.vue | 157 +- assets/src/components/APlaylist.vue | 34 +- assets/src/components/APlaylistEditor.vue | 329 - assets/src/components/AProgress.vue | 23 +- assets/src/components/ARow.vue | 21 +- assets/src/components/ARows.vue | 75 +- assets/src/components/ASelectFile.vue | 167 + assets/src/components/ASoundItem.vue | 40 +- assets/src/components/ASoundListEditor.vue | 83 + assets/src/components/AStatistics.vue | 5 +- assets/src/components/ASwitch.vue | 80 + assets/src/components/ATrackListEditor.vue | 288 + assets/src/components/admin.js | 23 + assets/src/components/index.js | 20 +- assets/src/index.js | 64 +- assets/src/live.js | 1 + assets/src/model.js | 114 +- assets/src/pageLoad.js | 174 + assets/src/{core.js => public.js} | 3 +- assets/src/sound.js | 7 +- assets/src/styles/admin.scss | 101 + assets/src/styles/common.scss | 98 + assets/src/styles/components.scss | 757 + assets/src/styles/helpers.scss | 162 + assets/src/styles/public.scss | 477 + assets/src/styles/vars.scss | 52 + assets/src/styles/vendor.scss | 35 + assets/src/track.js | 5 - assets/src/vueLoader.js | 47 + assets/vite.config.js | 44 + assets/vue.config.js | 22 - instance/settings/base.py | 15 +- instance/urls.py | 6 +- notes.md | 18 + radiocampus/__init__.py | 0 radiocampus/apps.py | 6 + radiocampus/migrations/__init__.py | 0 .../fonts/CampusGroteskv11-Regular.otf | Bin 0 -> 11916 bytes .../fonts/CampusGroteskv12-Regular.otf | Bin 0 -> 11996 bytes .../fonts/CampusGroteskv8-Regular.otf | Bin 0 -> 11860 bytes radiocampus/templates/aircox/base.html | 23 + requirements.txt | 8 +- 348 files changed, 124397 insertions(+), 17879 deletions(-) mode change 100755 => 100644 README.md delete mode 100644 aircox/admin_site.py create mode 100644 aircox/context_processors/__init__.py delete mode 100644 aircox/forms.py create mode 100644 aircox/forms/__init__.py create mode 100644 aircox/forms/episode.py create mode 100644 aircox/forms/page.py create mode 100644 aircox/forms/program.py create mode 100644 aircox/forms/sound.py create mode 100644 aircox/forms/track.py create mode 100644 aircox/forms/widgets.py create mode 100644 aircox/migrations/0015_alter_schedule_timezone_alter_staticpage_attach_to.py create mode 100644 aircox/migrations/0015_program_editors.py create mode 100644 aircox/migrations/0016_alter_staticpage_attach_to.py create mode 100644 aircox/migrations/0017_alter_navitem_text_alter_staticpage_attach_to.py create mode 100644 aircox/migrations/0018_alter_staticpage_attach_to.py create mode 100644 aircox/migrations/0019_merge_20240119_1022.py create mode 100644 aircox/migrations/0019_station_program_streams_title_and_more.py create mode 100644 aircox/migrations/0020_merge_20240205_1027.py create mode 100644 aircox/migrations/0021_alter_schedule_timezone.py create mode 100644 aircox/migrations/0022_set_group_ownership.py create mode 100644 aircox/migrations/0023_station_legal_label_alter_schedule_timezone.py create mode 100644 aircox/migrations/0024_rename_playlist_editor_columns_usersettings_tracklist_editor_columns_and_more.py create mode 100644 aircox/migrations/0025_sound_is_removed_alter_sound_is_downloadable_and_more.py create mode 100644 aircox/migrations/0026_alter_sound_options_remove_sound_episode_and_more.py create mode 100644 aircox/migrations/0027_remove_page_parent_remove_staticpage_parent_and_more.py create mode 100644 aircox/models/file.py create mode 100644 aircox/models/track.py create mode 100644 aircox/permissions.py create mode 100644 aircox/serializers/auth.py create mode 100644 aircox/serializers/episode.py create mode 100644 aircox/serializers/page.py create mode 100644 aircox/static/aircox/admin.css delete mode 100644 aircox/static/aircox/admin.html create mode 100644 aircox/static/aircox/admin.js create mode 100644 aircox/static/aircox/admin.js.map create mode 100644 aircox/static/aircox/assets/admin-BkECowH5.css create mode 100644 aircox/static/aircox/assets/admin-BoR3j_Hw.css create mode 100644 aircox/static/aircox/assets/index-BHp9xxGn.js create mode 100644 aircox/static/aircox/assets/index-BHp9xxGn.js.map create mode 100644 aircox/static/aircox/assets/index-BZUnmcIM.js create mode 100644 aircox/static/aircox/assets/index-BZUnmcIM.js.map create mode 100644 aircox/static/aircox/assets/index-BlOTjzEl.js create mode 100644 aircox/static/aircox/assets/index-BlOTjzEl.js.map create mode 100644 aircox/static/aircox/assets/index-CKSgqJ6k.js create mode 100644 aircox/static/aircox/assets/index-CKSgqJ6k.js.map create mode 100644 aircox/static/aircox/assets/index-CQSUXIlM.css create mode 100644 aircox/static/aircox/assets/index-DYrJKfQw.js create mode 100644 aircox/static/aircox/assets/index-DYrJKfQw.js.map create mode 100644 aircox/static/aircox/assets/index-QB2VsSsQ.css create mode 100644 aircox/static/aircox/assets/public-D1AeeZhP.css delete mode 100644 aircox/static/aircox/core.html delete mode 100644 aircox/static/aircox/css/admin.css delete mode 100644 aircox/static/aircox/css/chunk-common.css delete mode 100644 aircox/static/aircox/css/chunk-vendors.css create mode 100644 aircox/static/aircox/index-BtiUbLPj.cjs create mode 100644 aircox/static/aircox/index-ByevNFTS.js create mode 100644 aircox/static/aircox/index.css create mode 100644 aircox/static/aircox/index.js create mode 100644 aircox/static/aircox/index.js.map delete mode 100644 aircox/static/aircox/js/admin.js delete mode 100644 aircox/static/aircox/js/chunk-common.js delete mode 100644 aircox/static/aircox/js/chunk-vendors.js delete mode 100644 aircox/static/aircox/js/core.js create mode 100644 aircox/static/aircox/public.css create mode 100644 aircox/static/aircox/public.js create mode 100644 aircox/static/aircox/public.js.map create mode 100644 aircox/static/aircox/webfonts/fa-brands-400.eot create mode 100644 aircox/static/aircox/webfonts/fa-brands-400.svg rename aircox/static/aircox/{fonts => webfonts}/fa-brands-400.ttf (100%) create mode 100644 aircox/static/aircox/webfonts/fa-brands-400.woff rename aircox/static/aircox/{fonts => webfonts}/fa-brands-400.woff2 (100%) create mode 100644 aircox/static/aircox/webfonts/fa-regular-400.eot create mode 100644 aircox/static/aircox/webfonts/fa-regular-400.svg rename aircox/static/aircox/{fonts => webfonts}/fa-regular-400.ttf (100%) create mode 100644 aircox/static/aircox/webfonts/fa-regular-400.woff rename aircox/static/aircox/{fonts => webfonts}/fa-regular-400.woff2 (100%) create mode 100644 aircox/static/aircox/webfonts/fa-solid-900.eot create mode 100644 aircox/static/aircox/webfonts/fa-solid-900.svg rename aircox/static/aircox/{fonts => webfonts}/fa-solid-900.ttf (100%) create mode 100644 aircox/static/aircox/webfonts/fa-solid-900.woff rename aircox/static/aircox/{fonts => webfonts}/fa-solid-900.woff2 (100%) rename aircox/static/aircox/{fonts => webfonts}/fa-v4compatibility.ttf (100%) create mode 100644 aircox/static/fontawesome-free/LICENSE.txt create mode 100644 aircox/static/fontawesome-free/css/all.css create mode 100644 aircox/static/fontawesome-free/css/all.min.css create mode 100644 aircox/static/fontawesome-free/css/brands.css create mode 100644 aircox/static/fontawesome-free/css/brands.min.css create mode 100644 aircox/static/fontawesome-free/css/fontawesome.css create mode 100644 aircox/static/fontawesome-free/css/fontawesome.min.css create mode 100644 aircox/static/fontawesome-free/css/regular.css create mode 100644 aircox/static/fontawesome-free/css/regular.min.css create mode 100644 aircox/static/fontawesome-free/css/solid.css create mode 100644 aircox/static/fontawesome-free/css/solid.min.css create mode 100644 aircox/static/fontawesome-free/css/svg-with-js.css create mode 100644 aircox/static/fontawesome-free/css/svg-with-js.min.css create mode 100644 aircox/static/fontawesome-free/css/v4-font-face.css create mode 100644 aircox/static/fontawesome-free/css/v4-font-face.min.css create mode 100644 aircox/static/fontawesome-free/css/v4-shims.css create mode 100644 aircox/static/fontawesome-free/css/v4-shims.min.css create mode 100644 aircox/static/fontawesome-free/css/v5-font-face.css create mode 100644 aircox/static/fontawesome-free/css/v5-font-face.min.css create mode 100644 aircox/static/fontawesome-free/js/all.js create mode 100644 aircox/static/fontawesome-free/js/all.min.js create mode 100644 aircox/static/fontawesome-free/js/brands.js create mode 100644 aircox/static/fontawesome-free/js/brands.min.js create mode 100644 aircox/static/fontawesome-free/js/conflict-detection.js create mode 100644 aircox/static/fontawesome-free/js/conflict-detection.min.js create mode 100644 aircox/static/fontawesome-free/js/fontawesome.js create mode 100644 aircox/static/fontawesome-free/js/fontawesome.min.js create mode 100644 aircox/static/fontawesome-free/js/regular.js create mode 100644 aircox/static/fontawesome-free/js/regular.min.js create mode 100644 aircox/static/fontawesome-free/js/solid.js create mode 100644 aircox/static/fontawesome-free/js/solid.min.js create mode 100644 aircox/static/fontawesome-free/js/v4-shims.js create mode 100644 aircox/static/fontawesome-free/js/v4-shims.min.js create mode 100644 aircox/static/fontawesome-free/package.json create mode 100644 aircox/static/fontawesome-free/webfonts/fa-brands-400.ttf create mode 100644 aircox/static/fontawesome-free/webfonts/fa-brands-400.woff2 create mode 100644 aircox/static/fontawesome-free/webfonts/fa-regular-400.ttf create mode 100644 aircox/static/fontawesome-free/webfonts/fa-regular-400.woff2 create mode 100644 aircox/static/fontawesome-free/webfonts/fa-solid-900.ttf create mode 100644 aircox/static/fontawesome-free/webfonts/fa-solid-900.woff2 create mode 100644 aircox/static/fontawesome-free/webfonts/fa-v4compatibility.ttf create mode 100644 aircox/static/fontawesome-free/webfonts/fa-v4compatibility.woff2 create mode 100644 aircox/static/vue/vue.cjs.js create mode 100644 aircox/static/vue/vue.cjs.prod.js create mode 100644 aircox/static/vue/vue.d.mts create mode 100644 aircox/static/vue/vue.d.ts create mode 100644 aircox/static/vue/vue.esm-browser.js create mode 100644 aircox/static/vue/vue.esm-browser.prod.js create mode 100644 aircox/static/vue/vue.esm-bundler.js create mode 100644 aircox/static/vue/vue.global.js create mode 100644 aircox/static/vue/vue.global.prod.js create mode 100644 aircox/static/vue/vue.runtime.esm-browser.js create mode 100644 aircox/static/vue/vue.runtime.esm-browser.prod.js create mode 100644 aircox/static/vue/vue.runtime.esm-bundler.js create mode 100644 aircox/static/vue/vue.runtime.global.js create mode 100644 aircox/static/vue/vue.runtime.global.prod.js delete mode 100644 aircox/templates/admin/aircox/page_change_form.html delete mode 100644 aircox/templates/admin/aircox/page_change_list.html delete mode 100644 aircox/templates/admin/aircox/playlist_inline.html delete mode 100644 aircox/templates/admin/aircox/statistics.html delete mode 100644 aircox/templates/admin/base.html delete mode 100644 aircox/templates/admin/base_site.html delete mode 100644 aircox/templates/admin/change_form.html delete mode 100644 aircox/templates/admin/change_list.html delete mode 100644 aircox/templates/admin/index.html delete mode 100644 aircox/templates/aircox/basepage_detail.html create mode 100644 aircox/templates/aircox/dashboard/base.html create mode 100644 aircox/templates/aircox/dashboard/dashboard.html create mode 100644 aircox/templates/aircox/dashboard/statistics.html create mode 100644 aircox/templates/aircox/dashboard/user_list.html create mode 100644 aircox/templates/aircox/dashboard/widgets/form_field.html create mode 100644 aircox/templates/aircox/dashboard/widgets/group_users.html create mode 100644 aircox/templates/aircox/dashboard/widgets/list_editor.html create mode 100644 aircox/templates/aircox/dashboard/widgets/soundlist_editor.html create mode 100644 aircox/templates/aircox/dashboard/widgets/tracklist_editor.html create mode 100644 aircox/templates/aircox/dashboard/widgets/user_groups.html create mode 100644 aircox/templates/aircox/dashboard/widgets/v_form_field.html create mode 100644 aircox/templates/aircox/episode_form.html create mode 100644 aircox/templates/aircox/forms/form_field.html create mode 100644 aircox/templates/aircox/forms/formset.html create mode 100644 aircox/templates/aircox/forms/v_form_field.html delete mode 100644 aircox/templates/aircox/log_list.html create mode 100644 aircox/templates/aircox/page_form.html create mode 100644 aircox/templates/aircox/program_form.html delete mode 100644 aircox/templates/aircox/program_sidebar.html create mode 100644 aircox/templates/aircox/public.html create mode 100644 aircox/templates/aircox/timetable_list.html create mode 100644 aircox/templates/aircox/widgets/article.html create mode 100644 aircox/templates/aircox/widgets/autocomplete.html create mode 100644 aircox/templates/aircox/widgets/breadcrumbs.html create mode 100644 aircox/templates/aircox/widgets/card.html create mode 100644 aircox/templates/aircox/widgets/carousel.html create mode 100644 aircox/templates/aircox/widgets/comment.html create mode 100644 aircox/templates/aircox/widgets/diffusion_tags.html create mode 100644 aircox/templates/aircox/widgets/episode.html create mode 100644 aircox/templates/aircox/widgets/item.html create mode 100644 aircox/templates/aircox/widgets/list_pagination.html create mode 100644 aircox/templates/aircox/widgets/log.html delete mode 100644 aircox/templates/aircox/widgets/log_item.html delete mode 100644 aircox/templates/aircox/widgets/log_list.html create mode 100644 aircox/templates/aircox/widgets/logs.html create mode 100644 aircox/templates/aircox/widgets/nav.html create mode 100644 aircox/templates/aircox/widgets/page.html create mode 100644 aircox/templates/aircox/widgets/page_actions.html create mode 100644 aircox/templates/aircox/widgets/page_card.html create mode 100644 aircox/templates/aircox/widgets/preview.html create mode 100644 aircox/templates/aircox/widgets/wide.html create mode 100644 aircox/templates/registration/login.html create mode 100644 aircox/tests/_test_permissions.py create mode 100644 aircox/tests/image.png create mode 100644 aircox/tests/models/test_sound.py delete mode 100644 aircox/tests/test_admin_site.py create mode 100644 aircox/views/auth.py create mode 100644 aircox/views/dashboard.py create mode 100644 aircox_streamer/conf.py create mode 100644 aircox_streamer/templates/aircox/widgets/nav.html mode change 100644 => 100755 aircox_streamer/templates/aircox_streamer/source_item.html mode change 100644 => 100755 aircox_streamer/urls.py mode change 100644 => 100755 aircox_streamer/viewsets.py delete mode 100644 assets/babel.config.js delete mode 100644 assets/public/logo.png delete mode 120000 assets/public/vue.esm-browser.js delete mode 120000 assets/public/vue.esm-browser.prod.js delete mode 100644 assets/src/appBuilder.js delete mode 100644 assets/src/assets/admin.scss delete mode 100644 assets/src/assets/styles.scss create mode 100644 assets/src/components/ACarousel.vue create mode 100644 assets/src/components/ADropdown.vue create mode 100644 assets/src/components/AFileUpload.vue create mode 100644 assets/src/components/AFormSet.vue create mode 100644 assets/src/components/AManyToManyEdit.vue create mode 100644 assets/src/components/AModal.vue delete mode 100644 assets/src/components/APlaylistEditor.vue create mode 100644 assets/src/components/ASelectFile.vue create mode 100644 assets/src/components/ASoundListEditor.vue create mode 100644 assets/src/components/ASwitch.vue create mode 100644 assets/src/components/ATrackListEditor.vue create mode 100644 assets/src/components/admin.js create mode 100644 assets/src/pageLoad.js rename assets/src/{core.js => public.js} (68%) create mode 100644 assets/src/styles/admin.scss create mode 100644 assets/src/styles/common.scss create mode 100644 assets/src/styles/components.scss create mode 100644 assets/src/styles/helpers.scss create mode 100644 assets/src/styles/public.scss create mode 100644 assets/src/styles/vars.scss create mode 100644 assets/src/styles/vendor.scss delete mode 100644 assets/src/track.js create mode 100644 assets/src/vueLoader.js create mode 100644 assets/vite.config.js delete mode 100644 assets/vue.config.js create mode 100644 radiocampus/__init__.py create mode 100644 radiocampus/apps.py create mode 100644 radiocampus/migrations/__init__.py create mode 100644 radiocampus/static/radiocampus/fonts/CampusGroteskv11-Regular.otf create mode 100644 radiocampus/static/radiocampus/fonts/CampusGroteskv12-Regular.otf create mode 100644 radiocampus/static/radiocampus/fonts/CampusGroteskv8-Regular.otf create mode 100644 radiocampus/templates/aircox/base.html diff --git a/README.md b/README.md old mode 100755 new mode 100644 diff --git a/aircox/admin/__init__.py b/aircox/admin/__init__.py index 2d86ee8..314954f 100644 --- a/aircox/admin/__init__.py +++ b/aircox/admin/__init__.py @@ -4,7 +4,7 @@ from .diffusion import DiffusionAdmin from .episode import EpisodeAdmin from .log import LogAdmin from .page import PageAdmin, StaticPageAdmin -from .program import ProgramAdmin, StreamAdmin +from .program import ProgramAdmin from .schedule import ScheduleAdmin from .sound import SoundAdmin, TrackAdmin from .station import StationAdmin @@ -19,7 +19,6 @@ __all__ = ( "StaticPageAdmin", "ProgramAdmin", "ScheduleAdmin", - "StreamAdmin", "SoundAdmin", "TrackAdmin", "StationAdmin", diff --git a/aircox/admin/diffusion.py b/aircox/admin/diffusion.py index 2e2fee5..904daa6 100644 --- a/aircox/admin/diffusion.py +++ b/aircox/admin/diffusion.py @@ -30,12 +30,14 @@ class DiffusionAdmin(DiffusionBaseAdmin, admin.ModelAdmin): end_date.short_description = _("end") - list_display = ("episode", "start_date", "end_date", "type", "initial") + list_display = ("episode", "start", "end", "type", "initial") list_filter = ("type", "start", "program") - list_editable = ("type",) + list_editable = ("type", "start", "end") ordering = ("-start", "id") + search_fields = ("program__title", "episode__title") fields = ("type", "start", "end", "initial", "program", "schedule") + autocomplete_fields = ("episode", "program", "initial") readonly_fields = ("schedule",) diff --git a/aircox/admin/episode.py b/aircox/admin/episode.py index 346c169..198d5d6 100644 --- a/aircox/admin/episode.py +++ b/aircox/admin/episode.py @@ -2,12 +2,23 @@ from adminsortable2.admin import SortableAdminBase from django.contrib import admin from django.forms import ModelForm -from aircox.models import Episode -from .page import PageAdmin -from .sound import SoundInline, TrackInline +from aircox.models import Episode, EpisodeSound +from .page import ChildPageAdmin +from .sound import TrackInline from .diffusion import DiffusionInline +class EpisodeSoundInline(admin.TabularInline): + model = EpisodeSound + extra = 0 + fields = ( + "sound", + "position", + "broadcast", + ) + autocomplete_fields = ("sound",) + + class EpisodeAdminForm(ModelForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -15,26 +26,14 @@ class EpisodeAdminForm(ModelForm): @admin.register(Episode) -class EpisodeAdmin(SortableAdminBase, PageAdmin): +class EpisodeAdmin(SortableAdminBase, ChildPageAdmin): form = EpisodeAdminForm - list_display = PageAdmin.list_display - list_filter = tuple(f for f in PageAdmin.list_filter if f != "pub_date") + ( + list_display = ChildPageAdmin.list_display + list_filter = tuple(f for f in ChildPageAdmin.list_filter if f != "pub_date") + ( "diffusion__start", "pub_date", ) - search_fields = PageAdmin.search_fields + ("parent__title",) + search_fields = ChildPageAdmin.search_fields + ("parent__title",) # readonly_fields = ('parent',) - inlines = [TrackInline, SoundInline, DiffusionInline] - - def add_view(self, request, object_id, form_url="", context=None): - context = context or {} - context["init_app"] = True - context["init_el"] = "#inline-tracks" - return super().change_view(request, object_id, form_url, context) - - def change_view(self, request, object_id, form_url="", context=None): - context = context or {} - context["init_app"] = True - context["init_el"] = "#inline-tracks" - return super().change_view(request, object_id, form_url, context) + inlines = (TrackInline, EpisodeSoundInline, DiffusionInline) diff --git a/aircox/admin/page.py b/aircox/admin/page.py index cb227af..3b65ca8 100644 --- a/aircox/admin/page.py +++ b/aircox/admin/page.py @@ -18,10 +18,11 @@ class CategoryAdmin(admin.ModelAdmin): search_fields = ["title"] fields = ["title", "slug"] prepopulated_fields = {"slug": ("title",)} + ordering = ("title",) class BasePageAdmin(admin.ModelAdmin): - list_display = ("cover_thumb", "title", "status", "parent") + list_display = ("cover_thumb", "title", "status") list_display_links = ("cover_thumb", "title") list_editable = ("status",) list_filter = ("status",) @@ -42,15 +43,49 @@ class BasePageAdmin(admin.ModelAdmin): ( _("Publication Settings"), { - "fields": ["status", "parent"], + "fields": [ + "status", + ], }, ), ] - change_form_template = "admin/aircox/page_change_form.html" - def cover_thumb(self, obj): - return mark_safe(''.format(obj.cover.icons["64"])) if obj.cover else "" + if obj.cover and obj.cover.thumbnails: + return mark_safe(''.format(obj.cover.icons["64"])) + return "" + + def _get_extra_context(self, query, **extra_context): + return extra_context + + def add_view(self, request, form_url="", extra_context=None): + filters = QueryDict(request.GET.get("_changelist_filters", "")) + extra_context = self._get_extra_context(filters, **(extra_context or {})) + return super().add_view(request, form_url, extra_context) + + def changelist_view(self, request, extra_context=None): + extra_context = self._get_extra_context(request.GET, **(extra_context or {})) + return super().changelist_view(request, extra_context) + + +@admin.register(Page) +class PageAdmin(BasePageAdmin): + list_display = BasePageAdmin.list_display + ("category",) + list_editable = BasePageAdmin.list_editable + ("category",) + list_filter = BasePageAdmin.list_filter + ("category", "pub_date") + search_fields = BasePageAdmin.search_fields + ("category__title",) + + fieldsets = deepcopy(BasePageAdmin.fieldsets) + fieldsets[0][1]["fields"].insert(fieldsets[0][1]["fields"].index("slug") + 1, "category") + fieldsets[1][1]["fields"] += ("featured", "allow_comments") + + +class ChildPageAdmin(PageAdmin): + list_display = PageAdmin.list_display + ("parent",) + autocomplete_fields = ("parent",) + + fieldsets = deepcopy(PageAdmin.fieldsets) + fieldsets[1][1]["fields"] += ("parent",) def get_changeform_initial_data(self, request): data = super().get_changeform_initial_data(request) @@ -58,45 +93,22 @@ class BasePageAdmin(admin.ModelAdmin): data["parent"] = filters.get("parent", None) return data - def _get_common_context(self, query, extra_context=None): - extra_context = extra_context or {} + def _get_extra_context(self, query, **extra_context): parent = query.get("parent", None) extra_context["parent"] = None if parent is None else Page.objects.get_subclass(id=parent) - return extra_context + return super()._get_extra_context(query, **extra_context) def render_change_form(self, request, context, *args, **kwargs): if context["original"] and "parent" not in context: context["parent"] = context["original"].parent return super().render_change_form(request, context, *args, **kwargs) - def add_view(self, request, form_url="", extra_context=None): - filters = QueryDict(request.GET.get("_changelist_filters", "")) - extra_context = self._get_common_context(filters, extra_context) - return super().add_view(request, form_url, extra_context) - - def changelist_view(self, request, extra_context=None): - extra_context = self._get_common_context(request.GET, extra_context) - return super().changelist_view(request, extra_context) - - -class PageAdmin(BasePageAdmin): - change_list_template = "admin/aircox/page_change_list.html" - - list_display = BasePageAdmin.list_display + ("category",) - list_editable = BasePageAdmin.list_editable + ("category",) - list_filter = BasePageAdmin.list_filter + ("category", "pub_date") - search_fields = BasePageAdmin.search_fields + ("category__title",) - fieldsets = deepcopy(BasePageAdmin.fieldsets) - - fieldsets[0][1]["fields"].insert(fieldsets[0][1]["fields"].index("slug") + 1, "category") - fieldsets[1][1]["fields"] += ("featured", "allow_comments") - @admin.register(StaticPage) class StaticPageAdmin(BasePageAdmin): list_display = BasePageAdmin.list_display + ("attach_to",) + list_editable = BasePageAdmin.list_editable + ("attach_to",) fieldsets = deepcopy(BasePageAdmin.fieldsets) - fieldsets[1][1]["fields"] += ("attach_to",) @@ -105,6 +117,7 @@ class CommentAdmin(admin.ModelAdmin): list_display = ("page_title", "date", "nickname") list_filter = ("date",) search_fields = ("page__title", "nickname") + readonly_fields = ("page",) def page_title(self, obj): return obj.page.title diff --git a/aircox/admin/program.py b/aircox/admin/program.py index 72873bf..86bfeff 100644 --- a/aircox/admin/program.py +++ b/aircox/admin/program.py @@ -6,7 +6,10 @@ from .page import PageAdmin from .schedule import ScheduleInline -__all__ = ("ProgramAdmin", "StreamInline", "StreamAdmin") +__all__ = ( + "ProgramAdmin", + "StreamInline", +) class StreamInline(admin.TabularInline): @@ -27,6 +30,7 @@ class ProgramAdmin(PageAdmin): list_filter = PageAdmin.list_filter + ("station", "active") prepopulated_fields = {"slug": ("title",)} search_fields = ("title",) + ordering = ("title",) inlines = [ScheduleInline, StreamInline] @@ -42,8 +46,3 @@ class ProgramAdmin(PageAdmin): ) ] return fields - - -@admin.register(Stream) -class StreamAdmin(admin.ModelAdmin): - list_display = ("id", "program", "delay", "begin", "end") diff --git a/aircox/admin/schedule.py b/aircox/admin/schedule.py index 214e9d0..7fdbf41 100644 --- a/aircox/admin/schedule.py +++ b/aircox/admin/schedule.py @@ -22,6 +22,7 @@ class ScheduleInline(admin.TabularInline): model = Schedule form = ScheduleInlineForm readonly_fields = ("timezone",) + autocomplete_fields = ("initial",) extra = 1 @@ -46,7 +47,10 @@ class ScheduleAdmin(admin.ModelAdmin): "duration", "initial", ] - list_editable = ["time", "duration", "initial"] + list_editable = ("time", "duration", "initial") + autocomplete_fields = ("initial",) + search_fields = ("program__title",) + ordering = ("program__title", "initial", "date") def get_readonly_fields(self, request, obj=None): if obj: diff --git a/aircox/admin/sound.py b/aircox/admin/sound.py index 74ea9fb..a8250a3 100644 --- a/aircox/admin/sound.py +++ b/aircox/admin/sound.py @@ -9,7 +9,6 @@ from ..models import Sound, Track class TrackInline(admin.TabularInline): - template = "admin/aircox/playlist_inline.html" model = Track extra = 0 fields = ("position", "artist", "title", "tags", "album", "year", "info") @@ -25,15 +24,16 @@ class SoundTrackInline(TrackInline): class SoundInline(admin.TabularInline): model = Sound fields = [ - "type", "name", "audio", "duration", + "broadcast", "is_good_quality", "is_public", "is_downloadable", + "is_removed", ] - readonly_fields = ["type", "audio", "duration", "is_good_quality"] + readonly_fields = ["broadcast", "audio", "duration", "is_good_quality"] extra = 0 max_num = 0 @@ -42,9 +42,6 @@ class SoundInline(admin.TabularInline): audio.short_description = _("Audio") - def get_queryset(self, request): - return super().get_queryset(request).available() - @admin.register(Sound) class SoundAdmin(SortableAdminBase, admin.ModelAdmin): @@ -52,20 +49,31 @@ class SoundAdmin(SortableAdminBase, admin.ModelAdmin): list_display = [ "id", "name", - "related", - "type", + # "related", + "broadcast", "duration", "is_public", "is_good_quality", "is_downloadable", "audio", ] - list_filter = ("type", "is_good_quality", "is_public") + list_filter = ("broadcast", "is_good_quality", "is_public") list_editable = ["name", "is_public", "is_downloadable"] - search_fields = ["name", "program__title"] + search_fields = ["name", "program__title", "file"] + autocomplete_fields = ("program",) fieldsets = [ - (None, {"fields": ["name", "file", "type", "program", "episode"]}), + ( + None, + { + "fields": [ + "name", + "file", + "broadcast", + "program", + ] + }, + ), ( None, { @@ -79,21 +87,19 @@ class SoundAdmin(SortableAdminBase, admin.ModelAdmin): }, ), ] - readonly_fields = ("file", "duration", "type") + readonly_fields = ("file", "duration", "is_removed") inlines = [SoundTrackInline] def related(self, obj): - # TODO: link to episode or program edit - return obj.episode.title if obj.episode else obj.program.title if obj.program else "" + # # TODO: link to episode or program edit + return obj.program.title if obj.program else "" - related.short_description = _("Program / Episode") + # return obj.episode.title if obj.episode else obj.program.title if obj.program else "" + + related.short_description = _("Program") def audio(self, obj): - return ( - mark_safe(''.format(obj.file.url)) - if obj.type != Sound.TYPE_REMOVED - else "" - ) + return mark_safe(''.format(obj.file.url)) if not obj.is_removed else "" audio.short_description = _("Audio") diff --git a/aircox/admin_site.py b/aircox/admin_site.py deleted file mode 100644 index 2abd96e..0000000 --- a/aircox/admin_site.py +++ /dev/null @@ -1,67 +0,0 @@ -from django.contrib import admin -from django.urls import include, path, reverse -from django.utils.translation import gettext_lazy as _ -from rest_framework.routers import DefaultRouter - -from . import models -from .views.admin import StatisticsView - -__all__ = ["AdminSite"] - - -class AdminSite(admin.AdminSite): - extra_urls = None - tools = [ - (_("Statistics"), "admin:tools-stats"), - ] - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.router = DefaultRouter() - self.extra_urls = [] - self.tools = type(self).tools.copy() - - def each_context(self, request): - context = super().each_context(request) - context.update( - { - # all programs - "programs": models.Program.objects.active().values("pk", "title").order_by("title"), - # today's diffusions - "diffusions": models.Diffusion.objects.date().order_by("start").select_related("episode"), - # TODO: only for dashboard - # last comments - "comments": models.Comment.objects.order_by("-date").select_related("page")[0:10], - "latests": models.Page.objects.select_subclasses().order_by("-pub_date")[0:10], - } - ) - return context - - def get_urls(self): - urls = ( - [ - path("api/", include((self.router.urls, "api"))), - path( - "tools/statistics/", - self.admin_view(StatisticsView.as_view()), - name="tools-stats", - ), - path( - "tools/statistics//", - self.admin_view(StatisticsView.as_view()), - name="tools-stats", - ), - ] - + self.extra_urls - + super().get_urls() - ) - return urls - - def get_tools(self): - return [(label, reverse(url)) for label, url in self.tools] - - def route_view(self, url, view, name, admin_view=True, label=None): - self.extra_urls.append(path(url, self.admin_view(view) if admin_view else view, name=name)) - - if label: - self.tools.append((label, "admin:" + name)) diff --git a/aircox/apps.py b/aircox/apps.py index bc0d7a6..cc3095a 100755 --- a/aircox/apps.py +++ b/aircox/apps.py @@ -1,11 +1,6 @@ from django.apps import AppConfig -from django.contrib.admin.apps import AdminConfig class AircoxConfig(AppConfig): name = "aircox" verbose_name = "Aircox" - - -class AircoxAdminConfig(AdminConfig): - default_site = "aircox.admin_site.AdminSite" diff --git a/aircox/conf.py b/aircox/conf.py index c54f8be..f3d6a9e 100755 --- a/aircox/conf.py +++ b/aircox/conf.py @@ -86,8 +86,8 @@ class Settings(BaseSettings): # TODO include content_type in order to avoid clash with potential # extra applications # aircox - "change_program", - "change_episode", + "view_program", + "view_episode", "change_diffusion", "add_comment", "change_comment", @@ -140,7 +140,7 @@ class Settings(BaseSettings): """In days, minimal age of a log before it is archived.""" # --- Sounds - SOUND_ARCHIVES_SUBDIR = "archives" + SOUND_BROADCASTS_SUBDIR = "archives" """Sub directory used for the complete episode sounds.""" SOUND_EXCERPTS_SUBDIR = "excerpts" """Sub directory used for the excerpts of the episode.""" @@ -176,5 +176,8 @@ class Settings(BaseSettings): IMPORT_PLAYLIST_CSV_TEXT_QUOTE = '"' """Text delimiter of csv text files.""" + ALLOW_COMMENTS = True + """Allow comments.""" + settings = Settings("AIRCOX") diff --git a/aircox/context_processors/__init__.py b/aircox/context_processors/__init__.py new file mode 100644 index 0000000..7147c88 --- /dev/null +++ b/aircox/context_processors/__init__.py @@ -0,0 +1,4 @@ +def station(request): + station = request.station + audio_streams = station.streams if station else None + return {"station": station, "audio_streams": audio_streams} diff --git a/aircox/controllers/sound_file.py b/aircox/controllers/sound_file.py index 752da3e..2cf8821 100644 --- a/aircox/controllers/sound_file.py +++ b/aircox/controllers/sound_file.py @@ -21,23 +21,18 @@ parameters given by the setting SOUND_QUALITY. This script requires Sox (and soxi). """ import logging -import os -import re -from datetime import date -import mutagen from django.conf import settings as conf -from django.utils import timezone as tz -from django.utils.translation import gettext as _ -from aircox import utils -from aircox.models import Program, Sound, Track +from aircox.models import Program, Sound, EpisodeSound -from .playlist_import import PlaylistImport logger = logging.getLogger("aircox.commands") +__all__ = ("SoundFile",) + + class SoundFile: """Handle synchronisation between sounds on files and database.""" @@ -61,153 +56,40 @@ class SoundFile: def sync(self, sound=None, program=None, deleted=False, keep_deleted=False, **kwargs): """Update related sound model and save it.""" if deleted: - return self._on_delete(self.path, keep_deleted) + self.sound = self._on_delete(self.path, keep_deleted) + return self.sound - # FIXME: sound.program as not null - if not program: - program = Program.get_from_path(self.path) - logger.debug('program from path "%s" -> %s', self.path, program) - kwargs["program_id"] = program.pk + program = sound and sound.program or Program.get_from_path(self.path) + if program: + kwargs["program_id"] = program.pk - if sound: - created = False - else: + created = False + if not sound: sound, created = Sound.objects.get_or_create(file=self.sound_path, defaults=kwargs) self.sound = sound - self.path_info = self.read_path(self.path) - - sound.program = program - if created or sound.check_on_file(): - sound.name = self.path_info.get("name") - self.info = self.read_file_info() - if self.info is not None: - sound.duration = utils.seconds_to_time(self.info.info.length) - - # check for episode - if sound.episode is None and "year" in self.path_info: - sound.episode = self.find_episode(sound, self.path_info) + sound.sync_fs(on_update=True, find_playlist=True) sound.save() - # check for playlist - self.find_playlist(sound) + if not sound.episodesound_set.all().exists(): + self.create_episode_sound(sound) return sound + def create_episode_sound(self, sound): + episode = sound.find_episode() + if episode: + # FIXME: position from name + item = EpisodeSound( + episode=episode, sound=sound, position=episode.episodesound_set.all().count(), broadcast=sound.broadcast + ) + item.save() + def _on_delete(self, path, keep_deleted): - # TODO: remove from db on delete + sound = None if keep_deleted: - sound = Sound.objects.path(self.path).first() - if sound: - if keep_deleted: - sound.type = sound.TYPE_REMOVED - sound.check_on_file() - sound.save() - return sound - else: - Sound.objects.path(self.path).delete() - - def read_path(self, path): - """Parse path name returning dictionary of extracted info. It can - contain: - - - `year`, `month`, `day`: diffusion date - - `hour`, `minute`: diffusion time - - `n`: sound arbitrary number (used for sound ordering) - - `name`: cleaned name extracted or file name (without extension) - """ - basename = os.path.basename(path) - basename = os.path.splitext(basename)[0] - reg_match = self._path_re.search(basename) - if reg_match: - info = reg_match.groupdict() - for k in ("year", "month", "day", "hour", "minute", "n"): - if info.get(k) is not None: - info[k] = int(info[k]) - - name = info.get("name") - info["name"] = name and self._into_name(name) or basename - else: - info = {"name": basename} - return info - - _path_re = re.compile( - "^(?P[0-9]{4})(?P[0-9]{2})(?P[0-9]{2})" - "(_(?P[0-9]{2})h(?P[0-9]{2}))?" - "(_(?P[0-9]+))?" - "_?[ -]*(?P.*)$" - ) - - def _into_name(self, name): - name = name.replace("_", " ") - return " ".join(r.capitalize() for r in name.split(" ")) - - def read_file_info(self): - """Read file information and metadata.""" - try: - if os.path.exists(self.path): - return mutagen.File(self.path) - except Exception: - pass - return None - - def find_episode(self, sound, path_info): - """For a given program, check if there is an initial diffusion to - associate to, using the date info we have. Update self.sound and save - it consequently. - - We only allow initial diffusion since there should be no rerun. - """ - program, pi = sound.program, path_info - if "year" not in pi or not sound or sound.episode: - return None - - year, month, day = pi.get("year"), pi.get("month"), pi.get("day") - if pi.get("hour") is not None: - at = tz.datetime(year, month, day, pi.get("hour", 0), pi.get("minute", 0)) - at = tz.make_aware(at) - else: - at = date(year, month, day) - - diffusion = program.diffusion_set.at(at).first() - if not diffusion: - return None - - logger.debug("%s <--> %s", sound.file.name, str(diffusion.episode)) - return diffusion.episode - - def find_playlist(self, sound=None, use_meta=True): - """Find a playlist file corresponding to the sound path, such as: - my_sound.ogg => my_sound.csv. - - Use sound's file metadata if no corresponding playlist has been - found and `use_meta` is True. - """ - if sound is None: - sound = self.sound - if sound.track_set.count() > 1: - return - - # import playlist - path_noext, ext = os.path.splitext(self.sound.file.path) - path = path_noext + ".csv" - if os.path.exists(path): - PlaylistImport(path, sound=sound).run() - # use metadata - elif use_meta: - if self.info is None: - self.read_file_info() - if self.info and self.info.tags: - tags = self.info.tags - title, artist, album, year = tuple( - t and ", ".join(t) for t in (tags.get(k) for k in ("title", "artist", "album", "year")) - ) - title = title or (self.path_info and self.path_info.get("name")) or os.path.basename(path_noext) - info = "{} ({})".format(album, year) if album and year else album or year or "" - track = Track( - sound=sound, - position=int(tags.get("tracknumber", 0)), - title=title, - artist=artist or _("unknown"), - info=info, - ) - track.save() + if sound := Sound.objects.path(self.path).first(): + sound.is_removed = True + sound.save(sync=False) + elif sound := Sound.objects.path(self.path): + sound.delete() + return sound diff --git a/aircox/controllers/sound_monitor.py b/aircox/controllers/sound_monitor.py index b7116a7..70a0880 100644 --- a/aircox/controllers/sound_monitor.py +++ b/aircox/controllers/sound_monitor.py @@ -105,8 +105,7 @@ class MoveTask(Task): def __call__(self, event, **kw): sound = Sound.objects.filter(file=event.src_path).first() if sound: - kw["sound"] = sound - kw["path"] = event.src_path + kw = {**kw, "sound": sound, "path": event.src_path} else: kw["path"] = event.dest_path return super().__call__(event, **kw) @@ -214,15 +213,15 @@ class SoundMonitor: logger.info(f"#{program.id} {program.title}") self.scan_for_program( program, - settings.SOUND_ARCHIVES_SUBDIR, + settings.SOUND_BROADCASTS_SUBDIR, logger=logger, - type=Sound.TYPE_ARCHIVE, + broadcast=True, ) self.scan_for_program( program, settings.SOUND_EXCERPTS_SUBDIR, logger=logger, - type=Sound.TYPE_EXCERPT, + broadcast=False, ) dirs.append(program.abspath) return dirs @@ -234,12 +233,12 @@ class SoundMonitor: if not program.ensure_dir(subdir): return - subdir = os.path.join(program.abspath, subdir) + abs_subdir = os.path.join(program.abspath, subdir) sounds = [] # sounds in directory - for path in os.listdir(subdir): - path = os.path.join(subdir, path) + for path in os.listdir(abs_subdir): + path = os.path.join(abs_subdir, path) if not path.endswith(settings.SOUND_FILE_EXT): continue @@ -248,14 +247,14 @@ class SoundMonitor: sounds.append(sound_file.sound.pk) # sounds in db & unchecked - sounds = Sound.objects.filter(file__startswith=subdir).exclude(pk__in=sounds) + sounds = Sound.objects.filter(file__startswith=program.path).exclude(pk__in=sounds) self.check_sounds(sounds, program=program) def check_sounds(self, qs, **sync_kwargs): """Only check for the sound existence or update.""" # check files for sound in qs: - if sound.check_on_file(): + if sound.sync_fs(on_update=True): SoundFile(sound.file.path).sync(sound=sound, **sync_kwargs) _running = False @@ -267,15 +266,15 @@ class SoundMonitor: """Run in monitor mode.""" with futures.ThreadPoolExecutor() as pool: archives_handler = MonitorHandler( - settings.SOUND_ARCHIVES_SUBDIR, + settings.SOUND_BROADCASTS_SUBDIR, pool, - type=Sound.TYPE_ARCHIVE, + broadcast=True, logger=logger, ) excerpts_handler = MonitorHandler( settings.SOUND_EXCERPTS_SUBDIR, pool, - type=Sound.TYPE_EXCERPT, + broadcast=False, logger=logger, ) diff --git a/aircox/filters.py b/aircox/filters.py index 48db0d5..ffa4e7b 100644 --- a/aircox/filters.py +++ b/aircox/filters.py @@ -1,16 +1,30 @@ -import django_filters as filters +from django.contrib.auth.models import User +from django.db.models import Q from django.utils.translation import gettext_lazy as _ +import django_filters as filters -from .models import Episode, Page +from . import models + + +__all__ = ( + "PageFilters", + "EpisodeFilters", + "ImageFilterSet", + "SoundFilterSet", + "TrackFilterSet", + "UserFilterSet", + "GroupFilterSet", + "UserGroupFilterSet", +) class PageFilters(filters.FilterSet): q = filters.CharFilter(method="search_filter", label=_("Search")) class Meta: - model = Page + model = models.Page fields = { - "category__id": ["in"], + "category__id": ["in", "exact"], "pub_date": ["exact", "gte", "lte"], } @@ -22,10 +36,81 @@ class EpisodeFilters(PageFilters): podcast = filters.BooleanFilter(method="podcast_filter", label=_("Podcast")) class Meta: - model = Episode + model = models.Episode fields = PageFilters.Meta.fields.copy() def podcast_filter(self, queryset, name, value): if value: return queryset.filter(sound__is_public=True).distinct() return queryset.filter(sound__isnull=True) + + +class ImageFilterSet(filters.FilterSet): + search = filters.CharFilter(field_name="search", method="search_filter") + + def search_filter(self, queryset, name, value): + return queryset.filter(original_filename__icontains=value) + + +class SoundFilterSet(filters.FilterSet): + station = filters.NumberFilter(field_name="program__station__id") + program = filters.NumberFilter(field_name="program_id") + # episode = filters.NumberFilter(field_name="episode_id") + search = filters.CharFilter(field_name="search", method="search_filter") + + class Meta: + model = models.Sound + fields = { + # "episode": ["in", "exact", "isnull"], + } + + def search_filter(self, queryset, name, value): + return queryset.search(value) + + +class TrackFilterSet(filters.FilterSet): + artist = filters.CharFilter(field_name="artist", lookup_expr="icontains") + album = filters.CharFilter(field_name="album", lookup_expr="icontains") + title = filters.CharFilter(field_name="title", lookup_expr="icontains") + + +class UserFilterSet(filters.FilterSet): + search = filters.CharFilter(field_name="search", method="search_filter") + in_group = filters.NumberFilter(field_name="in_group", method="in_group_filter") + not_in_group = filters.NumberFilter(field_name="not_in_group", method="not_in_group_filter") + + def in_group_filter(self, queryset, name, value): + return queryset.filter(groups__in=[value]) + + def not_in_group_filter(self, queryset, name, value): + return queryset.exclude(groups__in=[value]) + + def search_filter(self, queryset, name, value): + return queryset.filter( + Q(username__icontains=value) | Q(first_name__icontains=value) | Q(last_name__icontains=value) + ) + + +class GroupFilterSet(filters.FilterSet): + search = filters.CharFilter(field_name="search", method="search_filter") + no_user = filters.NumberFilter(field_name="no_user", method="no_user_filter") + + def no_user_filter(self, queryset, name, value): + return queryset.exclude(user__in=[value]) + + def search_filter(self, queryset, name, value): + return queryset.filter(Q(name__icontains=value) | Q(program__title__icontains=value)) + + +class UserGroupFilterSet(filters.FilterSet): + class Meta: + model = User.groups.through + fields = ["group", "user"] + + def filter_queryset(self, queryset): + queryset = super().filter_queryset(queryset) + if self.form.cleaned_data.get("user"): + queryset = queryset.order_by("group__name") + elif self.form.cleaned_data.get("group"): + queryset = queryset.order_by("user__first_name") + return queryset diff --git a/aircox/forms.py b/aircox/forms.py deleted file mode 100644 index 3984695..0000000 --- a/aircox/forms.py +++ /dev/null @@ -1,18 +0,0 @@ -from django import forms -from django.forms import ModelForm - -from .models import Comment - - -class CommentForm(ModelForm): - nickname = forms.CharField() - email = forms.EmailField(required=False) - content = forms.CharField(widget=forms.Textarea()) - - nickname.widget.attrs.update({"class": "input"}) - email.widget.attrs.update({"class": "input"}) - content.widget.attrs.update({"class": "textarea"}) - - class Meta: - model = Comment - fields = ["nickname", "email", "content"] diff --git a/aircox/forms/__init__.py b/aircox/forms/__init__.py new file mode 100644 index 0000000..f0cd11c --- /dev/null +++ b/aircox/forms/__init__.py @@ -0,0 +1,23 @@ +from . import widgets + +from .episode import EpisodeForm, EpisodeSoundFormSet +from .program import ProgramForm +from .page import CommentForm, ImageForm, PageForm, ChildPageForm +from .sound import SoundForm, SoundCreateForm +from .track import TrackFormSet + + +__all__ = ( + widgets, + # ---- forms + EpisodeForm, + EpisodeSoundFormSet, + ProgramForm, + CommentForm, + ImageForm, + PageForm, + ChildPageForm, + SoundForm, + SoundCreateForm, + TrackFormSet, +) diff --git a/aircox/forms/episode.py b/aircox/forms/episode.py new file mode 100644 index 0000000..b288656 --- /dev/null +++ b/aircox/forms/episode.py @@ -0,0 +1,34 @@ +from django import forms +from django.forms.models import modelformset_factory + +from aircox import models +from .page import ChildPageForm + + +__all__ = ("EpisodeForm", "EpisodeSoundFormSet") + + +class EpisodeForm(ChildPageForm): + class Meta: + model = models.Episode + fields = ChildPageForm.Meta.fields + + +EpisodeSoundFormSet = modelformset_factory( + models.EpisodeSound, + fields=( + "position", + "episode", + "sound", + "broadcast", + ), + widgets={ + "broadcast": forms.CheckboxInput(), + "episode": forms.HiddenInput(), + # "sound": forms.HiddenInput(), + "position": forms.HiddenInput(), + }, + can_delete=True, + extra=0, +) +"""Formset used in EpisodeUpdateView.""" diff --git a/aircox/forms/page.py b/aircox/forms/page.py new file mode 100644 index 0000000..8af0aa9 --- /dev/null +++ b/aircox/forms/page.py @@ -0,0 +1,37 @@ +from django import forms + + +from aircox import models + + +__all__ = ("CommentForm", "ImageForm", "PageForm", "ChildPageForm") + + +class CommentForm(forms.ModelForm): + nickname = forms.CharField() + email = forms.EmailField(required=False) + content = forms.CharField(widget=forms.Textarea()) + + nickname.widget.attrs.update({"class": "input"}) + email.widget.attrs.update({"class": "input"}) + content.widget.attrs.update({"class": "textarea"}) + + class Meta: + model = models.Comment + fields = ["nickname", "email", "content"] + + +class ImageForm(forms.Form): + file = forms.ImageField() + + +class PageForm(forms.ModelForm): + class Meta: + fields = ("title", "category", "status", "cover", "content") + model = models.Page + + +class ChildPageForm(forms.ModelForm): + class Meta: + fields = ("title", "status", "cover", "content") + model = models.Page diff --git a/aircox/forms/program.py b/aircox/forms/program.py new file mode 100644 index 0000000..65d349b --- /dev/null +++ b/aircox/forms/program.py @@ -0,0 +1,11 @@ +from aircox import models +from .page import PageForm + + +__all__ = ("ProgramForm",) + + +class ProgramForm(PageForm): + class Meta: + fields = PageForm.Meta.fields + model = models.Program diff --git a/aircox/forms/sound.py b/aircox/forms/sound.py new file mode 100644 index 0000000..eb7388b --- /dev/null +++ b/aircox/forms/sound.py @@ -0,0 +1,26 @@ +from django import forms + +from aircox import models + + +__all__ = ( + "SoundForm", + "SoundCreateForm", +) + + +class SoundForm(forms.ModelForm): + """SoundForm used in EpisodeUpdateView.""" + + class Meta: + model = models.Sound + fields = ["name", "program", "file", "broadcast", "duration", "is_public", "is_downloadable"] + + +class SoundCreateForm(forms.ModelForm): + """SoundForm used in EpisodeUpdateView.""" + + class Meta: + model = models.Sound + fields = ["name", "program", "file", "broadcast", "is_public", "is_downloadable"] + widgets = {"program": forms.HiddenInput()} diff --git a/aircox/forms/track.py b/aircox/forms/track.py new file mode 100644 index 0000000..ecdf3ea --- /dev/null +++ b/aircox/forms/track.py @@ -0,0 +1,23 @@ +from django import forms +from django.forms.models import modelformset_factory + +from aircox import models + + +__all__ = ("TrackFormSet",) + + +TrackFormSet = modelformset_factory( + models.Track, + fields=[ + "position", + "episode", + "artist", + "title", + "tags", + ], + widgets={"episode": forms.HiddenInput(), "position": forms.HiddenInput()}, + can_delete=True, + extra=0, +) +"""Track formset used in EpisodeUpdateView.""" diff --git a/aircox/forms/widgets.py b/aircox/forms/widgets.py new file mode 100644 index 0000000..149fc39 --- /dev/null +++ b/aircox/forms/widgets.py @@ -0,0 +1,89 @@ +from itertools import chain +from functools import cached_property + +from django import forms, http +from django.urls import reverse + + +__all__ = ( + "VueWidget", + "VueAutoComplete", +) + + +class VueWidget(forms.Widget): + binds = None + """Dict of `{attribute: value}` attrs set as bindings.""" + events = None + """Dict of `{event: value}` attrs set as events.""" + v_model = "" + """ES6 Model instance to bind to (`v-model`).""" + + def __init__(self, *args, binds=None, events=None, v_model=None, **kwargs): + super().__init__(*args, **kwargs) + self.binds = binds or [] + self.events = events or [] + + @cached_property + def vue_attrs(self): + """Dict of Vue specific attributes.""" + binds, events = self.binds, self.events + if isinstance(binds, dict): + binds = binds.items() + if isinstance(events, dict): + events = events.items() + + return dict( + chain( + ((":" + key, value) for key, value in binds), + (("@" + key, value) for key, value in events), + ) + ) + + def build_attrs(self, base_attrs, extra_attrs=None): + extra_attrs = extra_attrs or {} + extra_attrs.update(self.vue_attrs) + return super().build_attrs(base_attrs, extra_attrs) + + +class VueAutoComplete(VueWidget, forms.TextInput): + """Autocomplete Vue component.""" + + template_name = "aircox/widgets/autocomplete.html" + + url: str = "" + """Url to autocomplete API view. + + If it has query parameters, does not generate it based on lookup + (see `get_url()` doc). + """ + lookup: str = "" + """Field name used as lookup (instead as provided one).""" + params: http.QueryDict + + def __init__(self, url_name, *args, lookup=None, params=None, **kwargs): + self.url_name = url_name + self.lookup = lookup + self.params = params + super().__init__(*args, **kwargs) + + def get_context(self, name, value, attrs): + context = super().get_context(name, value, attrs) + context["url"] = self.get_url(name, self.lookup, self.params) + return context + + def get_url(self, name, lookup, params=None): + """Return url to autocomplete API. When query parameters are not + provided generate them using `?{lookup}=${query}&field={name}` (where + `${query} is Vue `a-autocomplete` specific). + + :param str name: field name (not used by default) + :param str lookup: lookup query parameter + :param http.QueryDict params: additional mutable parameter + """ + url = reverse(self.url_name) + query = http.QueryDict(mutable=True) + if params: + query.update(params) + query.update({lookup: "${query}"}) + return f"{url}?{query.urlencode()}" diff --git a/aircox/locale/fr/LC_MESSAGES/django.mo b/aircox/locale/fr/LC_MESSAGES/django.mo index 53652d05d654959492d66d915feff2ac90e08b5e..9b001f3ac6534db7a42cc130700da7426b8d7c64 100644 GIT binary patch literal 17225 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 B*_;3X literal 14556 zcmb7~3!EKQeaBBC8VrQD#){NIh`Yh;=E*B0N=O0$6IikvDhh_#duMlt+&gobnVZe> z5GkM_pn}@?ZVX7X_yQkLpHS9_MT-<#uqshR5w#YJA}XT&{?0jb@7*M%?fv9G-FTg|K7opmF zA3O*?0+s*Q@a6E2Q1!j!Wv-sUa~@QCPlS8H)1k(pAF3VaLA7rLD&GZA{kasXe^s3(Y+yK>& z4?yML2Ki@h=c4vJ05yI;gZl0{sCuWdNW~dYUoprEl};e6|%IEq|%2KX(g_s0fK zAD2Rve+HZd2cY!yLa6j>q1tgh)VO{SYP`2Wt&`6{m3NotH=y2s8|wQ9{quLB?BEZf z^zSLC_s>Cw+)O>(wLgIRehyT<3!&;=0yRFTLCxEFP~$h|(=US>pUqJH_?YKCa5m4s zg8F_YlcDl^pxVC(s{EBu-}n3UbD_pLftvq|JgERc-+Fz8mWMx&Hkk|NJ^Ad$__szX7V<=R(y#3^7Tj0afoc zp4UT_w;8IQ55uG3P4ETqdypYAJK%gMBdm420IHm2P7Mb}WP%m*e0xcoNk3oCfuMzfT{6YHtFU!UCQNKMS><_Ud-?a{yF(4}~gcA=LXN zPsz7vy&W>-=3%IIJqy*Z{ZLx#N1(>*XgCcnhwA6+ z;odNWFNWts=|ut6pR3@D;5(tdd!Oe=pvwO^RQo;;HQrx@s_$N?@_!7~&foj=8ArMH z&VtgHW1-f?2~g#{-m?#?z3ZULt3dVhBB=gd2ld?+pMERUxZDlpFTM}eucshGVV;Fd zg*g&sQ@>V2>B(BCd^M=?Z9-JdydR<(=4+6qo1a4Ue}9xz`Da4)FMuzG$H0ByiBR@_ zwND>`>VF2cp56|p!>vC3Q&8XC4RKZGKDaOZB~*RChstkyT)F!|)z<~}-F&F=Sqe2D z>wJ0@s=Nl&xLgNS-px?$|Fq{FQ03hPWk2tQ>hBZ&`Ji5BZ|1=nq@M|;59^`kZwPAM z&WCDu3e~QQq3XX1%71(us{HLxS z`%O^u^H!*Q*T62g*}uQjzrP1+oF0IrnYd zq2}Q&P~}_;)xS?b&Btv}g zqJA}?zP}hA1+RcA_cL%a+z!=_a~HaGT7l|s6V8GcLaoa!Q0=?}sy|d)O!<9k0m z5IzD`-jkkBL*;)Ks@$oI-19Ve5YPKT_2Y1#J_pJVErc4!bx`def*PL$9t=mJ+I0<- zer<&sx355b{|%^epMbB1zwvze(QX`;L499?D)(}za<7Hz*A}RHKMgg$w?mb;9liwK z57oZMq3V4GYP_c$ zb+Ff;aNUUXAbTOwuWumtA@4%2Mb1Osft0^@a`Tb$0lp1+5P1N3H8P5nzueypc*?(M z4)r?+`JBCTms^3x@s-G9h;&mw&A~4b#bMqHUw|BsT!A!^dyw_WG~{$dzi&C1Ue7x` zzvH>X^K$sOzb_%1^=0I{$fhz8{s)pGe?nFupFlc)AL8K`K4Ge-){TDu>|jt$GlF~? z`3&+#t~ku#B}kxLNS zp!tY?OB}4-yTRZ83cdn40l5)5718fU$hVOb5&cd>PD0K{zKJ}GB*=%6n~`P6y~taU znaDpPvyroqmB?j?exF4e$aTo)k*&xAM2lx0*dO^I@-gHONcnrezgZ5yhdhO>Lw=5w zzYYH8445Go`sXV>h3k=zBX35ILki?jM8BUQw;4%ifcubtuNxD7R}BBGpoCvR-+oX=@74dy6MiMteMO<^YbDI zlWMRqs|FWU!?8=0tr&@ddYTj?_VsR(7x0C?T2zee?km0C%~#J;$MY1jtXieLNm!>F zMh{_7WMO3k_w_i5a~_H~P0X^|V6)D(7F5!DJxU64Cu3$=qfz6U6>+5&mE@I6$b%Xs z*e9V`jZ=3G8ntjN%7SJdRf8h6*`h>u8k8&~qwRSktyaQZb?MuH)YAzYf%xELuFRPLqkzUbF17m zbB(8xcDf5HWmQ3%1Yt}uWv0C2*4D&xUJ22ritPvQ(@Im*;$~}QH7<-UttPBo#j zJoDC#o1&GQ!b)LIwpwjYNzJOX9@&el?bKu_HK#S}4NXy%;WVq6mY~rbti=^uWll;F zk=9ZV|C~glt#_02#~Y)L0a=|68ze}Zg_!EXhg@6})`Z!HAk2em77i6=O_Vf)xQOcQ zyWFfvvnU9?>bWOdyG`$`ySKNO*HkZs%ffuboE~i|%;{;_`k;~K1@ko)+0@pw1#uqa zm6530tnqdz%_xE8+{>p)WcpC*fV4)4ZU&)Mccs}yP~HTZ7BZCzw{v=b)CgH~X-08T zNI8`h1*{ZyWkgxVr-M-~9qoPI_jxOWTRo<)Tt$8KidEsKi1hY+nZ7LA7^ls=byK4+ zP7V9AbT|v^ys-@C2BkKYDg9AVupsoY&Cebg)6w`4Zw7+X-;9ET~DQlyOTY2S; z8Z@iB;p)I%t&Qr;BEZPHt24jSQ2RP({`y0*=+dsuKFP9HFu!rqKhwe+w3uHCox=bh^4QtN21>5r2N*R+{b-IaD|P3IWc z+zf;a@xVw}1S4Ui5hduL9T9H=>GHq`c1Bv7q{Rp`;+ANR#s+#L#UB{q^=K4r*yY=p zH9x?G;_40V@NH&5`pq@1QFvN}H8Wt{gS`f5P;A|Ud)&o<+2q`;W0Y%VU5;fuD~?73 zK8noQjIY7!U-j!&(qyYO=bsDM2`hIY9GiaiMDG1bMSEA~PUAfu)r#ofE`E z>N3_Y46>*mZ<-SfMPbp*qI^ye6_s8STJOcQF?lTmrRlYmdy6sGlyf(CNnUFXFV^L? zE*19`7DY(iMQZHIE}fAXjE3XHR6-`MhE?FsdYds*F0-;iR3p;7NPW^#mZj>jq`>qkR2|$Bf4rUkeSQKEQ^L?lAAEq zbTp}@VKp3NlATShHodPeZ_0+`Rvja`6AgQ-MYuaEOyVK$158v8ndb=Ijl8?@%;%OV z`Q)*UHdUgmQ5Zj8Rl~+AGDBH(K{HA!V`ex_tHA}$uof3%Ca%(QYGdQzw@jp8yUyk%+{hql*iUecqA}El-rK=N-}S`I4aLKOMnl@3 zh0@z9pES@(vicqyddk?^>k{eCVqMT#=D2CJfvdBmtfJr)3RXR}jIxo^jM{BXZYK56 zc1*iLlf;z`?na}i=45qr*1H7UUAgzPB^vfntZs~suC@!}7L^L|RTYjWnqgs0KG#!8m&tgzL7pBX@Se?gg2_Y~*E{6?@ zNG^}*$m$2C?P=6v=SG>dEE;as!Yn8kHVMue*ePl~xE{7%mUae{ikDR>amol)X{Ia$m>%xaPHQimPy;^zWjuCbzC)392;guX8 z{)R0`G1iDoGue<}E={wc_ko6vv25+lsfs;XRr-q3l)Y~4%sGh|?FVh^d^buDhP_O1 z!<SV4FBcZHy@{&GMkzPm-p%m3_A9D9D;DZT%^vWzN(me_m_loz!Q( zVjIKO$xV%R%2?=LGp3DzwWHcTo{u-{IknnzmUHflgJp46NjC-adgo7H8)>QctjUMt zYR^f{;k;)cT^vmBJELbUn^)}xdR8zwi-QI8<{#5De;)sW`Nu4tH}5s`7EITPrH2hk zk~4v6M!{h=mXn+eH?dSb0}O9j8-vRH`<&eKs+Exn*POa$WxIj%d*@AG?mTVJ0Q$0+ z{F{ooOjDekAT`LaO-t7eoZNF3HPZ=usz#ymzt&qfO9@ed0Z7Ue6H8<^43^|z~+;`2_!hBGcHvqlCI##)Y6gF2p z?Bf&@|gD*a2_bMl*4> z+Xx11JX%d*6R+}!l_gYVzv5iqv9UC#WY~!)7)di**|H?z!0owxu4acC zWj5}yYlF*{w3^hZAQR)RnG9Jg5W-+89)iC!%PN&-#Ll))o9b#8_TE0)s0bl3Dx27% z$+hldMU*A+#5Oe4TUvLPAtppRMs2bZcjQhq9hAn1JYNy5L1#E?I=c`b;e;A6b?kEz zXKdRdKr6j1I#H`ZDj+M3BS~}hnF)y3J z9zqtS(Zs>`v)qw(Pzi&OqZ5T(XhCbu@-78&t=$cj@g=7S z8iI0N`W$=mD?ZC60@pd+GwZB_w@cZamBw->E)s=%k@wQJbcnNmXZ*>>r4nnGwURix zVV%@>>&|R5=dj_E-R4AN=cyH!%DduPkauy=#kO{_c71e8d53g*Wyi{qDaP2jNL3lA zYO+C<%33ekVHtBmRR5o#6`itCD@vN!!lB6?A)FD!Wel=^3gucS#uc?H_ZuDc8MU_C3}%R*kr3pM~3oh+oj>9Z}f~8+K3NFHs00teVm9j^$xNfbj~=P(z?3Bn1i8qQ0YXqgB$14Oze#?cxDeFx>h{h^pfm%%P_ z`vG)EV|0k~i~e?kHll;Lc9X{}x^{=>kgl<5*=|;?UDfj8y9syUGu$X>vaoj}TByEK zY^B@H;Ds52GSX+0(W=R(1qD>}q22qo{RnYMPU%FvH~?CAipDT;aIZEdAEwI6|W}VVm zB~;3W9$m--ER+=ddB!10>!y)M&1z~ALSwd}S)J+AZl~pRS@~|DWD-@h!e!V$)SYY2 znKpKjGGn*h|68~W??P*hgN=gAlx0za%{+EZqk<M(Ro6w5A0e-<$=bQTdh zYdLC^oPnb$yetD~jI;}&MV%2e>y$ffaZArw5Z-$Q{C&bk$h0@Jidt*NJ(ZXcvDQ5vm|uS~~%8CqkOz1>Lk7Ynwu))>hl zvd`MxNMM2OVW72!P43QD6vdy1%iXD|_P{sx8Oxr&`>;LT{~8UFSnkuGP|*`@GpN zVIAeJwpy<@Mu~hqkuB?EY~2&Z%qA%{w2k=+q+prN~)ix2L3e`f4@<-L3$Qv4^I1 c39e9io5|x;&NV)X^A>iHKe)Lqm5b{A59mPP4FCWD diff --git a/aircox/locale/fr/LC_MESSAGES/django.po b/aircox/locale/fr/LC_MESSAGES/django.po index 97efa4a..34264f2 100644 --- a/aircox/locale/fr/LC_MESSAGES/django.po +++ b/aircox/locale/fr/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Aircox 0.1\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-09-12 18:48+0000\n" +"POT-Creation-Date: 2024-04-28 18:57+0000\n" "PO-Revision-Date: 2016-10-10 16:00+02\n" "Last-Translator: Aarys\n" "Language-Team: Aircox's translators team\n" @@ -18,670 +18,630 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" -#: aircox/admin/diffusion.py:26 aircox/models/diffusion.py:130 -#: aircox/models/log.py:80 +#: admin/diffusion.py:26 models/diffusion.py:126 models/log.py:80 msgid "start" msgstr "début" -#: aircox/admin/diffusion.py:31 aircox/models/diffusion.py:131 -#: aircox/models/program.py:186 +#: admin/diffusion.py:31 models/diffusion.py:127 models/program.py:172 msgid "end" msgstr "fin" -#: aircox/admin/filters.py:27 aircox/tests/admin/test_filters.py:56 +#: admin/filters.py:23 tests/admin/test_filters.py:54 msgid "Exact" msgstr "Exact" -#: aircox/admin/filters.py:28 aircox/tests/admin/test_filters.py:57 +#: admin/filters.py:24 tests/admin/test_filters.py:55 msgid "Since" msgstr "Depuis" -#: aircox/admin/filters.py:29 aircox/tests/admin/test_filters.py:58 +#: admin/filters.py:25 tests/admin/test_filters.py:56 msgid "Until" msgstr "Jusque" -#: aircox/admin/filters.py:33 aircox/tests/admin/test_filters.py:55 +#: admin/filters.py:28 models/page.py:289 tests/admin/test_filters.py:53 msgid "None" msgstr "Aucun" -#: aircox/admin/filters.py:49 +#: admin/filters.py:39 msgid "Any" msgstr "Tout" -#: aircox/admin/page.py:43 +#: admin/page.py:44 msgid "Publication Settings" -msgstr "Paramètre de la publication" +msgstr "Paramètres de la publication" -#: aircox/admin/program.py:24 aircox/models/schedule.py:67 +#: admin/program.py:27 models/schedule.py:72 msgid "Schedule" msgstr "Horaire" -#: aircox/admin/program.py:38 +#: admin/program.py:42 msgid "Program Settings" msgstr "Paramètres de l'émission" -#: aircox/admin/schedule.py:33 aircox/models/program.py:115 +#: admin/schedule.py:34 admin/sound.py:99 models/file.py:45 +#: models/program.py:120 msgid "Program" msgstr "Émission" -#: aircox/admin/schedule.py:38 +#: admin/schedule.py:39 msgid "Day" msgstr "Jour" -#: aircox/admin/sound.py:45 aircox/admin/sound.py:106 +#: admin/sound.py:43 admin/sound.py:104 msgid "Audio" msgstr "Audio" -#: aircox/admin/sound.py:97 -msgid "Program / Episode" -msgstr "Émission / Épisode" - -#: aircox/admin/sound.py:143 -#: aircox/templates/admin/aircox/playlist_inline.html:17 -#: aircox/templates/aircox/episode_detail.html:36 +#: admin/sound.py:141 templates/aircox/episode_detail.html:29 +#: templates/aircox/widgets/player.html:23 msgid "Playlist" msgstr "Playlist" -#: aircox/admin/sound.py:146 +#: admin/sound.py:144 msgid "Info" msgstr "Info" -#: aircox/admin/sound.py:160 aircox/models/sound.py:285 +#: admin/sound.py:158 models/track.py:40 msgid "timestamp" msgstr "temps" -#: aircox/admin_site.py:15 aircox/tests/test_admin_site.py:40 -#: aircox/views/admin.py:35 -msgid "Statistics" -msgstr "Statistiques" - -#: aircox/controllers/sound_file.py:233 -msgid "unknown" -msgstr "inconnu" - -#: aircox/filters.py:8 aircox/templates/admin/base.html:90 -#: aircox/templates/admin/base.html:107 aircox/templates/admin/base.html:124 -#: aircox/templates/aircox/base.html:79 -#: aircox/templates/aircox/page_list.html:15 +#: filters.py:22 msgid "Search" msgstr "Chercher" -#: aircox/filters.py:23 +#: filters.py:36 models/episode.py:131 msgid "Podcast" msgstr "Podcast" -#: aircox/models/article.py:15 +#: models/article.py:16 msgid "Article" msgstr "Article" -#: aircox/models/article.py:16 aircox/templates/admin/base.html:86 -#: aircox/templates/aircox/program_detail.html:19 +#: models/article.py:17 msgid "Articles" msgstr "Articles" -#: aircox/models/diffusion.py:108 aircox/models/log.py:82 +#: models/diffusion.py:104 models/log.py:82 msgid "on air" msgstr "à l'antenne" -#: aircox/models/diffusion.py:109 +#: models/diffusion.py:105 msgid "not confirmed" msgstr "non confirmé" -#: aircox/models/diffusion.py:110 aircox/models/log.py:81 +#: models/diffusion.py:106 models/log.py:81 msgid "cancelled" msgstr "annulé" -#: aircox/models/diffusion.py:116 aircox/models/sound.py:114 -#: aircox/models/sound.py:270 +#: models/diffusion.py:112 models/track.py:25 msgid "episode" msgstr "épisode" -#: aircox/models/diffusion.py:121 +#: models/diffusion.py:117 msgid "schedule" msgstr "horaire" -#: aircox/models/diffusion.py:126 aircox/models/log.py:92 -#: aircox/models/sound.py:117 aircox/models/station.py:162 +#: models/diffusion.py:122 models/log.py:92 models/station.py:148 msgid "type" msgstr "type" -#: aircox/models/diffusion.py:143 aircox/models/log.py:131 +#: models/diffusion.py:137 models/log.py:131 msgid "Diffusion" msgstr "Date de diffusion" -#: aircox/models/diffusion.py:144 -#: aircox/templates/aircox/episode_detail.html:55 -#: aircox/templates/aircox/program_detail.html:45 +#: models/diffusion.py:138 msgid "Diffusions" msgstr "Dates de diffusion" -#: aircox/models/diffusion.py:146 +#: models/diffusion.py:139 msgid "edit the diffusions' planification" msgstr "éditer les dates de diffusion" -#: aircox/models/diffusion.py:155 -#: aircox/templates/aircox/episode_detail.html:71 +#: models/diffusion.py:147 msgid "rerun" msgstr "rediffusion" -#: aircox/models/episode.py:48 aircox/templates/admin/aircox/statistics.html:23 +#: models/episode.py:60 templates/aircox/dashboard/statistics.html:29 msgid "Episode" msgstr "Épisode" -#: aircox/models/episode.py:49 aircox/templates/admin/base.html:120 +#: models/episode.py:61 msgid "Episodes" msgstr "Épisodes" -#: aircox/models/log.py:79 -msgid "stop" -msgstr "stop" - -#: aircox/models/log.py:83 aircox/models/sound.py:94 -msgid "other" -msgstr "autre" - -#: aircox/models/log.py:89 aircox/models/page.py:301 -#: aircox/models/program.py:51 aircox/models/station.py:157 -msgid "station" -msgstr "station" - -#: aircox/models/log.py:90 -msgid "related station" -msgstr "station relative" - -#: aircox/models/log.py:93 aircox/models/schedule.py:43 -msgid "date" -msgstr "date" - -#: aircox/models/log.py:100 -msgid "source" -msgstr "source" - -#: aircox/models/log.py:101 -msgid "identifier of the source related to this log" -msgstr "identifiant de la source relative à ce log" - -#: aircox/models/log.py:107 -msgid "comment" -msgstr "commentaire" - -#: aircox/models/log.py:115 aircox/models/sound.py:174 -msgid "Sound" -msgstr "Son" - -#: aircox/models/log.py:123 aircox/models/sound.py:308 -#: aircox/templates/admin/aircox/statistics.html:24 -msgid "Track" -msgstr "Morceau" - -#: aircox/models/log.py:155 -msgid "Log" -msgstr "Log" - -#: aircox/models/log.py:156 -msgid "Logs" -msgstr "Logs" - -#: aircox/models/page.py:34 aircox/models/page.py:305 -#: aircox/models/sound.py:290 -msgid "title" -msgstr "titre" - -#: aircox/models/page.py:35 aircox/models/page.py:93 -#: aircox/models/station.py:34 -msgid "slug" -msgstr "slug" - -#: aircox/models/page.py:38 -msgid "Category" -msgstr "Catégorie" - -#: aircox/models/page.py:39 aircox/templates/aircox/page_list.html:32 -msgid "Categories" -msgstr "Catégories" - -#: aircox/models/page.py:78 -msgid "draft" -msgstr "brouillon" - -#: aircox/models/page.py:79 -msgid "published" -msgstr "publié" - -#: aircox/models/page.py:80 -msgid "trash" -msgstr "corbeille" - -#: aircox/models/page.py:96 -msgid "status" -msgstr "statut" - -#: aircox/models/page.py:102 -msgid "cover" -msgstr "couverture" - -#: aircox/models/page.py:107 aircox/models/page.py:279 -msgid "content" -msgstr "contenu" - -#: aircox/models/page.py:191 -msgid "category" -msgstr "catégorie" - -#: aircox/models/page.py:197 -msgid "publication date" -msgstr "date de publication" - -#: aircox/models/page.py:200 -msgid "featured" -msgstr "en avant" - -#: aircox/models/page.py:204 -msgid "allow comments" -msgstr "autoriser les commentaires" - -#: aircox/models/page.py:211 -msgid "Publication" -msgstr "Publication" - -#: aircox/models/page.py:212 -msgid "Publications" -msgstr "Publications" - -#: aircox/models/page.py:238 -msgid "Home page" -msgstr "Page d'accueil" - -#: aircox/models/page.py:239 -msgid "Diffusions page" -msgstr "Grille horaire" - -#: aircox/models/page.py:240 -msgid "Logs page" -msgstr "Page des logs" - -#: aircox/models/page.py:241 -msgid "Programs list" -msgstr "Liste des émissions" - -#: aircox/models/page.py:242 -msgid "Episodes list" -msgstr "Liste des épisodes" - -#: aircox/models/page.py:243 -msgid "Articles list" -msgstr "Liste des articles" - -#: aircox/models/page.py:255 -msgid "attach to" -msgstr "attacher à" - -#: aircox/models/page.py:259 -msgid "display this page content to related element" -msgstr "Afficher le contenu de cette page pour l'élément sélectionné" - -#: aircox/models/page.py:272 -msgid "related page" -msgstr "page liée" - -#: aircox/models/page.py:276 -msgid "nickname" -msgstr "pseudo" - -#: aircox/models/page.py:277 -msgid "email" -msgstr "email" - -#: aircox/models/page.py:293 -msgid "Comment" -msgstr "Commentaire" - -#: aircox/models/page.py:294 aircox/templates/aircox/page_detail.html:37 -msgid "Comments" -msgstr "Commentaires" - -#: aircox/models/page.py:303 -msgid "menu" -msgstr "menu" - -#: aircox/models/page.py:304 aircox/models/sound.py:119 -#: aircox/models/sound.py:280 +#: models/episode.py:118 models/page.py:351 models/track.py:35 msgid "order" msgstr "ordre" -#: aircox/models/page.py:306 +#: models/episode.py:120 models/track.py:37 +msgid "position in the playlist" +msgstr "position dans la playlist" + +#: models/episode.py:123 models/sound.py:59 +msgid "Broadcast" +msgstr "Broadcast" + +#: models/episode.py:125 models/sound.py:61 +msgid "The sound is broadcasted on air" +msgstr "Le son est radiodiffusé" + +#: models/episode.py:132 templates/aircox/episode_detail.html:16 +#: templates/aircox/episode_form.html:11 templates/aircox/episode_list.html:8 +msgid "Podcasts" +msgstr "Podcasts" + +#: models/file.py:50 models/station.py:143 +msgid "file" +msgstr "fichier" + +#: models/file.py:56 models/station.py:30 +msgid "name" +msgstr "nom" + +#: models/file.py:61 +msgid "description" +msgstr "description" + +#: models/file.py:67 +msgid "modification time" +msgstr "dernière modification" + +#: models/file.py:70 +msgid "last modification date and time" +msgstr "date et heure de la dernière modification" + +#: models/file.py:73 +msgid "public" +msgstr "publique" + +#: models/file.py:74 +msgid "file is publicly accessible" +msgstr "le fichier est accessible publiquement" + +#: models/file.py:78 models/sound.py:192 +msgid "removed" +msgstr "supprimé" + +#: models/file.py:79 +msgid "file has been removed from server" +msgstr "le fichier a été supprimé du serveur" + +#: models/log.py:79 +msgid "stop" +msgstr "stop" + +#: models/log.py:83 +msgid "other" +msgstr "autre" + +#: models/log.py:89 models/page.py:349 models/program.py:55 +#: models/station.py:146 +msgid "station" +msgstr "station" + +#: models/log.py:90 +msgid "related station" +msgstr "station relative" + +#: models/log.py:93 models/schedule.py:48 +msgid "date" +msgstr "date" + +#: models/log.py:100 +msgid "source" +msgstr "source" + +#: models/log.py:101 +msgid "Identifier of the log's source." +msgstr "Identifiant de la source du log" + +#: models/log.py:107 +msgid "comment" +msgstr "commentaire" + +#: models/log.py:115 templatetags/aircox_admin.py:50 +msgid "Sound" +msgstr "Son" + +#: models/log.py:123 models/track.py:62 +#: templates/aircox/dashboard/statistics.html:29 +msgid "Track" +msgstr "Morceau" + +#: models/log.py:155 +msgid "Log" +msgstr "Log" + +#: models/log.py:156 +msgid "Logs" +msgstr "Logs" + +#: models/page.py:43 models/page.py:352 models/track.py:45 +msgid "title" +msgstr "titre" + +#: models/page.py:44 models/page.py:90 models/station.py:31 +msgid "slug" +msgstr "slug" + +#: models/page.py:47 +msgid "Category" +msgstr "Catégorie" + +#: models/page.py:48 templates/aircox/page_list.html:19 +msgid "Categories" +msgstr "Catégories" + +#: models/page.py:84 +msgid "draft" +msgstr "brouillon" + +#: models/page.py:85 +msgid "published" +msgstr "publié" + +#: models/page.py:86 +msgid "trash" +msgstr "corbeille" + +#: models/page.py:92 +msgid "status" +msgstr "statut" + +#: models/page.py:98 +msgid "cover" +msgstr "couverture" + +#: models/page.py:103 models/page.py:329 +msgid "content" +msgstr "contenu" + +#: models/page.py:202 +msgid "category" +msgstr "catégorie" + +#: models/page.py:207 +msgid "publication date" +msgstr "date de publication" + +#: models/page.py:209 +msgid "featured" +msgstr "en avant" + +#: models/page.py:213 +msgid "allow comments" +msgstr "autoriser les commentaires" + +#: models/page.py:228 +msgid "Publication" +msgstr "Publication" + +#: models/page.py:229 +msgid "Publications" +msgstr "Publications" + +#: models/page.py:290 +msgid "Home Page" +msgstr "Page d'accueil" + +#: models/page.py:291 +msgid "Timetable" +msgstr "Temps" + +#: models/page.py:292 +msgid "Programs list" +msgstr "Liste des émissions" + +#: models/page.py:293 +msgid "Episodes list" +msgstr "Liste des épisodes" + +#: models/page.py:294 +msgid "Articles list" +msgstr "Liste des articles" + +#: models/page.py:295 +msgid "Publications list" +msgstr "Publications" + +#: models/page.py:296 +msgid "Podcasts list" +msgstr "Podcasts" + +#: models/page.py:299 +msgid "attach to" +msgstr "attacher à" + +#: models/page.py:304 +msgid "display this page content to related element" +msgstr "Afficher le contenu de cette page pour l'élément sélectionné" + +#: models/page.py:322 +msgid "related page" +msgstr "page liée" + +#: models/page.py:326 +msgid "nickname" +msgstr "pseudo" + +#: models/page.py:327 +msgid "email" +msgstr "email" + +#: models/page.py:342 +msgid "Comment" +msgstr "Commentaire" + +#: models/page.py:343 templates/aircox/page_detail.html:51 +msgid "Comments" +msgstr "Commentaires" + +#: models/page.py:350 +msgid "menu" +msgstr "menu" + +#: models/page.py:353 msgid "url" msgstr "url" -#: aircox/models/page.py:311 +#: models/page.py:358 msgid "page" msgstr "page" -#: aircox/models/page.py:317 +#: models/page.py:364 msgid "Menu item" msgstr "Élément du menu" -#: aircox/models/page.py:318 +#: models/page.py:365 msgid "Menu items" msgstr "Éléments de menu" -#: aircox/models/program.py:54 aircox/models/station.py:48 -#: aircox/models/station.py:164 +#: models/program.py:57 models/station.py:38 models/station.py:149 msgid "active" msgstr "actif" -#: aircox/models/program.py:56 +#: models/program.py:59 msgid "if not checked this program is no longer active" msgstr "si selectionné, cette émission n'est plus active" -#: aircox/models/program.py:59 +#: models/program.py:62 msgid "syncronise" msgstr "synchroniser" -#: aircox/models/program.py:61 +#: models/program.py:64 msgid "update later diffusions according to schedule changes" msgstr "met à jour les dates de diffusion à venir lorsque l'horaire change" -#: aircox/models/program.py:116 aircox/templates/admin/base.html:103 +#: models/program.py:66 permissions.py:17 +msgid "editors" +msgstr "éditeurs" + +#: models/program.py:121 templates/aircox/dashboard/dashboard.html:29 +#: templates/aircox/dashboard/user_list.html:21 msgid "Programs" msgstr "Émissions" -#: aircox/models/program.py:171 aircox/models/rerun.py:49 +#: models/program.py:157 models/rerun.py:42 msgid "related program" msgstr "émission apparentée" -#: aircox/models/program.py:174 +#: models/program.py:160 msgid "delay" msgstr "délai" -#: aircox/models/program.py:177 +#: models/program.py:163 msgid "minimal delay between two sound plays" msgstr "délai minimum entre deux sons joués" -#: aircox/models/program.py:180 +#: models/program.py:166 msgid "begin" msgstr "début" -#: aircox/models/program.py:183 aircox/models/program.py:189 +#: models/program.py:169 models/program.py:175 msgid "used to define a time range this stream is played" -msgstr "" -"utilisé pour définir un intervalle de temps pendant lequel ce stream est joué" +msgstr "utilisé pour définir une période durant lequel ce stream est joué" -#: aircox/models/rerun.py:55 +#: models/rerun.py:48 msgid "rerun of" msgstr "rediffusion de" -#: aircox/models/rerun.py:87 +#: models/rerun.py:75 msgid "rerun must happen after original" msgstr "la rediffusion doit être après l'original" -#: aircox/models/schedule.py:31 +#: models/schedule.py:35 msgid "ponctual" msgstr "ponctuel" -#: aircox/models/schedule.py:32 +#: models/schedule.py:36 #, python-brace-format msgid "1st {day} of the month" msgstr "1er {day} du mois" -#: aircox/models/schedule.py:33 +#: models/schedule.py:37 #, python-brace-format msgid "2nd {day} of the month" msgstr "2e {day} du mois" -#: aircox/models/schedule.py:34 +#: models/schedule.py:38 #, python-brace-format msgid "3rd {day} of the month" msgstr "3e {day} du mois" -#: aircox/models/schedule.py:35 +#: models/schedule.py:39 #, python-brace-format msgid "4th {day} of the month" msgstr "4e {day} du mois" -#: aircox/models/schedule.py:36 +#: models/schedule.py:40 #, python-brace-format msgid "last {day} of the month" msgstr "dernier {day} du mois" -#: aircox/models/schedule.py:37 +#: models/schedule.py:41 #, python-brace-format msgid "1st and 3rd {day} of the month" msgstr "1er et 3e {day} du mois" -#: aircox/models/schedule.py:38 +#: models/schedule.py:42 #, python-brace-format msgid "2nd and 4th {day} of the month" msgstr "2ème et 4e {day} du mois" -#: aircox/models/schedule.py:39 +#: models/schedule.py:43 #, python-brace-format msgid "{day}" msgstr "{day}" -#: aircox/models/schedule.py:40 +#: models/schedule.py:44 #, python-brace-format msgid "one {day} on two" msgstr "un {day} sur deux" -#: aircox/models/schedule.py:44 +#: models/schedule.py:49 msgid "date of the first diffusion" msgstr "date de la première diffusion" -#: aircox/models/schedule.py:47 +#: models/schedule.py:52 msgid "time" msgstr "heure" -#: aircox/models/schedule.py:48 +#: models/schedule.py:53 msgid "start time" msgstr "heure de début" -#: aircox/models/schedule.py:51 +#: models/schedule.py:56 msgid "timezone" msgstr "zone horaire" -#: aircox/models/schedule.py:55 +#: models/schedule.py:60 msgid "timezone used for the date" msgstr "zone horaire utilisée pour la date" -#: aircox/models/schedule.py:58 aircox/models/sound.py:140 +#: models/schedule.py:63 models/sound.py:42 msgid "duration" msgstr "durée" -#: aircox/models/schedule.py:59 +#: models/schedule.py:64 msgid "regular duration" msgstr "durée normale" -#: aircox/models/schedule.py:62 +#: models/schedule.py:67 msgid "frequency" msgstr "fréquence" -#: aircox/models/schedule.py:68 +#: models/schedule.py:73 msgid "Schedules" msgstr "Horaires" -#: aircox/models/sound.py:95 -msgid "archive" -msgstr "archive" - -#: aircox/models/sound.py:96 -msgid "excerpt" -msgstr "extrait" - -#: aircox/models/sound.py:97 -msgid "removed" -msgstr "supprimé" - -#: aircox/models/sound.py:100 aircox/models/station.py:33 -msgid "name" -msgstr "nom" - -#: aircox/models/sound.py:105 -msgid "program" -msgstr "émission" - -#: aircox/models/sound.py:106 -msgid "program related to it" -msgstr "émission apparentée à celui-ci" - -#: aircox/models/sound.py:121 aircox/models/sound.py:282 -msgid "position in the playlist" -msgstr "position dans la playlist" - -#: aircox/models/sound.py:133 aircox/models/station.py:153 -msgid "file" -msgstr "fichier" - -#: aircox/models/sound.py:143 +#: models/sound.py:45 msgid "duration of the sound" msgstr "durée du son" -#: aircox/models/sound.py:146 -msgid "modification time" -msgstr "dernière modification" - -#: aircox/models/sound.py:149 -msgid "last modification date and time" -msgstr "date et heure de la dernière modification" - -#: aircox/models/sound.py:152 +#: models/sound.py:48 msgid "good quality" msgstr "bonne qualité" -#: aircox/models/sound.py:153 +#: models/sound.py:49 msgid "sound meets quality requirements" msgstr "le son rencontre les exigences de qualité" -#: aircox/models/sound.py:158 -msgid "public" -msgstr "publique" - -#: aircox/models/sound.py:159 -msgid "whether it is publicly available as podcast" -msgstr "coché pour rendre le podcast public" - -#: aircox/models/sound.py:163 +#: models/sound.py:54 msgid "downloadable" msgstr "téléchargeable" -#: aircox/models/sound.py:165 -msgid "" -"whether it can be publicly downloaded by visitors (sound must be public)" -msgstr "" -"coché pour permettre le téléchargement public (le podcast doit être " -"disponible publiquement)" +#: models/sound.py:55 +#, fuzzy +#| msgid "sound can be downloaded by visitors" +msgid "Sound can be downloaded by website visitors." +msgstr "Le son peut être téléchargé par les visiteurs du site." -#: aircox/models/sound.py:175 -msgid "Sounds" -msgstr "Sons" +#: models/sound.py:67 +msgid "Sound file" +msgstr "Fichier son" -#: aircox/models/sound.py:277 -msgid "sound" -msgstr "son" +#: models/sound.py:68 +msgid "Sound files" +msgstr "Fichiers son" -#: aircox/models/sound.py:288 -msgid "position (in seconds)" -msgstr "position (en secondes)" +#: models/sound.py:153 +msgid "unknown" +msgstr "inconnu" -#: aircox/models/sound.py:291 -msgid "artist" -msgstr "artiste" - -#: aircox/models/sound.py:292 -msgid "album" -msgstr "album" - -#: aircox/models/sound.py:293 -msgid "tags" -msgstr "tags" - -#: aircox/models/sound.py:294 -msgid "year" -msgstr "année" - -#: aircox/models/sound.py:297 -msgid "information" -msgstr "information" - -#: aircox/models/sound.py:302 -msgid "" -"additional informations about this track, such as the version, if is it a " -"remix, features, etc." -msgstr "" -"informations additionnelles à propos de ce morceau, telles que la version, " -"s'il s'agit d'un remix, les fonctionnalités, etc" - -#: aircox/models/sound.py:309 -msgid "Tracks" -msgstr "Morceaux" - -#: aircox/models/station.py:37 -msgid "path" -msgstr "chemin" - -#: aircox/models/station.py:38 -msgid "path to the working directory" -msgstr "chemin vers le repertoire courant" - -#: aircox/models/station.py:43 +#: models/station.py:33 msgid "default station" msgstr "station par défaut" -#: aircox/models/station.py:45 +#: models/station.py:35 msgid "use this station as the main one." msgstr "utiliser cette station comme principale." -#: aircox/models/station.py:50 +#: models/station.py:40 msgid "whether this station is still active or not." msgstr "si cette station est active ou non." -#: aircox/models/station.py:56 +#: models/station.py:46 msgid "Logo" msgstr "Logo" -#: aircox/models/station.py:59 +#: models/station.py:49 msgid "website's urls" msgstr "URL du site web" -#: aircox/models/station.py:63 +#: models/station.py:53 msgid "specify one domain per line, without 'http://' prefix" -msgstr "" +msgstr "spécifier un nom de de domaine par ligne, sans le préfix 'http://'" -#: aircox/models/station.py:66 +#: models/station.py:56 msgid "audio streams" msgstr "stream audio" -#: aircox/models/station.py:71 +#: models/station.py:60 msgid "Audio streams urls used by station's player. One url a line." msgstr "" "Les URL des flux audio utilisés par le lecteur de la station. Une url par " "ligne." -#: aircox/models/station.py:76 +#: models/station.py:64 msgid "Default pages' cover" msgstr "Couverture par défault des pages." -#: aircox/models/station.py:135 +#: models/station.py:70 +msgid "Music stream's title" +msgstr "Titre du flux musical" + +#: models/station.py:72 +msgid "Music stream" +msgstr "Flux musical" + +#: models/station.py:75 +msgid "Legal label" +msgstr "Label légal" + +#: models/station.py:75 +msgid "Displayed at the bottom of pages." +msgstr "Affiché en bas des pages." + +#: models/station.py:125 msgid "input" msgstr "entrée" -#: aircox/models/station.py:136 +#: models/station.py:126 msgid "output" msgstr "sortie" -#: aircox/models/station.py:160 +#: models/station.py:147 msgid "direction" msgstr "direction" -#: aircox/models/station.py:164 +#: models/station.py:149 msgid "this port is active" msgstr "ce port est actif" -#: aircox/models/station.py:167 +#: models/station.py:151 msgid "port settings" msgstr "paramètres du port" -#: aircox/models/station.py:169 +#: models/station.py:153 msgid "" "list of comma separated params available; this is put in the output config " "file as raw code; plugin related" @@ -689,486 +649,626 @@ msgstr "" "liste des paramètres disponibles séparés par des virgules; placé dans le " "fichier de configuration en tant que code brut; relatif au plugin utilisé" -#: aircox/models/user_settings.py:14 +#: models/track.py:32 +msgid "sound" +msgstr "son" + +#: models/track.py:43 +msgid "position (in seconds)" +msgstr "position (en secondes)" + +#: models/track.py:46 +msgid "artist" +msgstr "artiste" + +#: models/track.py:47 +msgid "album" +msgstr "album" + +#: models/track.py:48 +msgid "tags" +msgstr "tags" + +#: models/track.py:49 +msgid "year" +msgstr "année" + +#: models/track.py:52 +msgid "information" +msgstr "information" + +#: models/track.py:57 +msgid "" +"additional informations about this track, such as the version, if is it a " +"remix, features, etc." +msgstr "" +"informations additionnelles à propos de ce morceau, telles que la version, " +"s'il s'agit d'un remix, les fonctionnalités, etc" + +#: models/track.py:63 +msgid "Tracks" +msgstr "Morceaux" + +#: models/user_settings.py:14 templates/aircox/dashboard/user_list.html:19 msgid "User" msgstr "Utilisateur" -#: aircox/models/user_settings.py:17 +#: models/user_settings.py:17 msgid "Playlist Editor Columns" msgstr "Colonnes de l'éditeur de playlist" -#: aircox/models/user_settings.py:19 +#: models/user_settings.py:18 msgid "Playlist Editor Separator" msgstr "Séparateur de l'éditeur de playlist" -#: aircox/templates/admin/aircox/filters/filter.html:2 +#: templates/admin/aircox/filters/filter.html:2 #, python-format msgid " By %(filter_title)s " msgstr "Par %(filter_title)s " -#: aircox/templates/admin/aircox/page_change_form.html:9 -#: aircox/templates/admin/aircox/page_change_list.html:7 -#: aircox/templates/admin/base.html:182 -#: aircox/templates/admin/change_list.html:30 -#: aircox/templates/aircox/base.html:55 -msgid "Home" -msgstr "Accueil" +#: templates/aircox/base.html:70 +msgid "Main menu" +msgstr "Menu principal" -#: aircox/templates/admin/aircox/page_change_form.html:17 -#, python-format -msgid "Add %(name)s" -msgstr "Ajouter %(name)s" - -#: aircox/templates/admin/aircox/page_change_form.html:28 -msgid "Move to trash" -msgstr "Mettre à la corbeille" - -#: aircox/templates/admin/aircox/page_change_form.html:31 -msgid "Mark as draft" -msgstr "Marquer comme brouillon" - -#: aircox/templates/admin/aircox/page_change_form.html:36 -msgid "Save" -msgstr "Sauvegarder" - -#: aircox/templates/admin/aircox/page_change_form.html:37 -msgid "Save and continue" -msgstr "Sauvegarder et continuer" - -#: aircox/templates/admin/aircox/page_change_form.html:39 -msgid "Publish" -msgstr "Publier" - -#: aircox/templates/admin/aircox/playlist_inline.html:30 -#: aircox/templates/admin/aircox/playlist_inline.html:31 -msgid "Track Position" -msgstr "Position dans la playlist" - -#: aircox/templates/admin/aircox/statistics.html:22 -msgid "Time" -msgstr "Heure" - -#: aircox/templates/admin/aircox/statistics.html:25 -#: aircox/templatetags/aircox_admin.py:51 -msgid "Tags" -msgstr "Étiquettes" - -#: aircox/templates/admin/aircox/statistics.html:67 -msgid "Total" -msgstr "Total" - -#: aircox/templates/admin/base.html:70 aircox/templates/admin/index.html:12 -#: aircox/templates/aircox/home.html:47 -msgid "Today" -msgstr "Aujourd'hui" - -#: aircox/templates/admin/base.html:138 -msgid "Tools" -msgstr "Outils" - -#: aircox/templates/admin/base.html:156 -msgid "View site" -msgstr "Voir le site" - -#: aircox/templates/admin/base.html:161 -msgid "Documentation" -msgstr "Documentation" - -#: aircox/templates/admin/base.html:165 -msgid "Change password" -msgstr "Changer le mot de passe" - -#: aircox/templates/admin/base.html:168 -msgid "Log out" -msgstr "Se déconnecter" - -#: aircox/templates/admin/change_list.html:50 -msgid "Please correct the error below." -msgstr "Veuillez corriger l'erreur ci-dessous." - -#: aircox/templates/admin/change_list.html:50 -msgid "Please correct the errors below." -msgstr "Veuillez corriger les erreurs ci-dessous." - -#: aircox/templates/admin/change_list.html:79 -msgid "Filter" -msgstr "Filtre" - -#: aircox/templates/admin/index.html:30 -msgid "Live diffusion" -msgstr "Diffusion en live" - -#: aircox/templates/admin/index.html:33 -msgid "Differed diffusion" -msgstr "Diffusion différée" - -#: aircox/templates/admin/index.html:54 -msgid "No diffusion is scheduled for today." -msgstr "Aucune diffusion planifiée aujourd'hui" - -#: aircox/templates/admin/index.html:62 -msgid "Latest comments" -msgstr "Derniers commentaires" - -#: aircox/templates/admin/index.html:67 -msgid "All comments" -msgstr "Tous les commentaires" - -#: aircox/templates/admin/index.html:70 -msgid "No comment posted yet" -msgstr "Aucun commentaire posté pour le moment." - -#: aircox/templates/admin/index.html:78 -msgid "Latest publications" -msgstr "Dernières publications" - -#: aircox/templates/admin/index.html:87 -msgid "Administration" -msgstr "Administration" - -#. Translators: in page detail sidebar -#: aircox/templates/aircox/article_detail.html:12 -msgid "Latest news" -msgstr "Dernières nouvelles" - -#: aircox/templates/aircox/article_detail.html:23 -msgid "Show all news" -msgstr "Afficher toutes les nouvelles" - -#: aircox/templates/aircox/article_detail.html:24 -msgid "More news" -msgstr "Plus de nouvelles" - -#: aircox/templates/aircox/base.html:149 -msgid "Recently" -msgstr "Récemment" - -#. Translators: title when pages are filtered for a specific parent page, e.g.: Articles of My Incredible Show -#: aircox/templates/aircox/basepage_list.html:15 -#, python-format -msgid "%(model)s of %(title)s" -msgstr "%(model)s de %(title)s" - -#: aircox/templates/aircox/basepage_list.html:38 +#: templates/aircox/basepage_list.html:19 msgid "There is nothing published here..." msgstr "Il n'y a rien de publié ici..." -#: aircox/templates/aircox/basepage_list.html:48 -msgid "pagination" -msgstr "pagination" +#: templates/aircox/dashboard/dashboard.html:10 +msgid "administrator" +msgstr "administrateur" -#. Translators: Bottom of the list, "previous page" -#: aircox/templates/aircox/basepage_list.html:56 -msgid "Previous" -msgstr "Précédent" +#: templates/aircox/dashboard/dashboard.html:18 +msgid "Next diffusions" +msgstr "Prochaines diffusions" -#. Translators: Bottom of the list, "Nextpage" -#: aircox/templates/aircox/basepage_list.html:64 -msgid "Next" -msgstr "Prochain" +#: templates/aircox/dashboard/dashboard.html:23 +msgid "No diffusion to display" +msgstr "Aucune diffusion à afficher" -#: aircox/templates/aircox/diffusion_list.html:9 +#: templates/aircox/dashboard/dashboard.html:34 +msgid "No program to display" +msgstr "Pas de program à afficher" + +#: templates/aircox/dashboard/dashboard.html:41 +msgid "Last Comments" +msgstr "Derniers commentaires" + +#: templates/aircox/dashboard/statistics.html:4 +#: templates/aircox/widgets/nav.html:41 tests/test_admin_site.py:40 +#: views/admin.py:35 +msgid "Statistics" +msgstr "Statistiques" + +#: templates/aircox/dashboard/statistics.html:10 +msgid "Filter by date" +msgstr "Filtrer par date" + +#: templates/aircox/dashboard/statistics.html:13 +msgid "from" +msgstr "de" + +#: templates/aircox/dashboard/statistics.html:17 +msgid "... to" +msgstr "... à" + +#: templates/aircox/dashboard/statistics.html:20 +msgid "Apply" +msgstr "Appliquer" + +#: templates/aircox/dashboard/statistics.html:28 +msgid "Time" +msgstr "Heure" + +#: templates/aircox/dashboard/statistics.html:30 +msgid "Tags" +msgstr "Étiquettes" + +#: templates/aircox/dashboard/statistics.html:77 +msgid "No tracks" +msgstr "Pas de morceaux" + +#: templates/aircox/dashboard/statistics.html:90 +msgid "Totals" +msgstr "Totaux" + +#: templates/aircox/dashboard/user_list.html:4 +#: templates/aircox/widgets/nav.html:18 +msgid "Users" +msgstr "Utilisateurs" + +#: templates/aircox/dashboard/user_list.html:12 +msgid "Group and editors' changes will be visible only after page reload." +msgstr "" +"Les changement de group et d'éditeurs ne seront visible qu'après le " +"rechargement de la page." + +#: templates/aircox/dashboard/user_list.html:20 +msgid "Infos" +msgstr "Infos" + +#: templates/aircox/dashboard/user_list.html:41 +msgid "Inactive" +msgstr "Inactif" + +#: templates/aircox/dashboard/user_list.html:44 +#: templates/aircox/widgets/nav.html:38 +msgid "Admin" +msgstr "Admin" + +#: templates/aircox/dashboard/user_list.html:46 +msgid "Staff" +msgstr "Staff" + +#: templates/aircox/dashboard/user_list.html:61 +#: templates/aircox/dashboard/widgets/user_groups.html:14 +msgid "Groups" +msgstr "Groupes" + +#: templates/aircox/dashboard/widgets/group_users.html:14 +msgid "Members" +msgstr "Membres" + +#: templates/aircox/dashboard/widgets/tracklist_editor.html:15 +msgid "Track list" +msgstr "List des morceaux" + +#: templates/aircox/diffusion_list.html:9 #, python-format msgid "This week on %(station)s" msgstr "Cette semaine sur %(station)s" -#: aircox/templates/aircox/episode_detail.html:22 -#: aircox/templates/aircox/episode_list.html:8 -msgid "Podcasts" -msgstr "Podcasts" +#: templates/aircox/episode_detail.html:34 +msgid "Artist" +msgstr "Artiste" -#: aircox/templates/aircox/episode_detail.html:70 -#: aircox/templates/aircox/program_detail.html:58 -#: aircox/templates/aircox/widgets/episode_item.html:40 -#, python-format -msgid "Rerun of %(date)s" -msgstr "Rediffusion du %(date)s" +#: templates/aircox/episode_detail.html:35 +msgid "Title" +msgstr "Titre" -#: aircox/templates/aircox/errors/base.html:13 +#: templates/aircox/errors/base.html:13 msgid "An error occurred..." -msgstr "" +msgstr "Une erreur est arrivée..." -#: aircox/templates/aircox/errors/base.html:24 +#: templates/aircox/errors/base.html:24 msgid "An error occurred" -msgstr "" +msgstr "Une erreur est arrivée..." -#: aircox/templates/aircox/errors/no_station.html:4 +#: templates/aircox/errors/no_station.html:4 msgid "No station is configured" -msgstr "" +msgstr "Aucune station configurée" -#: aircox/templates/aircox/errors/no_station.html:7 +#: templates/aircox/errors/no_station.html:7 msgid "It seems there is no station configured for this website:" -msgstr "" +msgstr "Il semble qu'il n'y a pas de station configurée pour ce site." -#: aircox/templates/aircox/errors/no_station.html:11 +#: templates/aircox/errors/no_station.html:11 msgid "" "If you are the website administrator, please connect to administration " "interface." msgstr "" +"Si vous êtes l'administrateur/ice du site, connectez-vous à l'interface " +"d'administration." -#: aircox/templates/aircox/errors/no_station.html:13 +#: templates/aircox/errors/no_station.html:13 msgid "Go to admin" -msgstr "" +msgstr "Aller vers l'administration." -#: aircox/templates/aircox/errors/no_station.html:18 +#: templates/aircox/errors/no_station.html:18 msgid "If you are a visitor, please contact your favorite radio" -msgstr "" +msgstr "Si vous êtes un visiteur ou visiteuse, contactez votre radio favorite" -#: aircox/templates/aircox/home.html:28 -msgid "Currently" -msgstr "En ce moment" +#: templates/aircox/home.html:19 +#, python-format +msgid "Today on %(station)s" +msgstr "Aujourd'hui sur %(station)s" -#: aircox/templates/aircox/home.html:58 -msgid "Show all publication" -msgstr "Afficher toute la publication" +#: templates/aircox/home.html:43 +msgid "It just happened" +msgstr "Ça vient juste d'arriver" -#: aircox/templates/aircox/home.html:59 -msgid "More publications..." -msgstr "Plus de publications ..." +#: templates/aircox/home.html:51 +msgid "Show all program's for today" +msgstr "Tous les articles de l'émission" -#: aircox/templates/aircox/home.html:68 -msgid "Previously on air" -msgstr "Précédemment à l'antenne" +#: templates/aircox/home.html:52 +msgid "Today" +msgstr "Aujourd'hui" -#: aircox/templates/aircox/home.html:77 +#: templates/aircox/home.html:61 +msgid "Last podcasts" +msgstr "Derniers Podcasts" + +#: templates/aircox/home.html:62 +msgid "All podcasts" +msgstr "Tous les podcasts" + +#: templates/aircox/home.html:68 msgid "Last publications" msgstr "Dernières publications" -#: aircox/templates/aircox/log_list.html:9 +#: templates/aircox/home.html:69 +msgid "All publications" +msgstr "Toutes les publications" + +#: templates/aircox/page_detail.html:40 #, python-format -msgid "That happened on %(station)s" -msgstr "C'est passé sur %(station)s" +msgid "Related %(models)s" +msgstr "%(models)s connexes" -#: aircox/templates/aircox/page_detail.html:26 -msgid "Edit" -msgstr "Éditer" - -#: aircox/templates/aircox/page_detail.html:56 +#: templates/aircox/page_detail.html:61 msgid "Post a comment" msgstr "Poster un commentaire" -#: aircox/templates/aircox/page_detail.html:81 -#: aircox/templates/aircox/page_list.html:57 -msgid "Reset" -msgstr "Réinitialiser" - -#: aircox/templates/aircox/page_detail.html:82 +#: templates/aircox/page_detail.html:88 msgid "Post comment" msgstr "Commenter" -#: aircox/templates/aircox/page_list.html:25 -msgid "Search content" -msgstr "Recherche" +#: templates/aircox/page_form.html:14 +#, fuzzy, python-format +#| msgid "Create a %(model)s" +msgid "Create a %(model)s" +msgstr "Ajouter %(models)s" -#: aircox/templates/aircox/page_list.html:54 -msgid "Apply" -msgstr "Appliquer" +#: templates/aircox/page_form.html:28 +msgid "Select an image" +msgstr "Sélectionner une image" -#: aircox/templates/aircox/program_detail.html:31 -msgid "Show all program's articles" -msgstr "Afficher tous les articles de l'émission" +#: templates/aircox/page_form.html:44 +msgid "Are you sure you want to remove this item from server?" +msgstr "Êtes-vous sûr de vouloir retirer ce fichier du serveur?" -#: aircox/templates/aircox/program_detail.html:32 -msgid "More articles" -msgstr "Plus d'articles" +#: templates/aircox/page_form.html:91 +msgid "Change cover" +msgstr "Changer de couverture" -#: aircox/templates/aircox/program_detail.html:59 +#: templates/aircox/page_form.html:125 +msgid "Update" +msgstr "Mise à jour" + +#: templates/aircox/program_detail.html:24 +#, python-format +msgid "Rerun of %(date)s" +msgstr "Rediffusion du %(date)s" + +#: templates/aircox/program_detail.html:25 msgid "Rerun" msgstr "Rediffusion" -#: aircox/templates/aircox/program_sidebar.html:4 -#, python-format -msgid "Recently on %(program)s" -msgstr "Récemment dans %(program)s" +#: templates/aircox/program_detail.html:41 +msgid "Last Episodes" +msgstr "Derniers Épisodes" -#: aircox/templates/aircox/widgets/comment_item.html:7 -#: aircox/templates/aircox/widgets/comment_item.html:8 +#: templates/aircox/program_detail.html:42 +msgid "All episodes" +msgstr "Tous les épisodes" + +#: templates/aircox/program_detail.html:49 +msgid "Last Articles" +msgstr "Derniers articles" + +#: templates/aircox/program_detail.html:50 +msgid "All articles" +msgstr "Tous les articles" + +#: templates/aircox/program_form.html:13 +msgid "Editors" +msgstr "Éditeurs" + +#: templates/aircox/widgets/basepage_item.html:47 +msgid "More infos" +msgstr "Plus d'infos" + +#: templates/aircox/widgets/carousel.html:25 +msgid "Show all" +msgstr "Tout Afficher" + +#: templates/aircox/widgets/comment.html:38 +#: templates/aircox/widgets/comment.html:39 +#: templates/aircox/widgets/comment_item.html:7 +#: templates/aircox/widgets/comment_item.html:8 msgid "Edit comment" msgstr "Editer le commentaire" -#: aircox/templates/aircox/widgets/comment_item.html:12 -#: aircox/templates/aircox/widgets/comment_item.html:13 +#: templates/aircox/widgets/comment.html:44 +#: templates/aircox/widgets/comment.html:45 +#: templates/aircox/widgets/comment_item.html:12 +#: templates/aircox/widgets/comment_item.html:13 msgid "Delete comment" msgstr "Supprimer le commentaire" -#: aircox/templates/aircox/widgets/dates_menu.html:15 -msgid "pick a date" -msgstr "choisir une date" +#: templates/aircox/widgets/comment.html:49 +msgid "Delete comment?" +msgstr "Supprimer le commentaire?" -#: aircox/templates/aircox/widgets/dates_menu.html:32 -msgid "Jump to date" -msgstr "Aller à la date" +#: templates/aircox/widgets/dates_menu.html:16 +msgid "Dates" +msgstr "Dates" -#. Translators: form button to select a date -#: aircox/templates/aircox/widgets/dates_menu.html:41 -msgid "Go" -msgstr "Filtrer" +#: templates/aircox/widgets/dates_menu.html:33 +msgid "Pick a date" +msgstr "Choisir une date" -#: aircox/templates/aircox/widgets/episode_item.html:41 -msgid "(rerun)" -msgstr "(rediffusion)" +#: templates/aircox/widgets/diffusion_tags.html:11 +#: templates/aircox/widgets/episode.html:35 +msgid "Live diffusion" +msgstr "Diffusion en live" -#: aircox/templates/aircox/widgets/page_list.html:20 +#: templates/aircox/widgets/diffusion_tags.html:14 +#: templates/aircox/widgets/episode.html:38 +msgid "Differed diffusion" +msgstr "Diffusion différée" + +#: templates/aircox/widgets/episode.html:67 +msgid "Listen" +msgstr "Écouter" + +#: templates/aircox/widgets/list_pagination.html:12 +msgid "pagination" +msgstr "pagination" + +#: templates/aircox/widgets/list_pagination.html:17 +#: templates/aircox/widgets/list_pagination.html:18 +msgid "Previous" +msgstr "Précédent" + +#: templates/aircox/widgets/list_pagination.html:30 +#: templates/aircox/widgets/list_pagination.html:31 +msgid "Next" +msgstr "Prochain" + +#: templates/aircox/widgets/nav.html:14 views/dashboard.py:16 +msgid "Dashboard" +msgstr "Tableau de bord" + +#: templates/aircox/widgets/nav.html:47 +msgid "Disconnect" +msgstr "Déconnexion" + +#: templates/aircox/widgets/page.html:35 +msgid "Show" +msgstr "Voir" + +#: templates/aircox/widgets/page_actions.html:16 +#: templates/aircox/widgets/page_actions.html:20 +msgid "View" +msgstr "Voir" + +#: templates/aircox/widgets/page_actions.html:23 +#: templates/aircox/widgets/page_actions.html:27 +#: templates/aircox/widgets/preview.html:62 +msgid "Edit" +msgstr "Éditer" + +#: templates/aircox/widgets/page_list.html:20 msgid "Show all publications" msgstr "Afficher toutes les publications" -#: aircox/templates/aircox/widgets/page_list.html:21 +#: templates/aircox/widgets/page_list.html:21 msgid "Show more" msgstr "Afficher plus" -#: aircox/templates/aircox/widgets/player.html:9 +#: templates/aircox/widgets/player.html:9 msgid "player" msgstr "lecteur" -#: aircox/templates/aircox/widgets/player.html:10 +#: templates/aircox/widgets/player.html:10 msgid "Audio player used to listen to the radio and podcasts" msgstr "Lecteur audio utilisé pour écouter la radio et les podcasts" -#: aircox/templates/aircox/widgets/player.html:23 +#: templates/aircox/widgets/player.html:23 +msgid "Bookmarks" +msgstr "Favoris" + +#: templates/aircox/widgets/player.html:24 msgid "Play or pause audio" msgstr "Lire ou suspendre l'audio" -#: aircox/templates/aircox/widgets/player.html:29 +#: templates/aircox/widgets/player.html:33 msgid "Track currently on air" msgstr "Morceau en ce moment sur les ondes" -#: aircox/templates/aircox/widgets/player.html:38 +#: templates/aircox/widgets/player.html:45 msgid "Diffusion currently on air" msgstr "Épisode en ce moment sur les ondes" -#: aircox/templates/aircox/widgets/player.html:43 +#: templates/aircox/widgets/player.html:49 msgid "Currently playing" msgstr "En ce moment" -#: aircox/templatetags/aircox_admin.py:48 -msgid "Artist" -msgstr "Artiste" +#: templates/registration/login.html:6 templates/registration/login.html:14 +msgid "Log in" +msgstr "Connection" -#: aircox/templatetags/aircox_admin.py:49 -msgid "Album" -msgstr "Album" +#: templatetags/aircox_admin.py:68 +msgid "Add an item" +msgstr "Ajouter un élément" -#: aircox/templatetags/aircox_admin.py:50 -msgid "Title" -msgstr "Titre" - -#: aircox/templatetags/aircox_admin.py:52 -msgid "Year" -msgstr "Année" - -#: aircox/templatetags/aircox_admin.py:53 -msgid "Save Settings" -msgstr "Enregistrer la configuration" - -#: aircox/templatetags/aircox_admin.py:54 -msgid "Discard changes" -msgstr "Annuler les changements" - -#: aircox/templatetags/aircox_admin.py:55 -msgid "Columns" -msgstr "Colonnes" - -#: aircox/templatetags/aircox_admin.py:56 -msgid "Add a track" -msgstr "Ajouter un morceau" - -#: aircox/templatetags/aircox_admin.py:57 +#: templatetags/aircox_admin.py:69 msgid "Remove" msgstr "Supprimer" -#: aircox/templatetags/aircox_admin.py:58 +#: templatetags/aircox_admin.py:70 +msgid "Settings" +msgstr "Paramètres" + +#: templatetags/aircox_admin.py:71 +msgid "Save Settings" +msgstr "Enregistrer" + +#: templatetags/aircox_admin.py:72 +msgid "Discard changes" +msgstr "Annuler" + +#: templatetags/aircox_admin.py:73 +msgid "Submit" +msgstr "Envoyer" + +#: templatetags/aircox_admin.py:74 +msgid "Delete" +msgstr "Supprimer" + +#: templatetags/aircox_admin.py:76 +msgid "Upload" +msgstr "Téléverser" + +#: templatetags/aircox_admin.py:77 +msgid "List" +msgstr "Liste" + +#: templatetags/aircox_admin.py:78 +msgid "Are you sure to remove this element from the server?" +msgstr "Êtes-vous sûr de vouloir supprimer ce fichier du serveur?" + +#: templatetags/aircox_admin.py:79 +msgid "Show next" +msgstr "Suivant" + +#: templatetags/aircox_admin.py:80 +msgid "Show previous" +msgstr "Précédent" + +#: templatetags/aircox_admin.py:81 +msgid "Select a file" +msgstr "Sélectionner un fichier" + +#: templatetags/aircox_admin.py:83 +msgid "Text" +msgstr "Texte" + +#: templatetags/aircox_admin.py:84 +msgid "Columns" +msgstr "Colonnes" + +#: templatetags/aircox_admin.py:85 msgid "Timestamp" msgstr "Temps" -#: aircox/urls.py:46 -msgid "articles/" -msgstr "articles/" +#: templatetags/aircox_admin.py:87 +msgid "Add a sound" +msgstr "Ajouter un son" -#: aircox/urls.py:51 +#: urls.py:50 msgid "articles//" msgstr "articles//" -#: aircox/urls.py:55 -msgid "episodes/" -msgstr "episodes/" +#: urls.py:55 +msgid "articles/" +msgstr "articles/" -#: aircox/urls.py:57 -msgid "episodes//" -msgstr "episodes//" +#: urls.py:60 +msgid "articles/c//" +msgstr "articles//" -#: aircox/urls.py:61 -msgid "week/" -msgstr "semaine/" +#: urls.py:65 +msgid "timetable/" +msgstr "grille/" -#: aircox/urls.py:63 -msgid "week//" -msgstr "semaine//" +#: urls.py:67 +msgid "timetable//" +msgstr "grille//" -#: aircox/urls.py:67 -msgid "logs/" -msgstr "logs/" - -#: aircox/urls.py:68 -msgid "logs//" -msgstr "logs//" - -#: aircox/urls.py:71 +#: urls.py:73 msgid "publications/" msgstr "publications/" -#: aircox/urls.py:76 -msgid "pages/" -msgstr "pages/" +#: urls.py:78 +msgid "publications/c//" +msgstr "publications/c//" -#: aircox/urls.py:84 +#: urls.py:83 msgid "pages//" msgstr "pages//" -#: aircox/urls.py:91 +#: urls.py:91 +msgid "pages/" +msgstr "pages/" + +#: urls.py:99 msgid "programs/" msgstr "emissions/" -#: aircox/urls.py:93 +#: urls.py:100 +msgid "programs/c//" +msgstr "emissions/c//" + +#: urls.py:102 msgid "programs//" msgstr "emissions//" -#: aircox/urls.py:98 -msgid "programs//episodes/" -msgstr "emissions//episodes/" - -#: aircox/urls.py:103 +#: urls.py:106 msgid "programs//articles/" msgstr "emissions//articles/" -#: aircox/urls.py:108 +#: urls.py:107 +msgid "programs//podcasts/" +msgstr "emissions//podcasts/" + +#: urls.py:108 +msgid "programs//episodes/" +msgstr "emissions//episodes/" + +#: urls.py:110 +msgid "programs//diffusions/" +msgstr "emissions//diffusions/" + +#: urls.py:113 msgid "programs//publications/" msgstr "emissions//publications/" -#: aircox/views/page.py:147 +#: urls.py:118 +msgid "programs/episodes/" +msgstr "emissions/episodes/" + +#: urls.py:119 +msgid "programs/episodes/c//" +msgstr "emissions/episodes/c//" + +#: urls.py:121 +msgid "programs/episodes//" +msgstr "emissions/episodes//" + +#: urls.py:125 +msgid "podcasts/" +msgstr "podcasts/" + +#: urls.py:126 +msgid "podcasts/c//" +msgstr "podcasts/c//" + +#: urls.py:128 +msgid "dashboard/" +msgstr "dashboard/" + +#: urls.py:129 +msgid "dashboard/program//" +msgstr "dashboard/emissions//" + +#: urls.py:130 +msgid "dashboard/episodes//" +msgstr "dashboard/episodes//" + +#: urls.py:131 +msgid "dashboard/statistics/" +msgstr "dashboard/statistiques/" + +#: urls.py:132 +msgid "dashboard/statistics//" +msgstr "dashboard/statistiques//" + +#: urls.py:133 +msgid "dashboard/users/" +msgstr "dashboard/utilisateurs/" + +#: urls.py:135 +msgid "errors/no-station/" +msgstr "erreurs/pas-de-station/" + +#: views/page.py:89 +#, python-brace-format +msgid "{model}" +msgstr "{model}" + +#: views/page.py:204 msgid "comments are not allowed" msgstr "les commentaires ne sont pas autorisés" - -#~ msgid "specify one url per line" -#~ msgstr "spécifiez une url par ligne" - -#~ msgid "track" -#~ msgstr "morceau" - -#~ msgid "if it can be podcasted from the server" -#~ msgstr "s'il peut être podcasté depuis le serveur" - -#~ msgid "embed" -#~ msgstr "intégrer" - -#~ msgid "HTML code to embed a sound from an external plateform" -#~ msgstr "" -#~ "code HTML utilisé pour intégrer un son depuis une plateforme extérieure" - -#~ msgid "list filters" -#~ msgstr "Filtres" - -#~ msgid "" -#~ "Should this article be considered as a page instead of a blog article" -#~ msgstr "" -#~ "Cet article doit-il être considéré comme une page plutôt que comme un " -#~ "article de blog" diff --git a/aircox/middleware.py b/aircox/middleware.py index 3382ee4..8b5cbb1 100644 --- a/aircox/middleware.py +++ b/aircox/middleware.py @@ -13,6 +13,7 @@ class AircoxMiddleware(object): """Middleware used to get default info for the given website. It provide following request attributes: + - ``mobile``: set to True if mobile device is detected - ``station``: current Station This middleware must be set after the middleware @@ -24,6 +25,11 @@ class AircoxMiddleware(object): def __init__(self, get_response): self.get_response = get_response + def is_mobile(self, request): + if agent := request.META.get("HTTP_USER_AGENT"): + return " Mobi" in agent + return False + def get_station(self, request): """Return station for the provided request.""" host = request.get_host() @@ -45,6 +51,7 @@ class AircoxMiddleware(object): def __call__(self, request): self.init_timezone(request) request.station = self.get_station(request) + request.is_mobile = self.is_mobile(request) try: return self.get_response(request) except Redirect: diff --git a/aircox/migrations/0015_alter_schedule_timezone_alter_staticpage_attach_to.py b/aircox/migrations/0015_alter_schedule_timezone_alter_staticpage_attach_to.py new file mode 100644 index 0000000..bfbbbd3 --- /dev/null +++ b/aircox/migrations/0015_alter_schedule_timezone_alter_staticpage_attach_to.py @@ -0,0 +1,641 @@ +# Generated by Django 4.2.1 on 2023-11-24 21:11 + +import aircox.models.schedule +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("aircox", "0014_alter_schedule_timezone"), + ] + + operations = [ + migrations.AlterField( + model_name="schedule", + name="timezone", + field=models.CharField( + choices=[ + ("Africa/Abidjan", "Africa/Abidjan"), + ("Africa/Accra", "Africa/Accra"), + ("Africa/Addis_Ababa", "Africa/Addis_Ababa"), + ("Africa/Algiers", "Africa/Algiers"), + ("Africa/Asmara", "Africa/Asmara"), + ("Africa/Asmera", "Africa/Asmera"), + ("Africa/Bamako", "Africa/Bamako"), + ("Africa/Bangui", "Africa/Bangui"), + ("Africa/Banjul", "Africa/Banjul"), + ("Africa/Bissau", "Africa/Bissau"), + ("Africa/Blantyre", "Africa/Blantyre"), + ("Africa/Brazzaville", "Africa/Brazzaville"), + ("Africa/Bujumbura", "Africa/Bujumbura"), + ("Africa/Cairo", "Africa/Cairo"), + ("Africa/Casablanca", "Africa/Casablanca"), + ("Africa/Ceuta", "Africa/Ceuta"), + ("Africa/Conakry", "Africa/Conakry"), + ("Africa/Dakar", "Africa/Dakar"), + ("Africa/Dar_es_Salaam", "Africa/Dar_es_Salaam"), + ("Africa/Djibouti", "Africa/Djibouti"), + ("Africa/Douala", "Africa/Douala"), + ("Africa/El_Aaiun", "Africa/El_Aaiun"), + ("Africa/Freetown", "Africa/Freetown"), + ("Africa/Gaborone", "Africa/Gaborone"), + ("Africa/Harare", "Africa/Harare"), + ("Africa/Johannesburg", "Africa/Johannesburg"), + ("Africa/Juba", "Africa/Juba"), + ("Africa/Kampala", "Africa/Kampala"), + ("Africa/Khartoum", "Africa/Khartoum"), + ("Africa/Kigali", "Africa/Kigali"), + ("Africa/Kinshasa", "Africa/Kinshasa"), + ("Africa/Lagos", "Africa/Lagos"), + ("Africa/Libreville", "Africa/Libreville"), + ("Africa/Lome", "Africa/Lome"), + ("Africa/Luanda", "Africa/Luanda"), + ("Africa/Lubumbashi", "Africa/Lubumbashi"), + ("Africa/Lusaka", "Africa/Lusaka"), + ("Africa/Malabo", "Africa/Malabo"), + ("Africa/Maputo", "Africa/Maputo"), + ("Africa/Maseru", "Africa/Maseru"), + ("Africa/Mbabane", "Africa/Mbabane"), + ("Africa/Mogadishu", "Africa/Mogadishu"), + ("Africa/Monrovia", "Africa/Monrovia"), + ("Africa/Nairobi", "Africa/Nairobi"), + ("Africa/Ndjamena", "Africa/Ndjamena"), + ("Africa/Niamey", "Africa/Niamey"), + ("Africa/Nouakchott", "Africa/Nouakchott"), + ("Africa/Ouagadougou", "Africa/Ouagadougou"), + ("Africa/Porto-Novo", "Africa/Porto-Novo"), + ("Africa/Sao_Tome", "Africa/Sao_Tome"), + ("Africa/Timbuktu", "Africa/Timbuktu"), + ("Africa/Tripoli", "Africa/Tripoli"), + ("Africa/Tunis", "Africa/Tunis"), + ("Africa/Windhoek", "Africa/Windhoek"), + ("America/Adak", "America/Adak"), + ("America/Anchorage", "America/Anchorage"), + ("America/Anguilla", "America/Anguilla"), + ("America/Antigua", "America/Antigua"), + ("America/Araguaina", "America/Araguaina"), + ("America/Argentina/Buenos_Aires", "America/Argentina/Buenos_Aires"), + ("America/Argentina/Catamarca", "America/Argentina/Catamarca"), + ("America/Argentina/ComodRivadavia", "America/Argentina/ComodRivadavia"), + ("America/Argentina/Cordoba", "America/Argentina/Cordoba"), + ("America/Argentina/Jujuy", "America/Argentina/Jujuy"), + ("America/Argentina/La_Rioja", "America/Argentina/La_Rioja"), + ("America/Argentina/Mendoza", "America/Argentina/Mendoza"), + ("America/Argentina/Rio_Gallegos", "America/Argentina/Rio_Gallegos"), + ("America/Argentina/Salta", "America/Argentina/Salta"), + ("America/Argentina/San_Juan", "America/Argentina/San_Juan"), + ("America/Argentina/San_Luis", "America/Argentina/San_Luis"), + ("America/Argentina/Tucuman", "America/Argentina/Tucuman"), + ("America/Argentina/Ushuaia", "America/Argentina/Ushuaia"), + ("America/Aruba", "America/Aruba"), + ("America/Asuncion", "America/Asuncion"), + ("America/Atikokan", "America/Atikokan"), + ("America/Atka", "America/Atka"), + ("America/Bahia", "America/Bahia"), + ("America/Bahia_Banderas", "America/Bahia_Banderas"), + ("America/Barbados", "America/Barbados"), + ("America/Belem", "America/Belem"), + ("America/Belize", "America/Belize"), + ("America/Blanc-Sablon", "America/Blanc-Sablon"), + ("America/Boa_Vista", "America/Boa_Vista"), + ("America/Bogota", "America/Bogota"), + ("America/Boise", "America/Boise"), + ("America/Buenos_Aires", "America/Buenos_Aires"), + ("America/Cambridge_Bay", "America/Cambridge_Bay"), + ("America/Campo_Grande", "America/Campo_Grande"), + ("America/Cancun", "America/Cancun"), + ("America/Caracas", "America/Caracas"), + ("America/Catamarca", "America/Catamarca"), + ("America/Cayenne", "America/Cayenne"), + ("America/Cayman", "America/Cayman"), + ("America/Chicago", "America/Chicago"), + ("America/Chihuahua", "America/Chihuahua"), + ("America/Ciudad_Juarez", "America/Ciudad_Juarez"), + ("America/Coral_Harbour", "America/Coral_Harbour"), + ("America/Cordoba", "America/Cordoba"), + ("America/Costa_Rica", "America/Costa_Rica"), + ("America/Creston", "America/Creston"), + ("America/Cuiaba", "America/Cuiaba"), + ("America/Curacao", "America/Curacao"), + ("America/Danmarkshavn", "America/Danmarkshavn"), + ("America/Dawson", "America/Dawson"), + ("America/Dawson_Creek", "America/Dawson_Creek"), + ("America/Denver", "America/Denver"), + ("America/Detroit", "America/Detroit"), + ("America/Dominica", "America/Dominica"), + ("America/Edmonton", "America/Edmonton"), + ("America/Eirunepe", "America/Eirunepe"), + ("America/El_Salvador", "America/El_Salvador"), + ("America/Ensenada", "America/Ensenada"), + ("America/Fort_Nelson", "America/Fort_Nelson"), + ("America/Fort_Wayne", "America/Fort_Wayne"), + ("America/Fortaleza", "America/Fortaleza"), + ("America/Glace_Bay", "America/Glace_Bay"), + ("America/Godthab", "America/Godthab"), + ("America/Goose_Bay", "America/Goose_Bay"), + ("America/Grand_Turk", "America/Grand_Turk"), + ("America/Grenada", "America/Grenada"), + ("America/Guadeloupe", "America/Guadeloupe"), + ("America/Guatemala", "America/Guatemala"), + ("America/Guayaquil", "America/Guayaquil"), + ("America/Guyana", "America/Guyana"), + ("America/Halifax", "America/Halifax"), + ("America/Havana", "America/Havana"), + ("America/Hermosillo", "America/Hermosillo"), + ("America/Indiana/Indianapolis", "America/Indiana/Indianapolis"), + ("America/Indiana/Knox", "America/Indiana/Knox"), + ("America/Indiana/Marengo", "America/Indiana/Marengo"), + ("America/Indiana/Petersburg", "America/Indiana/Petersburg"), + ("America/Indiana/Tell_City", "America/Indiana/Tell_City"), + ("America/Indiana/Vevay", "America/Indiana/Vevay"), + ("America/Indiana/Vincennes", "America/Indiana/Vincennes"), + ("America/Indiana/Winamac", "America/Indiana/Winamac"), + ("America/Indianapolis", "America/Indianapolis"), + ("America/Inuvik", "America/Inuvik"), + ("America/Iqaluit", "America/Iqaluit"), + ("America/Jamaica", "America/Jamaica"), + ("America/Jujuy", "America/Jujuy"), + ("America/Juneau", "America/Juneau"), + ("America/Kentucky/Louisville", "America/Kentucky/Louisville"), + ("America/Kentucky/Monticello", "America/Kentucky/Monticello"), + ("America/Knox_IN", "America/Knox_IN"), + ("America/Kralendijk", "America/Kralendijk"), + ("America/La_Paz", "America/La_Paz"), + ("America/Lima", "America/Lima"), + ("America/Los_Angeles", "America/Los_Angeles"), + ("America/Louisville", "America/Louisville"), + ("America/Lower_Princes", "America/Lower_Princes"), + ("America/Maceio", "America/Maceio"), + ("America/Managua", "America/Managua"), + ("America/Manaus", "America/Manaus"), + ("America/Marigot", "America/Marigot"), + ("America/Martinique", "America/Martinique"), + ("America/Matamoros", "America/Matamoros"), + ("America/Mazatlan", "America/Mazatlan"), + ("America/Mendoza", "America/Mendoza"), + ("America/Menominee", "America/Menominee"), + ("America/Merida", "America/Merida"), + ("America/Metlakatla", "America/Metlakatla"), + ("America/Mexico_City", "America/Mexico_City"), + ("America/Miquelon", "America/Miquelon"), + ("America/Moncton", "America/Moncton"), + ("America/Monterrey", "America/Monterrey"), + ("America/Montevideo", "America/Montevideo"), + ("America/Montreal", "America/Montreal"), + ("America/Montserrat", "America/Montserrat"), + ("America/Nassau", "America/Nassau"), + ("America/New_York", "America/New_York"), + ("America/Nipigon", "America/Nipigon"), + ("America/Nome", "America/Nome"), + ("America/Noronha", "America/Noronha"), + ("America/North_Dakota/Beulah", "America/North_Dakota/Beulah"), + ("America/North_Dakota/Center", "America/North_Dakota/Center"), + ("America/North_Dakota/New_Salem", "America/North_Dakota/New_Salem"), + ("America/Nuuk", "America/Nuuk"), + ("America/Ojinaga", "America/Ojinaga"), + ("America/Panama", "America/Panama"), + ("America/Pangnirtung", "America/Pangnirtung"), + ("America/Paramaribo", "America/Paramaribo"), + ("America/Phoenix", "America/Phoenix"), + ("America/Port-au-Prince", "America/Port-au-Prince"), + ("America/Port_of_Spain", "America/Port_of_Spain"), + ("America/Porto_Acre", "America/Porto_Acre"), + ("America/Porto_Velho", "America/Porto_Velho"), + ("America/Puerto_Rico", "America/Puerto_Rico"), + ("America/Punta_Arenas", "America/Punta_Arenas"), + ("America/Rainy_River", "America/Rainy_River"), + ("America/Rankin_Inlet", "America/Rankin_Inlet"), + ("America/Recife", "America/Recife"), + ("America/Regina", "America/Regina"), + ("America/Resolute", "America/Resolute"), + ("America/Rio_Branco", "America/Rio_Branco"), + ("America/Rosario", "America/Rosario"), + ("America/Santa_Isabel", "America/Santa_Isabel"), + ("America/Santarem", "America/Santarem"), + ("America/Santiago", "America/Santiago"), + ("America/Santo_Domingo", "America/Santo_Domingo"), + ("America/Sao_Paulo", "America/Sao_Paulo"), + ("America/Scoresbysund", "America/Scoresbysund"), + ("America/Shiprock", "America/Shiprock"), + ("America/Sitka", "America/Sitka"), + ("America/St_Barthelemy", "America/St_Barthelemy"), + ("America/St_Johns", "America/St_Johns"), + ("America/St_Kitts", "America/St_Kitts"), + ("America/St_Lucia", "America/St_Lucia"), + ("America/St_Thomas", "America/St_Thomas"), + ("America/St_Vincent", "America/St_Vincent"), + ("America/Swift_Current", "America/Swift_Current"), + ("America/Tegucigalpa", "America/Tegucigalpa"), + ("America/Thule", "America/Thule"), + ("America/Thunder_Bay", "America/Thunder_Bay"), + ("America/Tijuana", "America/Tijuana"), + ("America/Toronto", "America/Toronto"), + ("America/Tortola", "America/Tortola"), + ("America/Vancouver", "America/Vancouver"), + ("America/Virgin", "America/Virgin"), + ("America/Whitehorse", "America/Whitehorse"), + ("America/Winnipeg", "America/Winnipeg"), + ("America/Yakutat", "America/Yakutat"), + ("America/Yellowknife", "America/Yellowknife"), + ("Antarctica/Casey", "Antarctica/Casey"), + ("Antarctica/Davis", "Antarctica/Davis"), + ("Antarctica/DumontDUrville", "Antarctica/DumontDUrville"), + ("Antarctica/Macquarie", "Antarctica/Macquarie"), + ("Antarctica/Mawson", "Antarctica/Mawson"), + ("Antarctica/McMurdo", "Antarctica/McMurdo"), + ("Antarctica/Palmer", "Antarctica/Palmer"), + ("Antarctica/Rothera", "Antarctica/Rothera"), + ("Antarctica/South_Pole", "Antarctica/South_Pole"), + ("Antarctica/Syowa", "Antarctica/Syowa"), + ("Antarctica/Troll", "Antarctica/Troll"), + ("Antarctica/Vostok", "Antarctica/Vostok"), + ("Arctic/Longyearbyen", "Arctic/Longyearbyen"), + ("Asia/Aden", "Asia/Aden"), + ("Asia/Almaty", "Asia/Almaty"), + ("Asia/Amman", "Asia/Amman"), + ("Asia/Anadyr", "Asia/Anadyr"), + ("Asia/Aqtau", "Asia/Aqtau"), + ("Asia/Aqtobe", "Asia/Aqtobe"), + ("Asia/Ashgabat", "Asia/Ashgabat"), + ("Asia/Ashkhabad", "Asia/Ashkhabad"), + ("Asia/Atyrau", "Asia/Atyrau"), + ("Asia/Baghdad", "Asia/Baghdad"), + ("Asia/Bahrain", "Asia/Bahrain"), + ("Asia/Baku", "Asia/Baku"), + ("Asia/Bangkok", "Asia/Bangkok"), + ("Asia/Barnaul", "Asia/Barnaul"), + ("Asia/Beirut", "Asia/Beirut"), + ("Asia/Bishkek", "Asia/Bishkek"), + ("Asia/Brunei", "Asia/Brunei"), + ("Asia/Calcutta", "Asia/Calcutta"), + ("Asia/Chita", "Asia/Chita"), + ("Asia/Choibalsan", "Asia/Choibalsan"), + ("Asia/Chongqing", "Asia/Chongqing"), + ("Asia/Chungking", "Asia/Chungking"), + ("Asia/Colombo", "Asia/Colombo"), + ("Asia/Dacca", "Asia/Dacca"), + ("Asia/Damascus", "Asia/Damascus"), + ("Asia/Dhaka", "Asia/Dhaka"), + ("Asia/Dili", "Asia/Dili"), + ("Asia/Dubai", "Asia/Dubai"), + ("Asia/Dushanbe", "Asia/Dushanbe"), + ("Asia/Famagusta", "Asia/Famagusta"), + ("Asia/Gaza", "Asia/Gaza"), + ("Asia/Harbin", "Asia/Harbin"), + ("Asia/Hebron", "Asia/Hebron"), + ("Asia/Ho_Chi_Minh", "Asia/Ho_Chi_Minh"), + ("Asia/Hong_Kong", "Asia/Hong_Kong"), + ("Asia/Hovd", "Asia/Hovd"), + ("Asia/Irkutsk", "Asia/Irkutsk"), + ("Asia/Istanbul", "Asia/Istanbul"), + ("Asia/Jakarta", "Asia/Jakarta"), + ("Asia/Jayapura", "Asia/Jayapura"), + ("Asia/Jerusalem", "Asia/Jerusalem"), + ("Asia/Kabul", "Asia/Kabul"), + ("Asia/Kamchatka", "Asia/Kamchatka"), + ("Asia/Karachi", "Asia/Karachi"), + ("Asia/Kashgar", "Asia/Kashgar"), + ("Asia/Kathmandu", "Asia/Kathmandu"), + ("Asia/Katmandu", "Asia/Katmandu"), + ("Asia/Khandyga", "Asia/Khandyga"), + ("Asia/Kolkata", "Asia/Kolkata"), + ("Asia/Krasnoyarsk", "Asia/Krasnoyarsk"), + ("Asia/Kuala_Lumpur", "Asia/Kuala_Lumpur"), + ("Asia/Kuching", "Asia/Kuching"), + ("Asia/Kuwait", "Asia/Kuwait"), + ("Asia/Macao", "Asia/Macao"), + ("Asia/Macau", "Asia/Macau"), + ("Asia/Magadan", "Asia/Magadan"), + ("Asia/Makassar", "Asia/Makassar"), + ("Asia/Manila", "Asia/Manila"), + ("Asia/Muscat", "Asia/Muscat"), + ("Asia/Nicosia", "Asia/Nicosia"), + ("Asia/Novokuznetsk", "Asia/Novokuznetsk"), + ("Asia/Novosibirsk", "Asia/Novosibirsk"), + ("Asia/Omsk", "Asia/Omsk"), + ("Asia/Oral", "Asia/Oral"), + ("Asia/Phnom_Penh", "Asia/Phnom_Penh"), + ("Asia/Pontianak", "Asia/Pontianak"), + ("Asia/Pyongyang", "Asia/Pyongyang"), + ("Asia/Qatar", "Asia/Qatar"), + ("Asia/Qostanay", "Asia/Qostanay"), + ("Asia/Qyzylorda", "Asia/Qyzylorda"), + ("Asia/Rangoon", "Asia/Rangoon"), + ("Asia/Riyadh", "Asia/Riyadh"), + ("Asia/Saigon", "Asia/Saigon"), + ("Asia/Sakhalin", "Asia/Sakhalin"), + ("Asia/Samarkand", "Asia/Samarkand"), + ("Asia/Seoul", "Asia/Seoul"), + ("Asia/Shanghai", "Asia/Shanghai"), + ("Asia/Singapore", "Asia/Singapore"), + ("Asia/Srednekolymsk", "Asia/Srednekolymsk"), + ("Asia/Taipei", "Asia/Taipei"), + ("Asia/Tashkent", "Asia/Tashkent"), + ("Asia/Tbilisi", "Asia/Tbilisi"), + ("Asia/Tehran", "Asia/Tehran"), + ("Asia/Tel_Aviv", "Asia/Tel_Aviv"), + ("Asia/Thimbu", "Asia/Thimbu"), + ("Asia/Thimphu", "Asia/Thimphu"), + ("Asia/Tokyo", "Asia/Tokyo"), + ("Asia/Tomsk", "Asia/Tomsk"), + ("Asia/Ujung_Pandang", "Asia/Ujung_Pandang"), + ("Asia/Ulaanbaatar", "Asia/Ulaanbaatar"), + ("Asia/Ulan_Bator", "Asia/Ulan_Bator"), + ("Asia/Urumqi", "Asia/Urumqi"), + ("Asia/Ust-Nera", "Asia/Ust-Nera"), + ("Asia/Vientiane", "Asia/Vientiane"), + ("Asia/Vladivostok", "Asia/Vladivostok"), + ("Asia/Yakutsk", "Asia/Yakutsk"), + ("Asia/Yangon", "Asia/Yangon"), + ("Asia/Yekaterinburg", "Asia/Yekaterinburg"), + ("Asia/Yerevan", "Asia/Yerevan"), + ("Atlantic/Azores", "Atlantic/Azores"), + ("Atlantic/Bermuda", "Atlantic/Bermuda"), + ("Atlantic/Canary", "Atlantic/Canary"), + ("Atlantic/Cape_Verde", "Atlantic/Cape_Verde"), + ("Atlantic/Faeroe", "Atlantic/Faeroe"), + ("Atlantic/Faroe", "Atlantic/Faroe"), + ("Atlantic/Jan_Mayen", "Atlantic/Jan_Mayen"), + ("Atlantic/Madeira", "Atlantic/Madeira"), + ("Atlantic/Reykjavik", "Atlantic/Reykjavik"), + ("Atlantic/South_Georgia", "Atlantic/South_Georgia"), + ("Atlantic/St_Helena", "Atlantic/St_Helena"), + ("Atlantic/Stanley", "Atlantic/Stanley"), + ("Australia/ACT", "Australia/ACT"), + ("Australia/Adelaide", "Australia/Adelaide"), + ("Australia/Brisbane", "Australia/Brisbane"), + ("Australia/Broken_Hill", "Australia/Broken_Hill"), + ("Australia/Canberra", "Australia/Canberra"), + ("Australia/Currie", "Australia/Currie"), + ("Australia/Darwin", "Australia/Darwin"), + ("Australia/Eucla", "Australia/Eucla"), + ("Australia/Hobart", "Australia/Hobart"), + ("Australia/LHI", "Australia/LHI"), + ("Australia/Lindeman", "Australia/Lindeman"), + ("Australia/Lord_Howe", "Australia/Lord_Howe"), + ("Australia/Melbourne", "Australia/Melbourne"), + ("Australia/NSW", "Australia/NSW"), + ("Australia/North", "Australia/North"), + ("Australia/Perth", "Australia/Perth"), + ("Australia/Queensland", "Australia/Queensland"), + ("Australia/South", "Australia/South"), + ("Australia/Sydney", "Australia/Sydney"), + ("Australia/Tasmania", "Australia/Tasmania"), + ("Australia/Victoria", "Australia/Victoria"), + ("Australia/West", "Australia/West"), + ("Australia/Yancowinna", "Australia/Yancowinna"), + ("Brazil/Acre", "Brazil/Acre"), + ("Brazil/DeNoronha", "Brazil/DeNoronha"), + ("Brazil/East", "Brazil/East"), + ("Brazil/West", "Brazil/West"), + ("CET", "CET"), + ("CST6CDT", "CST6CDT"), + ("Canada/Atlantic", "Canada/Atlantic"), + ("Canada/Central", "Canada/Central"), + ("Canada/Eastern", "Canada/Eastern"), + ("Canada/Mountain", "Canada/Mountain"), + ("Canada/Newfoundland", "Canada/Newfoundland"), + ("Canada/Pacific", "Canada/Pacific"), + ("Canada/Saskatchewan", "Canada/Saskatchewan"), + ("Canada/Yukon", "Canada/Yukon"), + ("Chile/Continental", "Chile/Continental"), + ("Chile/EasterIsland", "Chile/EasterIsland"), + ("Cuba", "Cuba"), + ("EET", "EET"), + ("EST", "EST"), + ("EST5EDT", "EST5EDT"), + ("Egypt", "Egypt"), + ("Eire", "Eire"), + ("Etc/GMT", "Etc/GMT"), + ("Etc/GMT+0", "Etc/GMT+0"), + ("Etc/GMT+1", "Etc/GMT+1"), + ("Etc/GMT+10", "Etc/GMT+10"), + ("Etc/GMT+11", "Etc/GMT+11"), + ("Etc/GMT+12", "Etc/GMT+12"), + ("Etc/GMT+2", "Etc/GMT+2"), + ("Etc/GMT+3", "Etc/GMT+3"), + ("Etc/GMT+4", "Etc/GMT+4"), + ("Etc/GMT+5", "Etc/GMT+5"), + ("Etc/GMT+6", "Etc/GMT+6"), + ("Etc/GMT+7", "Etc/GMT+7"), + ("Etc/GMT+8", "Etc/GMT+8"), + ("Etc/GMT+9", "Etc/GMT+9"), + ("Etc/GMT-0", "Etc/GMT-0"), + ("Etc/GMT-1", "Etc/GMT-1"), + ("Etc/GMT-10", "Etc/GMT-10"), + ("Etc/GMT-11", "Etc/GMT-11"), + ("Etc/GMT-12", "Etc/GMT-12"), + ("Etc/GMT-13", "Etc/GMT-13"), + ("Etc/GMT-14", "Etc/GMT-14"), + ("Etc/GMT-2", "Etc/GMT-2"), + ("Etc/GMT-3", "Etc/GMT-3"), + ("Etc/GMT-4", "Etc/GMT-4"), + ("Etc/GMT-5", "Etc/GMT-5"), + ("Etc/GMT-6", "Etc/GMT-6"), + ("Etc/GMT-7", "Etc/GMT-7"), + ("Etc/GMT-8", "Etc/GMT-8"), + ("Etc/GMT-9", "Etc/GMT-9"), + ("Etc/GMT0", "Etc/GMT0"), + ("Etc/Greenwich", "Etc/Greenwich"), + ("Etc/UCT", "Etc/UCT"), + ("Etc/UTC", "Etc/UTC"), + ("Etc/Universal", "Etc/Universal"), + ("Etc/Zulu", "Etc/Zulu"), + ("Europe/Amsterdam", "Europe/Amsterdam"), + ("Europe/Andorra", "Europe/Andorra"), + ("Europe/Astrakhan", "Europe/Astrakhan"), + ("Europe/Athens", "Europe/Athens"), + ("Europe/Belfast", "Europe/Belfast"), + ("Europe/Belgrade", "Europe/Belgrade"), + ("Europe/Berlin", "Europe/Berlin"), + ("Europe/Bratislava", "Europe/Bratislava"), + ("Europe/Brussels", "Europe/Brussels"), + ("Europe/Bucharest", "Europe/Bucharest"), + ("Europe/Budapest", "Europe/Budapest"), + ("Europe/Busingen", "Europe/Busingen"), + ("Europe/Chisinau", "Europe/Chisinau"), + ("Europe/Copenhagen", "Europe/Copenhagen"), + ("Europe/Dublin", "Europe/Dublin"), + ("Europe/Gibraltar", "Europe/Gibraltar"), + ("Europe/Guernsey", "Europe/Guernsey"), + ("Europe/Helsinki", "Europe/Helsinki"), + ("Europe/Isle_of_Man", "Europe/Isle_of_Man"), + ("Europe/Istanbul", "Europe/Istanbul"), + ("Europe/Jersey", "Europe/Jersey"), + ("Europe/Kaliningrad", "Europe/Kaliningrad"), + ("Europe/Kiev", "Europe/Kiev"), + ("Europe/Kirov", "Europe/Kirov"), + ("Europe/Kyiv", "Europe/Kyiv"), + ("Europe/Lisbon", "Europe/Lisbon"), + ("Europe/Ljubljana", "Europe/Ljubljana"), + ("Europe/London", "Europe/London"), + ("Europe/Luxembourg", "Europe/Luxembourg"), + ("Europe/Madrid", "Europe/Madrid"), + ("Europe/Malta", "Europe/Malta"), + ("Europe/Mariehamn", "Europe/Mariehamn"), + ("Europe/Minsk", "Europe/Minsk"), + ("Europe/Monaco", "Europe/Monaco"), + ("Europe/Moscow", "Europe/Moscow"), + ("Europe/Nicosia", "Europe/Nicosia"), + ("Europe/Oslo", "Europe/Oslo"), + ("Europe/Paris", "Europe/Paris"), + ("Europe/Podgorica", "Europe/Podgorica"), + ("Europe/Prague", "Europe/Prague"), + ("Europe/Riga", "Europe/Riga"), + ("Europe/Rome", "Europe/Rome"), + ("Europe/Samara", "Europe/Samara"), + ("Europe/San_Marino", "Europe/San_Marino"), + ("Europe/Sarajevo", "Europe/Sarajevo"), + ("Europe/Saratov", "Europe/Saratov"), + ("Europe/Simferopol", "Europe/Simferopol"), + ("Europe/Skopje", "Europe/Skopje"), + ("Europe/Sofia", "Europe/Sofia"), + ("Europe/Stockholm", "Europe/Stockholm"), + ("Europe/Tallinn", "Europe/Tallinn"), + ("Europe/Tirane", "Europe/Tirane"), + ("Europe/Tiraspol", "Europe/Tiraspol"), + ("Europe/Ulyanovsk", "Europe/Ulyanovsk"), + ("Europe/Uzhgorod", "Europe/Uzhgorod"), + ("Europe/Vaduz", "Europe/Vaduz"), + ("Europe/Vatican", "Europe/Vatican"), + ("Europe/Vienna", "Europe/Vienna"), + ("Europe/Vilnius", "Europe/Vilnius"), + ("Europe/Volgograd", "Europe/Volgograd"), + ("Europe/Warsaw", "Europe/Warsaw"), + ("Europe/Zagreb", "Europe/Zagreb"), + ("Europe/Zaporozhye", "Europe/Zaporozhye"), + ("Europe/Zurich", "Europe/Zurich"), + ("Factory", "Factory"), + ("GB", "GB"), + ("GB-Eire", "GB-Eire"), + ("GMT", "GMT"), + ("GMT+0", "GMT+0"), + ("GMT-0", "GMT-0"), + ("GMT0", "GMT0"), + ("Greenwich", "Greenwich"), + ("HST", "HST"), + ("Hongkong", "Hongkong"), + ("Iceland", "Iceland"), + ("Indian/Antananarivo", "Indian/Antananarivo"), + ("Indian/Chagos", "Indian/Chagos"), + ("Indian/Christmas", "Indian/Christmas"), + ("Indian/Cocos", "Indian/Cocos"), + ("Indian/Comoro", "Indian/Comoro"), + ("Indian/Kerguelen", "Indian/Kerguelen"), + ("Indian/Mahe", "Indian/Mahe"), + ("Indian/Maldives", "Indian/Maldives"), + ("Indian/Mauritius", "Indian/Mauritius"), + ("Indian/Mayotte", "Indian/Mayotte"), + ("Indian/Reunion", "Indian/Reunion"), + ("Iran", "Iran"), + ("Israel", "Israel"), + ("Jamaica", "Jamaica"), + ("Japan", "Japan"), + ("Kwajalein", "Kwajalein"), + ("Libya", "Libya"), + ("MET", "MET"), + ("MST", "MST"), + ("MST7MDT", "MST7MDT"), + ("Mexico/BajaNorte", "Mexico/BajaNorte"), + ("Mexico/BajaSur", "Mexico/BajaSur"), + ("Mexico/General", "Mexico/General"), + ("NZ", "NZ"), + ("NZ-CHAT", "NZ-CHAT"), + ("Navajo", "Navajo"), + ("PRC", "PRC"), + ("PST8PDT", "PST8PDT"), + ("Pacific/Apia", "Pacific/Apia"), + ("Pacific/Auckland", "Pacific/Auckland"), + ("Pacific/Bougainville", "Pacific/Bougainville"), + ("Pacific/Chatham", "Pacific/Chatham"), + ("Pacific/Chuuk", "Pacific/Chuuk"), + ("Pacific/Easter", "Pacific/Easter"), + ("Pacific/Efate", "Pacific/Efate"), + ("Pacific/Enderbury", "Pacific/Enderbury"), + ("Pacific/Fakaofo", "Pacific/Fakaofo"), + ("Pacific/Fiji", "Pacific/Fiji"), + ("Pacific/Funafuti", "Pacific/Funafuti"), + ("Pacific/Galapagos", "Pacific/Galapagos"), + ("Pacific/Gambier", "Pacific/Gambier"), + ("Pacific/Guadalcanal", "Pacific/Guadalcanal"), + ("Pacific/Guam", "Pacific/Guam"), + ("Pacific/Honolulu", "Pacific/Honolulu"), + ("Pacific/Johnston", "Pacific/Johnston"), + ("Pacific/Kanton", "Pacific/Kanton"), + ("Pacific/Kiritimati", "Pacific/Kiritimati"), + ("Pacific/Kosrae", "Pacific/Kosrae"), + ("Pacific/Kwajalein", "Pacific/Kwajalein"), + ("Pacific/Majuro", "Pacific/Majuro"), + ("Pacific/Marquesas", "Pacific/Marquesas"), + ("Pacific/Midway", "Pacific/Midway"), + ("Pacific/Nauru", "Pacific/Nauru"), + ("Pacific/Niue", "Pacific/Niue"), + ("Pacific/Norfolk", "Pacific/Norfolk"), + ("Pacific/Noumea", "Pacific/Noumea"), + ("Pacific/Pago_Pago", "Pacific/Pago_Pago"), + ("Pacific/Palau", "Pacific/Palau"), + ("Pacific/Pitcairn", "Pacific/Pitcairn"), + ("Pacific/Pohnpei", "Pacific/Pohnpei"), + ("Pacific/Ponape", "Pacific/Ponape"), + ("Pacific/Port_Moresby", "Pacific/Port_Moresby"), + ("Pacific/Rarotonga", "Pacific/Rarotonga"), + ("Pacific/Saipan", "Pacific/Saipan"), + ("Pacific/Samoa", "Pacific/Samoa"), + ("Pacific/Tahiti", "Pacific/Tahiti"), + ("Pacific/Tarawa", "Pacific/Tarawa"), + ("Pacific/Tongatapu", "Pacific/Tongatapu"), + ("Pacific/Truk", "Pacific/Truk"), + ("Pacific/Wake", "Pacific/Wake"), + ("Pacific/Wallis", "Pacific/Wallis"), + ("Pacific/Yap", "Pacific/Yap"), + ("Poland", "Poland"), + ("Portugal", "Portugal"), + ("ROC", "ROC"), + ("ROK", "ROK"), + ("Singapore", "Singapore"), + ("Turkey", "Turkey"), + ("UCT", "UCT"), + ("US/Alaska", "US/Alaska"), + ("US/Aleutian", "US/Aleutian"), + ("US/Arizona", "US/Arizona"), + ("US/Central", "US/Central"), + ("US/East-Indiana", "US/East-Indiana"), + ("US/Eastern", "US/Eastern"), + ("US/Hawaii", "US/Hawaii"), + ("US/Indiana-Starke", "US/Indiana-Starke"), + ("US/Michigan", "US/Michigan"), + ("US/Mountain", "US/Mountain"), + ("US/Pacific", "US/Pacific"), + ("US/Samoa", "US/Samoa"), + ("UTC", "UTC"), + ("Universal", "Universal"), + ("W-SU", "W-SU"), + ("WET", "WET"), + ("Zulu", "Zulu"), + ], + default=aircox.models.schedule.current_timezone_key, + help_text="timezone used for the date", + max_length=100, + verbose_name="timezone", + ), + ), + migrations.AlterField( + model_name="staticpage", + name="attach_to", + field=models.SmallIntegerField( + blank=True, + choices=[ + (0, "Home page"), + (1, "Diffusions page"), + (2, "Logs page"), + (3, "Programs list"), + (4, "Episodes list"), + (5, "Articles list"), + (6, "Publications list"), + ], + help_text="display this page content to related element", + null=True, + verbose_name="attach to", + ), + ), + ] diff --git a/aircox/migrations/0015_program_editors.py b/aircox/migrations/0015_program_editors.py new file mode 100644 index 0000000..1a18ab5 --- /dev/null +++ b/aircox/migrations/0015_program_editors.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.5 on 2023-10-18 13:50 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + ("aircox", "0014_alter_schedule_timezone"), + ] + + operations = [ + migrations.AddField( + model_name="program", + name="editors_group", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="auth.group", + verbose_name="editors", + ), + ), + ] diff --git a/aircox/migrations/0016_alter_staticpage_attach_to.py b/aircox/migrations/0016_alter_staticpage_attach_to.py new file mode 100644 index 0000000..caaf307 --- /dev/null +++ b/aircox/migrations/0016_alter_staticpage_attach_to.py @@ -0,0 +1,32 @@ +# Generated by Django 4.2.1 on 2023-11-28 01:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("aircox", "0015_alter_schedule_timezone_alter_staticpage_attach_to"), + ] + + operations = [ + migrations.AlterField( + model_name="staticpage", + name="attach_to", + field=models.SmallIntegerField( + blank=True, + choices=[ + (0, "Home page"), + (1, "Diffusions page"), + (2, "Logs page"), + (3, "Programs list"), + (4, "Episodes list"), + (5, "Articles list"), + (6, "Publications list"), + (7, "Podcasts list"), + ], + help_text="display this page content to related element", + null=True, + verbose_name="attach to", + ), + ), + ] diff --git a/aircox/migrations/0017_alter_navitem_text_alter_staticpage_attach_to.py b/aircox/migrations/0017_alter_navitem_text_alter_staticpage_attach_to.py new file mode 100644 index 0000000..b6feff2 --- /dev/null +++ b/aircox/migrations/0017_alter_navitem_text_alter_staticpage_attach_to.py @@ -0,0 +1,36 @@ +# Generated by Django 4.2.1 on 2023-12-12 16:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("aircox", "0016_alter_staticpage_attach_to"), + ] + + operations = [ + migrations.AlterField( + model_name="navitem", + name="text", + field=models.CharField(blank=True, max_length=64, null=True, verbose_name="title"), + ), + migrations.AlterField( + model_name="staticpage", + name="attach_to", + field=models.SmallIntegerField( + blank=True, + choices=[ + (0, "Home page"), + (1, "Diffusions page"), + (3, "Programs list"), + (4, "Episodes list"), + (5, "Articles list"), + (6, "Publications list"), + (7, "Podcasts list"), + ], + help_text="display this page content to related element", + null=True, + verbose_name="attach to", + ), + ), + ] diff --git a/aircox/migrations/0018_alter_staticpage_attach_to.py b/aircox/migrations/0018_alter_staticpage_attach_to.py new file mode 100644 index 0000000..708ef19 --- /dev/null +++ b/aircox/migrations/0018_alter_staticpage_attach_to.py @@ -0,0 +1,32 @@ +# Generated by Django 4.2.1 on 2023-12-12 18:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("aircox", "0017_alter_navitem_text_alter_staticpage_attach_to"), + ] + + operations = [ + migrations.AlterField( + model_name="staticpage", + name="attach_to", + field=models.CharField( + blank=True, + choices=[ + ("", "Home Page"), + ("timetable-list", "Timetable"), + ("program-list", "Programs list"), + ("episode-list", "Episodes list"), + ("article-list", "Articles list"), + ("page-list", "Publications list"), + ("podcast-list", "Podcasts list"), + ], + help_text="display this page content to related element", + max_length=32, + null=True, + verbose_name="attach to", + ), + ), + ] diff --git a/aircox/migrations/0019_merge_20240119_1022.py b/aircox/migrations/0019_merge_20240119_1022.py new file mode 100644 index 0000000..26bc380 --- /dev/null +++ b/aircox/migrations/0019_merge_20240119_1022.py @@ -0,0 +1,12 @@ +# Generated by Django 4.2.7 on 2024-01-19 09:22 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("aircox", "0015_program_editors"), + ("aircox", "0018_alter_staticpage_attach_to"), + ] + + operations = [] diff --git a/aircox/migrations/0019_station_program_streams_title_and_more.py b/aircox/migrations/0019_station_program_streams_title_and_more.py new file mode 100644 index 0000000..20886b4 --- /dev/null +++ b/aircox/migrations/0019_station_program_streams_title_and_more.py @@ -0,0 +1,42 @@ +# Generated by Django 4.2.1 on 2024-02-01 18:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("aircox", "0018_alter_staticpage_attach_to"), + ] + + operations = [ + migrations.AddField( + model_name="station", + name="music_stream_title", + field=models.CharField( + default="Music stream", + max_length=64, + verbose_name="Music stream's title", + ), + ), + migrations.AlterField( + model_name="staticpage", + name="attach_to", + field=models.CharField( + blank=True, + choices=[ + ("", "None"), + ("home", "Home Page"), + ("timetable-list", "Timetable"), + ("program-list", "Programs list"), + ("episode-list", "Episodes list"), + ("article-list", "Articles list"), + ("page-list", "Publications list"), + ("podcast-list", "Podcasts list"), + ], + help_text="display this page content to related element", + max_length=32, + null=True, + verbose_name="attach to", + ), + ), + ] diff --git a/aircox/migrations/0020_merge_20240205_1027.py b/aircox/migrations/0020_merge_20240205_1027.py new file mode 100644 index 0000000..f52ae4c --- /dev/null +++ b/aircox/migrations/0020_merge_20240205_1027.py @@ -0,0 +1,12 @@ +# Generated by Django 4.2.7 on 2024-02-05 09:27 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("aircox", "0019_merge_20240119_1022"), + ("aircox", "0019_station_program_streams_title_and_more"), + ] + + operations = [] diff --git a/aircox/migrations/0021_alter_schedule_timezone.py b/aircox/migrations/0021_alter_schedule_timezone.py new file mode 100644 index 0000000..ef38808 --- /dev/null +++ b/aircox/migrations/0021_alter_schedule_timezone.py @@ -0,0 +1,623 @@ +# Generated by Django 4.2.7 on 2024-02-06 08:13 + +import aircox.models.schedule +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("aircox", "0020_merge_20240205_1027"), + ] + + operations = [ + migrations.AlterField( + model_name="schedule", + name="timezone", + field=models.CharField( + choices=[ + ("Africa/Abidjan", "Africa/Abidjan"), + ("Africa/Accra", "Africa/Accra"), + ("Africa/Addis_Ababa", "Africa/Addis_Ababa"), + ("Africa/Algiers", "Africa/Algiers"), + ("Africa/Asmara", "Africa/Asmara"), + ("Africa/Asmera", "Africa/Asmera"), + ("Africa/Bamako", "Africa/Bamako"), + ("Africa/Bangui", "Africa/Bangui"), + ("Africa/Banjul", "Africa/Banjul"), + ("Africa/Bissau", "Africa/Bissau"), + ("Africa/Blantyre", "Africa/Blantyre"), + ("Africa/Brazzaville", "Africa/Brazzaville"), + ("Africa/Bujumbura", "Africa/Bujumbura"), + ("Africa/Cairo", "Africa/Cairo"), + ("Africa/Casablanca", "Africa/Casablanca"), + ("Africa/Ceuta", "Africa/Ceuta"), + ("Africa/Conakry", "Africa/Conakry"), + ("Africa/Dakar", "Africa/Dakar"), + ("Africa/Dar_es_Salaam", "Africa/Dar_es_Salaam"), + ("Africa/Djibouti", "Africa/Djibouti"), + ("Africa/Douala", "Africa/Douala"), + ("Africa/El_Aaiun", "Africa/El_Aaiun"), + ("Africa/Freetown", "Africa/Freetown"), + ("Africa/Gaborone", "Africa/Gaborone"), + ("Africa/Harare", "Africa/Harare"), + ("Africa/Johannesburg", "Africa/Johannesburg"), + ("Africa/Juba", "Africa/Juba"), + ("Africa/Kampala", "Africa/Kampala"), + ("Africa/Khartoum", "Africa/Khartoum"), + ("Africa/Kigali", "Africa/Kigali"), + ("Africa/Kinshasa", "Africa/Kinshasa"), + ("Africa/Lagos", "Africa/Lagos"), + ("Africa/Libreville", "Africa/Libreville"), + ("Africa/Lome", "Africa/Lome"), + ("Africa/Luanda", "Africa/Luanda"), + ("Africa/Lubumbashi", "Africa/Lubumbashi"), + ("Africa/Lusaka", "Africa/Lusaka"), + ("Africa/Malabo", "Africa/Malabo"), + ("Africa/Maputo", "Africa/Maputo"), + ("Africa/Maseru", "Africa/Maseru"), + ("Africa/Mbabane", "Africa/Mbabane"), + ("Africa/Mogadishu", "Africa/Mogadishu"), + ("Africa/Monrovia", "Africa/Monrovia"), + ("Africa/Nairobi", "Africa/Nairobi"), + ("Africa/Ndjamena", "Africa/Ndjamena"), + ("Africa/Niamey", "Africa/Niamey"), + ("Africa/Nouakchott", "Africa/Nouakchott"), + ("Africa/Ouagadougou", "Africa/Ouagadougou"), + ("Africa/Porto-Novo", "Africa/Porto-Novo"), + ("Africa/Sao_Tome", "Africa/Sao_Tome"), + ("Africa/Timbuktu", "Africa/Timbuktu"), + ("Africa/Tripoli", "Africa/Tripoli"), + ("Africa/Tunis", "Africa/Tunis"), + ("Africa/Windhoek", "Africa/Windhoek"), + ("America/Adak", "America/Adak"), + ("America/Anchorage", "America/Anchorage"), + ("America/Anguilla", "America/Anguilla"), + ("America/Antigua", "America/Antigua"), + ("America/Araguaina", "America/Araguaina"), + ("America/Argentina/Buenos_Aires", "America/Argentina/Buenos_Aires"), + ("America/Argentina/Catamarca", "America/Argentina/Catamarca"), + ("America/Argentina/ComodRivadavia", "America/Argentina/ComodRivadavia"), + ("America/Argentina/Cordoba", "America/Argentina/Cordoba"), + ("America/Argentina/Jujuy", "America/Argentina/Jujuy"), + ("America/Argentina/La_Rioja", "America/Argentina/La_Rioja"), + ("America/Argentina/Mendoza", "America/Argentina/Mendoza"), + ("America/Argentina/Rio_Gallegos", "America/Argentina/Rio_Gallegos"), + ("America/Argentina/Salta", "America/Argentina/Salta"), + ("America/Argentina/San_Juan", "America/Argentina/San_Juan"), + ("America/Argentina/San_Luis", "America/Argentina/San_Luis"), + ("America/Argentina/Tucuman", "America/Argentina/Tucuman"), + ("America/Argentina/Ushuaia", "America/Argentina/Ushuaia"), + ("America/Aruba", "America/Aruba"), + ("America/Asuncion", "America/Asuncion"), + ("America/Atikokan", "America/Atikokan"), + ("America/Atka", "America/Atka"), + ("America/Bahia", "America/Bahia"), + ("America/Bahia_Banderas", "America/Bahia_Banderas"), + ("America/Barbados", "America/Barbados"), + ("America/Belem", "America/Belem"), + ("America/Belize", "America/Belize"), + ("America/Blanc-Sablon", "America/Blanc-Sablon"), + ("America/Boa_Vista", "America/Boa_Vista"), + ("America/Bogota", "America/Bogota"), + ("America/Boise", "America/Boise"), + ("America/Buenos_Aires", "America/Buenos_Aires"), + ("America/Cambridge_Bay", "America/Cambridge_Bay"), + ("America/Campo_Grande", "America/Campo_Grande"), + ("America/Cancun", "America/Cancun"), + ("America/Caracas", "America/Caracas"), + ("America/Catamarca", "America/Catamarca"), + ("America/Cayenne", "America/Cayenne"), + ("America/Cayman", "America/Cayman"), + ("America/Chicago", "America/Chicago"), + ("America/Chihuahua", "America/Chihuahua"), + ("America/Ciudad_Juarez", "America/Ciudad_Juarez"), + ("America/Coral_Harbour", "America/Coral_Harbour"), + ("America/Cordoba", "America/Cordoba"), + ("America/Costa_Rica", "America/Costa_Rica"), + ("America/Creston", "America/Creston"), + ("America/Cuiaba", "America/Cuiaba"), + ("America/Curacao", "America/Curacao"), + ("America/Danmarkshavn", "America/Danmarkshavn"), + ("America/Dawson", "America/Dawson"), + ("America/Dawson_Creek", "America/Dawson_Creek"), + ("America/Denver", "America/Denver"), + ("America/Detroit", "America/Detroit"), + ("America/Dominica", "America/Dominica"), + ("America/Edmonton", "America/Edmonton"), + ("America/Eirunepe", "America/Eirunepe"), + ("America/El_Salvador", "America/El_Salvador"), + ("America/Ensenada", "America/Ensenada"), + ("America/Fort_Nelson", "America/Fort_Nelson"), + ("America/Fort_Wayne", "America/Fort_Wayne"), + ("America/Fortaleza", "America/Fortaleza"), + ("America/Glace_Bay", "America/Glace_Bay"), + ("America/Godthab", "America/Godthab"), + ("America/Goose_Bay", "America/Goose_Bay"), + ("America/Grand_Turk", "America/Grand_Turk"), + ("America/Grenada", "America/Grenada"), + ("America/Guadeloupe", "America/Guadeloupe"), + ("America/Guatemala", "America/Guatemala"), + ("America/Guayaquil", "America/Guayaquil"), + ("America/Guyana", "America/Guyana"), + ("America/Halifax", "America/Halifax"), + ("America/Havana", "America/Havana"), + ("America/Hermosillo", "America/Hermosillo"), + ("America/Indiana/Indianapolis", "America/Indiana/Indianapolis"), + ("America/Indiana/Knox", "America/Indiana/Knox"), + ("America/Indiana/Marengo", "America/Indiana/Marengo"), + ("America/Indiana/Petersburg", "America/Indiana/Petersburg"), + ("America/Indiana/Tell_City", "America/Indiana/Tell_City"), + ("America/Indiana/Vevay", "America/Indiana/Vevay"), + ("America/Indiana/Vincennes", "America/Indiana/Vincennes"), + ("America/Indiana/Winamac", "America/Indiana/Winamac"), + ("America/Indianapolis", "America/Indianapolis"), + ("America/Inuvik", "America/Inuvik"), + ("America/Iqaluit", "America/Iqaluit"), + ("America/Jamaica", "America/Jamaica"), + ("America/Jujuy", "America/Jujuy"), + ("America/Juneau", "America/Juneau"), + ("America/Kentucky/Louisville", "America/Kentucky/Louisville"), + ("America/Kentucky/Monticello", "America/Kentucky/Monticello"), + ("America/Knox_IN", "America/Knox_IN"), + ("America/Kralendijk", "America/Kralendijk"), + ("America/La_Paz", "America/La_Paz"), + ("America/Lima", "America/Lima"), + ("America/Los_Angeles", "America/Los_Angeles"), + ("America/Louisville", "America/Louisville"), + ("America/Lower_Princes", "America/Lower_Princes"), + ("America/Maceio", "America/Maceio"), + ("America/Managua", "America/Managua"), + ("America/Manaus", "America/Manaus"), + ("America/Marigot", "America/Marigot"), + ("America/Martinique", "America/Martinique"), + ("America/Matamoros", "America/Matamoros"), + ("America/Mazatlan", "America/Mazatlan"), + ("America/Mendoza", "America/Mendoza"), + ("America/Menominee", "America/Menominee"), + ("America/Merida", "America/Merida"), + ("America/Metlakatla", "America/Metlakatla"), + ("America/Mexico_City", "America/Mexico_City"), + ("America/Miquelon", "America/Miquelon"), + ("America/Moncton", "America/Moncton"), + ("America/Monterrey", "America/Monterrey"), + ("America/Montevideo", "America/Montevideo"), + ("America/Montreal", "America/Montreal"), + ("America/Montserrat", "America/Montserrat"), + ("America/Nassau", "America/Nassau"), + ("America/New_York", "America/New_York"), + ("America/Nipigon", "America/Nipigon"), + ("America/Nome", "America/Nome"), + ("America/Noronha", "America/Noronha"), + ("America/North_Dakota/Beulah", "America/North_Dakota/Beulah"), + ("America/North_Dakota/Center", "America/North_Dakota/Center"), + ("America/North_Dakota/New_Salem", "America/North_Dakota/New_Salem"), + ("America/Nuuk", "America/Nuuk"), + ("America/Ojinaga", "America/Ojinaga"), + ("America/Panama", "America/Panama"), + ("America/Pangnirtung", "America/Pangnirtung"), + ("America/Paramaribo", "America/Paramaribo"), + ("America/Phoenix", "America/Phoenix"), + ("America/Port-au-Prince", "America/Port-au-Prince"), + ("America/Port_of_Spain", "America/Port_of_Spain"), + ("America/Porto_Acre", "America/Porto_Acre"), + ("America/Porto_Velho", "America/Porto_Velho"), + ("America/Puerto_Rico", "America/Puerto_Rico"), + ("America/Punta_Arenas", "America/Punta_Arenas"), + ("America/Rainy_River", "America/Rainy_River"), + ("America/Rankin_Inlet", "America/Rankin_Inlet"), + ("America/Recife", "America/Recife"), + ("America/Regina", "America/Regina"), + ("America/Resolute", "America/Resolute"), + ("America/Rio_Branco", "America/Rio_Branco"), + ("America/Rosario", "America/Rosario"), + ("America/Santa_Isabel", "America/Santa_Isabel"), + ("America/Santarem", "America/Santarem"), + ("America/Santiago", "America/Santiago"), + ("America/Santo_Domingo", "America/Santo_Domingo"), + ("America/Sao_Paulo", "America/Sao_Paulo"), + ("America/Scoresbysund", "America/Scoresbysund"), + ("America/Shiprock", "America/Shiprock"), + ("America/Sitka", "America/Sitka"), + ("America/St_Barthelemy", "America/St_Barthelemy"), + ("America/St_Johns", "America/St_Johns"), + ("America/St_Kitts", "America/St_Kitts"), + ("America/St_Lucia", "America/St_Lucia"), + ("America/St_Thomas", "America/St_Thomas"), + ("America/St_Vincent", "America/St_Vincent"), + ("America/Swift_Current", "America/Swift_Current"), + ("America/Tegucigalpa", "America/Tegucigalpa"), + ("America/Thule", "America/Thule"), + ("America/Thunder_Bay", "America/Thunder_Bay"), + ("America/Tijuana", "America/Tijuana"), + ("America/Toronto", "America/Toronto"), + ("America/Tortola", "America/Tortola"), + ("America/Vancouver", "America/Vancouver"), + ("America/Virgin", "America/Virgin"), + ("America/Whitehorse", "America/Whitehorse"), + ("America/Winnipeg", "America/Winnipeg"), + ("America/Yakutat", "America/Yakutat"), + ("America/Yellowknife", "America/Yellowknife"), + ("Antarctica/Casey", "Antarctica/Casey"), + ("Antarctica/Davis", "Antarctica/Davis"), + ("Antarctica/DumontDUrville", "Antarctica/DumontDUrville"), + ("Antarctica/Macquarie", "Antarctica/Macquarie"), + ("Antarctica/Mawson", "Antarctica/Mawson"), + ("Antarctica/McMurdo", "Antarctica/McMurdo"), + ("Antarctica/Palmer", "Antarctica/Palmer"), + ("Antarctica/Rothera", "Antarctica/Rothera"), + ("Antarctica/South_Pole", "Antarctica/South_Pole"), + ("Antarctica/Syowa", "Antarctica/Syowa"), + ("Antarctica/Troll", "Antarctica/Troll"), + ("Antarctica/Vostok", "Antarctica/Vostok"), + ("Arctic/Longyearbyen", "Arctic/Longyearbyen"), + ("Asia/Aden", "Asia/Aden"), + ("Asia/Almaty", "Asia/Almaty"), + ("Asia/Amman", "Asia/Amman"), + ("Asia/Anadyr", "Asia/Anadyr"), + ("Asia/Aqtau", "Asia/Aqtau"), + ("Asia/Aqtobe", "Asia/Aqtobe"), + ("Asia/Ashgabat", "Asia/Ashgabat"), + ("Asia/Ashkhabad", "Asia/Ashkhabad"), + ("Asia/Atyrau", "Asia/Atyrau"), + ("Asia/Baghdad", "Asia/Baghdad"), + ("Asia/Bahrain", "Asia/Bahrain"), + ("Asia/Baku", "Asia/Baku"), + ("Asia/Bangkok", "Asia/Bangkok"), + ("Asia/Barnaul", "Asia/Barnaul"), + ("Asia/Beirut", "Asia/Beirut"), + ("Asia/Bishkek", "Asia/Bishkek"), + ("Asia/Brunei", "Asia/Brunei"), + ("Asia/Calcutta", "Asia/Calcutta"), + ("Asia/Chita", "Asia/Chita"), + ("Asia/Choibalsan", "Asia/Choibalsan"), + ("Asia/Chongqing", "Asia/Chongqing"), + ("Asia/Chungking", "Asia/Chungking"), + ("Asia/Colombo", "Asia/Colombo"), + ("Asia/Dacca", "Asia/Dacca"), + ("Asia/Damascus", "Asia/Damascus"), + ("Asia/Dhaka", "Asia/Dhaka"), + ("Asia/Dili", "Asia/Dili"), + ("Asia/Dubai", "Asia/Dubai"), + ("Asia/Dushanbe", "Asia/Dushanbe"), + ("Asia/Famagusta", "Asia/Famagusta"), + ("Asia/Gaza", "Asia/Gaza"), + ("Asia/Harbin", "Asia/Harbin"), + ("Asia/Hebron", "Asia/Hebron"), + ("Asia/Ho_Chi_Minh", "Asia/Ho_Chi_Minh"), + ("Asia/Hong_Kong", "Asia/Hong_Kong"), + ("Asia/Hovd", "Asia/Hovd"), + ("Asia/Irkutsk", "Asia/Irkutsk"), + ("Asia/Istanbul", "Asia/Istanbul"), + ("Asia/Jakarta", "Asia/Jakarta"), + ("Asia/Jayapura", "Asia/Jayapura"), + ("Asia/Jerusalem", "Asia/Jerusalem"), + ("Asia/Kabul", "Asia/Kabul"), + ("Asia/Kamchatka", "Asia/Kamchatka"), + ("Asia/Karachi", "Asia/Karachi"), + ("Asia/Kashgar", "Asia/Kashgar"), + ("Asia/Kathmandu", "Asia/Kathmandu"), + ("Asia/Katmandu", "Asia/Katmandu"), + ("Asia/Khandyga", "Asia/Khandyga"), + ("Asia/Kolkata", "Asia/Kolkata"), + ("Asia/Krasnoyarsk", "Asia/Krasnoyarsk"), + ("Asia/Kuala_Lumpur", "Asia/Kuala_Lumpur"), + ("Asia/Kuching", "Asia/Kuching"), + ("Asia/Kuwait", "Asia/Kuwait"), + ("Asia/Macao", "Asia/Macao"), + ("Asia/Macau", "Asia/Macau"), + ("Asia/Magadan", "Asia/Magadan"), + ("Asia/Makassar", "Asia/Makassar"), + ("Asia/Manila", "Asia/Manila"), + ("Asia/Muscat", "Asia/Muscat"), + ("Asia/Nicosia", "Asia/Nicosia"), + ("Asia/Novokuznetsk", "Asia/Novokuznetsk"), + ("Asia/Novosibirsk", "Asia/Novosibirsk"), + ("Asia/Omsk", "Asia/Omsk"), + ("Asia/Oral", "Asia/Oral"), + ("Asia/Phnom_Penh", "Asia/Phnom_Penh"), + ("Asia/Pontianak", "Asia/Pontianak"), + ("Asia/Pyongyang", "Asia/Pyongyang"), + ("Asia/Qatar", "Asia/Qatar"), + ("Asia/Qostanay", "Asia/Qostanay"), + ("Asia/Qyzylorda", "Asia/Qyzylorda"), + ("Asia/Rangoon", "Asia/Rangoon"), + ("Asia/Riyadh", "Asia/Riyadh"), + ("Asia/Saigon", "Asia/Saigon"), + ("Asia/Sakhalin", "Asia/Sakhalin"), + ("Asia/Samarkand", "Asia/Samarkand"), + ("Asia/Seoul", "Asia/Seoul"), + ("Asia/Shanghai", "Asia/Shanghai"), + ("Asia/Singapore", "Asia/Singapore"), + ("Asia/Srednekolymsk", "Asia/Srednekolymsk"), + ("Asia/Taipei", "Asia/Taipei"), + ("Asia/Tashkent", "Asia/Tashkent"), + ("Asia/Tbilisi", "Asia/Tbilisi"), + ("Asia/Tehran", "Asia/Tehran"), + ("Asia/Tel_Aviv", "Asia/Tel_Aviv"), + ("Asia/Thimbu", "Asia/Thimbu"), + ("Asia/Thimphu", "Asia/Thimphu"), + ("Asia/Tokyo", "Asia/Tokyo"), + ("Asia/Tomsk", "Asia/Tomsk"), + ("Asia/Ujung_Pandang", "Asia/Ujung_Pandang"), + ("Asia/Ulaanbaatar", "Asia/Ulaanbaatar"), + ("Asia/Ulan_Bator", "Asia/Ulan_Bator"), + ("Asia/Urumqi", "Asia/Urumqi"), + ("Asia/Ust-Nera", "Asia/Ust-Nera"), + ("Asia/Vientiane", "Asia/Vientiane"), + ("Asia/Vladivostok", "Asia/Vladivostok"), + ("Asia/Yakutsk", "Asia/Yakutsk"), + ("Asia/Yangon", "Asia/Yangon"), + ("Asia/Yekaterinburg", "Asia/Yekaterinburg"), + ("Asia/Yerevan", "Asia/Yerevan"), + ("Atlantic/Azores", "Atlantic/Azores"), + ("Atlantic/Bermuda", "Atlantic/Bermuda"), + ("Atlantic/Canary", "Atlantic/Canary"), + ("Atlantic/Cape_Verde", "Atlantic/Cape_Verde"), + ("Atlantic/Faeroe", "Atlantic/Faeroe"), + ("Atlantic/Faroe", "Atlantic/Faroe"), + ("Atlantic/Jan_Mayen", "Atlantic/Jan_Mayen"), + ("Atlantic/Madeira", "Atlantic/Madeira"), + ("Atlantic/Reykjavik", "Atlantic/Reykjavik"), + ("Atlantic/South_Georgia", "Atlantic/South_Georgia"), + ("Atlantic/St_Helena", "Atlantic/St_Helena"), + ("Atlantic/Stanley", "Atlantic/Stanley"), + ("Australia/ACT", "Australia/ACT"), + ("Australia/Adelaide", "Australia/Adelaide"), + ("Australia/Brisbane", "Australia/Brisbane"), + ("Australia/Broken_Hill", "Australia/Broken_Hill"), + ("Australia/Canberra", "Australia/Canberra"), + ("Australia/Currie", "Australia/Currie"), + ("Australia/Darwin", "Australia/Darwin"), + ("Australia/Eucla", "Australia/Eucla"), + ("Australia/Hobart", "Australia/Hobart"), + ("Australia/LHI", "Australia/LHI"), + ("Australia/Lindeman", "Australia/Lindeman"), + ("Australia/Lord_Howe", "Australia/Lord_Howe"), + ("Australia/Melbourne", "Australia/Melbourne"), + ("Australia/NSW", "Australia/NSW"), + ("Australia/North", "Australia/North"), + ("Australia/Perth", "Australia/Perth"), + ("Australia/Queensland", "Australia/Queensland"), + ("Australia/South", "Australia/South"), + ("Australia/Sydney", "Australia/Sydney"), + ("Australia/Tasmania", "Australia/Tasmania"), + ("Australia/Victoria", "Australia/Victoria"), + ("Australia/West", "Australia/West"), + ("Australia/Yancowinna", "Australia/Yancowinna"), + ("Brazil/Acre", "Brazil/Acre"), + ("Brazil/DeNoronha", "Brazil/DeNoronha"), + ("Brazil/East", "Brazil/East"), + ("Brazil/West", "Brazil/West"), + ("CET", "CET"), + ("CST6CDT", "CST6CDT"), + ("Canada/Atlantic", "Canada/Atlantic"), + ("Canada/Central", "Canada/Central"), + ("Canada/Eastern", "Canada/Eastern"), + ("Canada/Mountain", "Canada/Mountain"), + ("Canada/Newfoundland", "Canada/Newfoundland"), + ("Canada/Pacific", "Canada/Pacific"), + ("Canada/Saskatchewan", "Canada/Saskatchewan"), + ("Canada/Yukon", "Canada/Yukon"), + ("Chile/Continental", "Chile/Continental"), + ("Chile/EasterIsland", "Chile/EasterIsland"), + ("Cuba", "Cuba"), + ("EET", "EET"), + ("EST", "EST"), + ("EST5EDT", "EST5EDT"), + ("Egypt", "Egypt"), + ("Eire", "Eire"), + ("Etc/GMT", "Etc/GMT"), + ("Etc/GMT+0", "Etc/GMT+0"), + ("Etc/GMT+1", "Etc/GMT+1"), + ("Etc/GMT+10", "Etc/GMT+10"), + ("Etc/GMT+11", "Etc/GMT+11"), + ("Etc/GMT+12", "Etc/GMT+12"), + ("Etc/GMT+2", "Etc/GMT+2"), + ("Etc/GMT+3", "Etc/GMT+3"), + ("Etc/GMT+4", "Etc/GMT+4"), + ("Etc/GMT+5", "Etc/GMT+5"), + ("Etc/GMT+6", "Etc/GMT+6"), + ("Etc/GMT+7", "Etc/GMT+7"), + ("Etc/GMT+8", "Etc/GMT+8"), + ("Etc/GMT+9", "Etc/GMT+9"), + ("Etc/GMT-0", "Etc/GMT-0"), + ("Etc/GMT-1", "Etc/GMT-1"), + ("Etc/GMT-10", "Etc/GMT-10"), + ("Etc/GMT-11", "Etc/GMT-11"), + ("Etc/GMT-12", "Etc/GMT-12"), + ("Etc/GMT-13", "Etc/GMT-13"), + ("Etc/GMT-14", "Etc/GMT-14"), + ("Etc/GMT-2", "Etc/GMT-2"), + ("Etc/GMT-3", "Etc/GMT-3"), + ("Etc/GMT-4", "Etc/GMT-4"), + ("Etc/GMT-5", "Etc/GMT-5"), + ("Etc/GMT-6", "Etc/GMT-6"), + ("Etc/GMT-7", "Etc/GMT-7"), + ("Etc/GMT-8", "Etc/GMT-8"), + ("Etc/GMT-9", "Etc/GMT-9"), + ("Etc/GMT0", "Etc/GMT0"), + ("Etc/Greenwich", "Etc/Greenwich"), + ("Etc/UCT", "Etc/UCT"), + ("Etc/UTC", "Etc/UTC"), + ("Etc/Universal", "Etc/Universal"), + ("Etc/Zulu", "Etc/Zulu"), + ("Europe/Amsterdam", "Europe/Amsterdam"), + ("Europe/Andorra", "Europe/Andorra"), + ("Europe/Astrakhan", "Europe/Astrakhan"), + ("Europe/Athens", "Europe/Athens"), + ("Europe/Belfast", "Europe/Belfast"), + ("Europe/Belgrade", "Europe/Belgrade"), + ("Europe/Berlin", "Europe/Berlin"), + ("Europe/Bratislava", "Europe/Bratislava"), + ("Europe/Brussels", "Europe/Brussels"), + ("Europe/Bucharest", "Europe/Bucharest"), + ("Europe/Budapest", "Europe/Budapest"), + ("Europe/Busingen", "Europe/Busingen"), + ("Europe/Chisinau", "Europe/Chisinau"), + ("Europe/Copenhagen", "Europe/Copenhagen"), + ("Europe/Dublin", "Europe/Dublin"), + ("Europe/Gibraltar", "Europe/Gibraltar"), + ("Europe/Guernsey", "Europe/Guernsey"), + ("Europe/Helsinki", "Europe/Helsinki"), + ("Europe/Isle_of_Man", "Europe/Isle_of_Man"), + ("Europe/Istanbul", "Europe/Istanbul"), + ("Europe/Jersey", "Europe/Jersey"), + ("Europe/Kaliningrad", "Europe/Kaliningrad"), + ("Europe/Kiev", "Europe/Kiev"), + ("Europe/Kirov", "Europe/Kirov"), + ("Europe/Kyiv", "Europe/Kyiv"), + ("Europe/Lisbon", "Europe/Lisbon"), + ("Europe/Ljubljana", "Europe/Ljubljana"), + ("Europe/London", "Europe/London"), + ("Europe/Luxembourg", "Europe/Luxembourg"), + ("Europe/Madrid", "Europe/Madrid"), + ("Europe/Malta", "Europe/Malta"), + ("Europe/Mariehamn", "Europe/Mariehamn"), + ("Europe/Minsk", "Europe/Minsk"), + ("Europe/Monaco", "Europe/Monaco"), + ("Europe/Moscow", "Europe/Moscow"), + ("Europe/Nicosia", "Europe/Nicosia"), + ("Europe/Oslo", "Europe/Oslo"), + ("Europe/Paris", "Europe/Paris"), + ("Europe/Podgorica", "Europe/Podgorica"), + ("Europe/Prague", "Europe/Prague"), + ("Europe/Riga", "Europe/Riga"), + ("Europe/Rome", "Europe/Rome"), + ("Europe/Samara", "Europe/Samara"), + ("Europe/San_Marino", "Europe/San_Marino"), + ("Europe/Sarajevo", "Europe/Sarajevo"), + ("Europe/Saratov", "Europe/Saratov"), + ("Europe/Simferopol", "Europe/Simferopol"), + ("Europe/Skopje", "Europe/Skopje"), + ("Europe/Sofia", "Europe/Sofia"), + ("Europe/Stockholm", "Europe/Stockholm"), + ("Europe/Tallinn", "Europe/Tallinn"), + ("Europe/Tirane", "Europe/Tirane"), + ("Europe/Tiraspol", "Europe/Tiraspol"), + ("Europe/Ulyanovsk", "Europe/Ulyanovsk"), + ("Europe/Uzhgorod", "Europe/Uzhgorod"), + ("Europe/Vaduz", "Europe/Vaduz"), + ("Europe/Vatican", "Europe/Vatican"), + ("Europe/Vienna", "Europe/Vienna"), + ("Europe/Vilnius", "Europe/Vilnius"), + ("Europe/Volgograd", "Europe/Volgograd"), + ("Europe/Warsaw", "Europe/Warsaw"), + ("Europe/Zagreb", "Europe/Zagreb"), + ("Europe/Zaporozhye", "Europe/Zaporozhye"), + ("Europe/Zurich", "Europe/Zurich"), + ("Factory", "Factory"), + ("GB", "GB"), + ("GB-Eire", "GB-Eire"), + ("GMT", "GMT"), + ("GMT+0", "GMT+0"), + ("GMT-0", "GMT-0"), + ("GMT0", "GMT0"), + ("Greenwich", "Greenwich"), + ("HST", "HST"), + ("Hongkong", "Hongkong"), + ("Iceland", "Iceland"), + ("Indian/Antananarivo", "Indian/Antananarivo"), + ("Indian/Chagos", "Indian/Chagos"), + ("Indian/Christmas", "Indian/Christmas"), + ("Indian/Cocos", "Indian/Cocos"), + ("Indian/Comoro", "Indian/Comoro"), + ("Indian/Kerguelen", "Indian/Kerguelen"), + ("Indian/Mahe", "Indian/Mahe"), + ("Indian/Maldives", "Indian/Maldives"), + ("Indian/Mauritius", "Indian/Mauritius"), + ("Indian/Mayotte", "Indian/Mayotte"), + ("Indian/Reunion", "Indian/Reunion"), + ("Iran", "Iran"), + ("Israel", "Israel"), + ("Jamaica", "Jamaica"), + ("Japan", "Japan"), + ("Kwajalein", "Kwajalein"), + ("Libya", "Libya"), + ("MET", "MET"), + ("MST", "MST"), + ("MST7MDT", "MST7MDT"), + ("Mexico/BajaNorte", "Mexico/BajaNorte"), + ("Mexico/BajaSur", "Mexico/BajaSur"), + ("Mexico/General", "Mexico/General"), + ("NZ", "NZ"), + ("NZ-CHAT", "NZ-CHAT"), + ("Navajo", "Navajo"), + ("PRC", "PRC"), + ("PST8PDT", "PST8PDT"), + ("Pacific/Apia", "Pacific/Apia"), + ("Pacific/Auckland", "Pacific/Auckland"), + ("Pacific/Bougainville", "Pacific/Bougainville"), + ("Pacific/Chatham", "Pacific/Chatham"), + ("Pacific/Chuuk", "Pacific/Chuuk"), + ("Pacific/Easter", "Pacific/Easter"), + ("Pacific/Efate", "Pacific/Efate"), + ("Pacific/Enderbury", "Pacific/Enderbury"), + ("Pacific/Fakaofo", "Pacific/Fakaofo"), + ("Pacific/Fiji", "Pacific/Fiji"), + ("Pacific/Funafuti", "Pacific/Funafuti"), + ("Pacific/Galapagos", "Pacific/Galapagos"), + ("Pacific/Gambier", "Pacific/Gambier"), + ("Pacific/Guadalcanal", "Pacific/Guadalcanal"), + ("Pacific/Guam", "Pacific/Guam"), + ("Pacific/Honolulu", "Pacific/Honolulu"), + ("Pacific/Johnston", "Pacific/Johnston"), + ("Pacific/Kanton", "Pacific/Kanton"), + ("Pacific/Kiritimati", "Pacific/Kiritimati"), + ("Pacific/Kosrae", "Pacific/Kosrae"), + ("Pacific/Kwajalein", "Pacific/Kwajalein"), + ("Pacific/Majuro", "Pacific/Majuro"), + ("Pacific/Marquesas", "Pacific/Marquesas"), + ("Pacific/Midway", "Pacific/Midway"), + ("Pacific/Nauru", "Pacific/Nauru"), + ("Pacific/Niue", "Pacific/Niue"), + ("Pacific/Norfolk", "Pacific/Norfolk"), + ("Pacific/Noumea", "Pacific/Noumea"), + ("Pacific/Pago_Pago", "Pacific/Pago_Pago"), + ("Pacific/Palau", "Pacific/Palau"), + ("Pacific/Pitcairn", "Pacific/Pitcairn"), + ("Pacific/Pohnpei", "Pacific/Pohnpei"), + ("Pacific/Ponape", "Pacific/Ponape"), + ("Pacific/Port_Moresby", "Pacific/Port_Moresby"), + ("Pacific/Rarotonga", "Pacific/Rarotonga"), + ("Pacific/Saipan", "Pacific/Saipan"), + ("Pacific/Samoa", "Pacific/Samoa"), + ("Pacific/Tahiti", "Pacific/Tahiti"), + ("Pacific/Tarawa", "Pacific/Tarawa"), + ("Pacific/Tongatapu", "Pacific/Tongatapu"), + ("Pacific/Truk", "Pacific/Truk"), + ("Pacific/Wake", "Pacific/Wake"), + ("Pacific/Wallis", "Pacific/Wallis"), + ("Pacific/Yap", "Pacific/Yap"), + ("Poland", "Poland"), + ("Portugal", "Portugal"), + ("ROC", "ROC"), + ("ROK", "ROK"), + ("Singapore", "Singapore"), + ("Turkey", "Turkey"), + ("UCT", "UCT"), + ("US/Alaska", "US/Alaska"), + ("US/Aleutian", "US/Aleutian"), + ("US/Arizona", "US/Arizona"), + ("US/Central", "US/Central"), + ("US/East-Indiana", "US/East-Indiana"), + ("US/Eastern", "US/Eastern"), + ("US/Hawaii", "US/Hawaii"), + ("US/Indiana-Starke", "US/Indiana-Starke"), + ("US/Michigan", "US/Michigan"), + ("US/Mountain", "US/Mountain"), + ("US/Pacific", "US/Pacific"), + ("US/Samoa", "US/Samoa"), + ("UTC", "UTC"), + ("Universal", "Universal"), + ("W-SU", "W-SU"), + ("WET", "WET"), + ("Zulu", "Zulu"), + ("localtime", "localtime"), + ], + default=aircox.models.schedule.current_timezone_key, + help_text="timezone used for the date", + max_length=100, + verbose_name="timezone", + ), + ), + ] diff --git a/aircox/migrations/0022_set_group_ownership.py b/aircox/migrations/0022_set_group_ownership.py new file mode 100644 index 0000000..365e709 --- /dev/null +++ b/aircox/migrations/0022_set_group_ownership.py @@ -0,0 +1,31 @@ +from django.db import migrations, models, transaction + + +def init_groups_and_permissions(app, schema_editor): + from aircox import permissions + + Program = app.get_model("aircox", "Program") + + with transaction.atomic(): + for program in Program.objects.all(): + permissions.program.init(program) + + +class Migration(migrations.Migration): + atomic = False + dependencies = [ + ("aircox", "0021_alter_schedule_timezone"), + ] + + operations = [ + migrations.RunPython(init_groups_and_permissions), + migrations.AlterField( + model_name="program", + name="editors_group", + field=models.ForeignKey( + on_delete=models.deletion.CASCADE, + to="auth.group", + verbose_name="editors", + ), + ), + ] diff --git a/aircox/migrations/0023_station_legal_label_alter_schedule_timezone.py b/aircox/migrations/0023_station_legal_label_alter_schedule_timezone.py new file mode 100644 index 0000000..814fba5 --- /dev/null +++ b/aircox/migrations/0023_station_legal_label_alter_schedule_timezone.py @@ -0,0 +1,634 @@ +# Generated by Django 4.2.9 on 2024-03-15 19:56 + +import aircox.models.schedule +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("filer", "0017_image__transparent"), + ("aircox", "0022_set_group_ownership"), + ] + + operations = [ + migrations.AddField( + model_name="station", + name="legal_label", + field=models.CharField( + blank=True, + default="", + help_text="Displayed at the bottom of pages.", + max_length=64, + verbose_name="Legal label", + ), + ), + migrations.AlterField( + model_name="schedule", + name="timezone", + field=models.CharField( + choices=[ + ("Africa/Abidjan", "Africa/Abidjan"), + ("Africa/Accra", "Africa/Accra"), + ("Africa/Addis_Ababa", "Africa/Addis_Ababa"), + ("Africa/Algiers", "Africa/Algiers"), + ("Africa/Asmara", "Africa/Asmara"), + ("Africa/Asmera", "Africa/Asmera"), + ("Africa/Bamako", "Africa/Bamako"), + ("Africa/Bangui", "Africa/Bangui"), + ("Africa/Banjul", "Africa/Banjul"), + ("Africa/Bissau", "Africa/Bissau"), + ("Africa/Blantyre", "Africa/Blantyre"), + ("Africa/Brazzaville", "Africa/Brazzaville"), + ("Africa/Bujumbura", "Africa/Bujumbura"), + ("Africa/Cairo", "Africa/Cairo"), + ("Africa/Casablanca", "Africa/Casablanca"), + ("Africa/Ceuta", "Africa/Ceuta"), + ("Africa/Conakry", "Africa/Conakry"), + ("Africa/Dakar", "Africa/Dakar"), + ("Africa/Dar_es_Salaam", "Africa/Dar_es_Salaam"), + ("Africa/Djibouti", "Africa/Djibouti"), + ("Africa/Douala", "Africa/Douala"), + ("Africa/El_Aaiun", "Africa/El_Aaiun"), + ("Africa/Freetown", "Africa/Freetown"), + ("Africa/Gaborone", "Africa/Gaborone"), + ("Africa/Harare", "Africa/Harare"), + ("Africa/Johannesburg", "Africa/Johannesburg"), + ("Africa/Juba", "Africa/Juba"), + ("Africa/Kampala", "Africa/Kampala"), + ("Africa/Khartoum", "Africa/Khartoum"), + ("Africa/Kigali", "Africa/Kigali"), + ("Africa/Kinshasa", "Africa/Kinshasa"), + ("Africa/Lagos", "Africa/Lagos"), + ("Africa/Libreville", "Africa/Libreville"), + ("Africa/Lome", "Africa/Lome"), + ("Africa/Luanda", "Africa/Luanda"), + ("Africa/Lubumbashi", "Africa/Lubumbashi"), + ("Africa/Lusaka", "Africa/Lusaka"), + ("Africa/Malabo", "Africa/Malabo"), + ("Africa/Maputo", "Africa/Maputo"), + ("Africa/Maseru", "Africa/Maseru"), + ("Africa/Mbabane", "Africa/Mbabane"), + ("Africa/Mogadishu", "Africa/Mogadishu"), + ("Africa/Monrovia", "Africa/Monrovia"), + ("Africa/Nairobi", "Africa/Nairobi"), + ("Africa/Ndjamena", "Africa/Ndjamena"), + ("Africa/Niamey", "Africa/Niamey"), + ("Africa/Nouakchott", "Africa/Nouakchott"), + ("Africa/Ouagadougou", "Africa/Ouagadougou"), + ("Africa/Porto-Novo", "Africa/Porto-Novo"), + ("Africa/Sao_Tome", "Africa/Sao_Tome"), + ("Africa/Timbuktu", "Africa/Timbuktu"), + ("Africa/Tripoli", "Africa/Tripoli"), + ("Africa/Tunis", "Africa/Tunis"), + ("Africa/Windhoek", "Africa/Windhoek"), + ("America/Adak", "America/Adak"), + ("America/Anchorage", "America/Anchorage"), + ("America/Anguilla", "America/Anguilla"), + ("America/Antigua", "America/Antigua"), + ("America/Araguaina", "America/Araguaina"), + ("America/Argentina/Buenos_Aires", "America/Argentina/Buenos_Aires"), + ("America/Argentina/Catamarca", "America/Argentina/Catamarca"), + ("America/Argentina/ComodRivadavia", "America/Argentina/ComodRivadavia"), + ("America/Argentina/Cordoba", "America/Argentina/Cordoba"), + ("America/Argentina/Jujuy", "America/Argentina/Jujuy"), + ("America/Argentina/La_Rioja", "America/Argentina/La_Rioja"), + ("America/Argentina/Mendoza", "America/Argentina/Mendoza"), + ("America/Argentina/Rio_Gallegos", "America/Argentina/Rio_Gallegos"), + ("America/Argentina/Salta", "America/Argentina/Salta"), + ("America/Argentina/San_Juan", "America/Argentina/San_Juan"), + ("America/Argentina/San_Luis", "America/Argentina/San_Luis"), + ("America/Argentina/Tucuman", "America/Argentina/Tucuman"), + ("America/Argentina/Ushuaia", "America/Argentina/Ushuaia"), + ("America/Aruba", "America/Aruba"), + ("America/Asuncion", "America/Asuncion"), + ("America/Atikokan", "America/Atikokan"), + ("America/Atka", "America/Atka"), + ("America/Bahia", "America/Bahia"), + ("America/Bahia_Banderas", "America/Bahia_Banderas"), + ("America/Barbados", "America/Barbados"), + ("America/Belem", "America/Belem"), + ("America/Belize", "America/Belize"), + ("America/Blanc-Sablon", "America/Blanc-Sablon"), + ("America/Boa_Vista", "America/Boa_Vista"), + ("America/Bogota", "America/Bogota"), + ("America/Boise", "America/Boise"), + ("America/Buenos_Aires", "America/Buenos_Aires"), + ("America/Cambridge_Bay", "America/Cambridge_Bay"), + ("America/Campo_Grande", "America/Campo_Grande"), + ("America/Cancun", "America/Cancun"), + ("America/Caracas", "America/Caracas"), + ("America/Catamarca", "America/Catamarca"), + ("America/Cayenne", "America/Cayenne"), + ("America/Cayman", "America/Cayman"), + ("America/Chicago", "America/Chicago"), + ("America/Chihuahua", "America/Chihuahua"), + ("America/Ciudad_Juarez", "America/Ciudad_Juarez"), + ("America/Coral_Harbour", "America/Coral_Harbour"), + ("America/Cordoba", "America/Cordoba"), + ("America/Costa_Rica", "America/Costa_Rica"), + ("America/Creston", "America/Creston"), + ("America/Cuiaba", "America/Cuiaba"), + ("America/Curacao", "America/Curacao"), + ("America/Danmarkshavn", "America/Danmarkshavn"), + ("America/Dawson", "America/Dawson"), + ("America/Dawson_Creek", "America/Dawson_Creek"), + ("America/Denver", "America/Denver"), + ("America/Detroit", "America/Detroit"), + ("America/Dominica", "America/Dominica"), + ("America/Edmonton", "America/Edmonton"), + ("America/Eirunepe", "America/Eirunepe"), + ("America/El_Salvador", "America/El_Salvador"), + ("America/Ensenada", "America/Ensenada"), + ("America/Fort_Nelson", "America/Fort_Nelson"), + ("America/Fort_Wayne", "America/Fort_Wayne"), + ("America/Fortaleza", "America/Fortaleza"), + ("America/Glace_Bay", "America/Glace_Bay"), + ("America/Godthab", "America/Godthab"), + ("America/Goose_Bay", "America/Goose_Bay"), + ("America/Grand_Turk", "America/Grand_Turk"), + ("America/Grenada", "America/Grenada"), + ("America/Guadeloupe", "America/Guadeloupe"), + ("America/Guatemala", "America/Guatemala"), + ("America/Guayaquil", "America/Guayaquil"), + ("America/Guyana", "America/Guyana"), + ("America/Halifax", "America/Halifax"), + ("America/Havana", "America/Havana"), + ("America/Hermosillo", "America/Hermosillo"), + ("America/Indiana/Indianapolis", "America/Indiana/Indianapolis"), + ("America/Indiana/Knox", "America/Indiana/Knox"), + ("America/Indiana/Marengo", "America/Indiana/Marengo"), + ("America/Indiana/Petersburg", "America/Indiana/Petersburg"), + ("America/Indiana/Tell_City", "America/Indiana/Tell_City"), + ("America/Indiana/Vevay", "America/Indiana/Vevay"), + ("America/Indiana/Vincennes", "America/Indiana/Vincennes"), + ("America/Indiana/Winamac", "America/Indiana/Winamac"), + ("America/Indianapolis", "America/Indianapolis"), + ("America/Inuvik", "America/Inuvik"), + ("America/Iqaluit", "America/Iqaluit"), + ("America/Jamaica", "America/Jamaica"), + ("America/Jujuy", "America/Jujuy"), + ("America/Juneau", "America/Juneau"), + ("America/Kentucky/Louisville", "America/Kentucky/Louisville"), + ("America/Kentucky/Monticello", "America/Kentucky/Monticello"), + ("America/Knox_IN", "America/Knox_IN"), + ("America/Kralendijk", "America/Kralendijk"), + ("America/La_Paz", "America/La_Paz"), + ("America/Lima", "America/Lima"), + ("America/Los_Angeles", "America/Los_Angeles"), + ("America/Louisville", "America/Louisville"), + ("America/Lower_Princes", "America/Lower_Princes"), + ("America/Maceio", "America/Maceio"), + ("America/Managua", "America/Managua"), + ("America/Manaus", "America/Manaus"), + ("America/Marigot", "America/Marigot"), + ("America/Martinique", "America/Martinique"), + ("America/Matamoros", "America/Matamoros"), + ("America/Mazatlan", "America/Mazatlan"), + ("America/Mendoza", "America/Mendoza"), + ("America/Menominee", "America/Menominee"), + ("America/Merida", "America/Merida"), + ("America/Metlakatla", "America/Metlakatla"), + ("America/Mexico_City", "America/Mexico_City"), + ("America/Miquelon", "America/Miquelon"), + ("America/Moncton", "America/Moncton"), + ("America/Monterrey", "America/Monterrey"), + ("America/Montevideo", "America/Montevideo"), + ("America/Montreal", "America/Montreal"), + ("America/Montserrat", "America/Montserrat"), + ("America/Nassau", "America/Nassau"), + ("America/New_York", "America/New_York"), + ("America/Nipigon", "America/Nipigon"), + ("America/Nome", "America/Nome"), + ("America/Noronha", "America/Noronha"), + ("America/North_Dakota/Beulah", "America/North_Dakota/Beulah"), + ("America/North_Dakota/Center", "America/North_Dakota/Center"), + ("America/North_Dakota/New_Salem", "America/North_Dakota/New_Salem"), + ("America/Nuuk", "America/Nuuk"), + ("America/Ojinaga", "America/Ojinaga"), + ("America/Panama", "America/Panama"), + ("America/Pangnirtung", "America/Pangnirtung"), + ("America/Paramaribo", "America/Paramaribo"), + ("America/Phoenix", "America/Phoenix"), + ("America/Port-au-Prince", "America/Port-au-Prince"), + ("America/Port_of_Spain", "America/Port_of_Spain"), + ("America/Porto_Acre", "America/Porto_Acre"), + ("America/Porto_Velho", "America/Porto_Velho"), + ("America/Puerto_Rico", "America/Puerto_Rico"), + ("America/Punta_Arenas", "America/Punta_Arenas"), + ("America/Rainy_River", "America/Rainy_River"), + ("America/Rankin_Inlet", "America/Rankin_Inlet"), + ("America/Recife", "America/Recife"), + ("America/Regina", "America/Regina"), + ("America/Resolute", "America/Resolute"), + ("America/Rio_Branco", "America/Rio_Branco"), + ("America/Rosario", "America/Rosario"), + ("America/Santa_Isabel", "America/Santa_Isabel"), + ("America/Santarem", "America/Santarem"), + ("America/Santiago", "America/Santiago"), + ("America/Santo_Domingo", "America/Santo_Domingo"), + ("America/Sao_Paulo", "America/Sao_Paulo"), + ("America/Scoresbysund", "America/Scoresbysund"), + ("America/Shiprock", "America/Shiprock"), + ("America/Sitka", "America/Sitka"), + ("America/St_Barthelemy", "America/St_Barthelemy"), + ("America/St_Johns", "America/St_Johns"), + ("America/St_Kitts", "America/St_Kitts"), + ("America/St_Lucia", "America/St_Lucia"), + ("America/St_Thomas", "America/St_Thomas"), + ("America/St_Vincent", "America/St_Vincent"), + ("America/Swift_Current", "America/Swift_Current"), + ("America/Tegucigalpa", "America/Tegucigalpa"), + ("America/Thule", "America/Thule"), + ("America/Thunder_Bay", "America/Thunder_Bay"), + ("America/Tijuana", "America/Tijuana"), + ("America/Toronto", "America/Toronto"), + ("America/Tortola", "America/Tortola"), + ("America/Vancouver", "America/Vancouver"), + ("America/Virgin", "America/Virgin"), + ("America/Whitehorse", "America/Whitehorse"), + ("America/Winnipeg", "America/Winnipeg"), + ("America/Yakutat", "America/Yakutat"), + ("America/Yellowknife", "America/Yellowknife"), + ("Antarctica/Casey", "Antarctica/Casey"), + ("Antarctica/Davis", "Antarctica/Davis"), + ("Antarctica/DumontDUrville", "Antarctica/DumontDUrville"), + ("Antarctica/Macquarie", "Antarctica/Macquarie"), + ("Antarctica/Mawson", "Antarctica/Mawson"), + ("Antarctica/McMurdo", "Antarctica/McMurdo"), + ("Antarctica/Palmer", "Antarctica/Palmer"), + ("Antarctica/Rothera", "Antarctica/Rothera"), + ("Antarctica/South_Pole", "Antarctica/South_Pole"), + ("Antarctica/Syowa", "Antarctica/Syowa"), + ("Antarctica/Troll", "Antarctica/Troll"), + ("Antarctica/Vostok", "Antarctica/Vostok"), + ("Arctic/Longyearbyen", "Arctic/Longyearbyen"), + ("Asia/Aden", "Asia/Aden"), + ("Asia/Almaty", "Asia/Almaty"), + ("Asia/Amman", "Asia/Amman"), + ("Asia/Anadyr", "Asia/Anadyr"), + ("Asia/Aqtau", "Asia/Aqtau"), + ("Asia/Aqtobe", "Asia/Aqtobe"), + ("Asia/Ashgabat", "Asia/Ashgabat"), + ("Asia/Ashkhabad", "Asia/Ashkhabad"), + ("Asia/Atyrau", "Asia/Atyrau"), + ("Asia/Baghdad", "Asia/Baghdad"), + ("Asia/Bahrain", "Asia/Bahrain"), + ("Asia/Baku", "Asia/Baku"), + ("Asia/Bangkok", "Asia/Bangkok"), + ("Asia/Barnaul", "Asia/Barnaul"), + ("Asia/Beirut", "Asia/Beirut"), + ("Asia/Bishkek", "Asia/Bishkek"), + ("Asia/Brunei", "Asia/Brunei"), + ("Asia/Calcutta", "Asia/Calcutta"), + ("Asia/Chita", "Asia/Chita"), + ("Asia/Choibalsan", "Asia/Choibalsan"), + ("Asia/Chongqing", "Asia/Chongqing"), + ("Asia/Chungking", "Asia/Chungking"), + ("Asia/Colombo", "Asia/Colombo"), + ("Asia/Dacca", "Asia/Dacca"), + ("Asia/Damascus", "Asia/Damascus"), + ("Asia/Dhaka", "Asia/Dhaka"), + ("Asia/Dili", "Asia/Dili"), + ("Asia/Dubai", "Asia/Dubai"), + ("Asia/Dushanbe", "Asia/Dushanbe"), + ("Asia/Famagusta", "Asia/Famagusta"), + ("Asia/Gaza", "Asia/Gaza"), + ("Asia/Harbin", "Asia/Harbin"), + ("Asia/Hebron", "Asia/Hebron"), + ("Asia/Ho_Chi_Minh", "Asia/Ho_Chi_Minh"), + ("Asia/Hong_Kong", "Asia/Hong_Kong"), + ("Asia/Hovd", "Asia/Hovd"), + ("Asia/Irkutsk", "Asia/Irkutsk"), + ("Asia/Istanbul", "Asia/Istanbul"), + ("Asia/Jakarta", "Asia/Jakarta"), + ("Asia/Jayapura", "Asia/Jayapura"), + ("Asia/Jerusalem", "Asia/Jerusalem"), + ("Asia/Kabul", "Asia/Kabul"), + ("Asia/Kamchatka", "Asia/Kamchatka"), + ("Asia/Karachi", "Asia/Karachi"), + ("Asia/Kashgar", "Asia/Kashgar"), + ("Asia/Kathmandu", "Asia/Kathmandu"), + ("Asia/Katmandu", "Asia/Katmandu"), + ("Asia/Khandyga", "Asia/Khandyga"), + ("Asia/Kolkata", "Asia/Kolkata"), + ("Asia/Krasnoyarsk", "Asia/Krasnoyarsk"), + ("Asia/Kuala_Lumpur", "Asia/Kuala_Lumpur"), + ("Asia/Kuching", "Asia/Kuching"), + ("Asia/Kuwait", "Asia/Kuwait"), + ("Asia/Macao", "Asia/Macao"), + ("Asia/Macau", "Asia/Macau"), + ("Asia/Magadan", "Asia/Magadan"), + ("Asia/Makassar", "Asia/Makassar"), + ("Asia/Manila", "Asia/Manila"), + ("Asia/Muscat", "Asia/Muscat"), + ("Asia/Nicosia", "Asia/Nicosia"), + ("Asia/Novokuznetsk", "Asia/Novokuznetsk"), + ("Asia/Novosibirsk", "Asia/Novosibirsk"), + ("Asia/Omsk", "Asia/Omsk"), + ("Asia/Oral", "Asia/Oral"), + ("Asia/Phnom_Penh", "Asia/Phnom_Penh"), + ("Asia/Pontianak", "Asia/Pontianak"), + ("Asia/Pyongyang", "Asia/Pyongyang"), + ("Asia/Qatar", "Asia/Qatar"), + ("Asia/Qostanay", "Asia/Qostanay"), + ("Asia/Qyzylorda", "Asia/Qyzylorda"), + ("Asia/Rangoon", "Asia/Rangoon"), + ("Asia/Riyadh", "Asia/Riyadh"), + ("Asia/Saigon", "Asia/Saigon"), + ("Asia/Sakhalin", "Asia/Sakhalin"), + ("Asia/Samarkand", "Asia/Samarkand"), + ("Asia/Seoul", "Asia/Seoul"), + ("Asia/Shanghai", "Asia/Shanghai"), + ("Asia/Singapore", "Asia/Singapore"), + ("Asia/Srednekolymsk", "Asia/Srednekolymsk"), + ("Asia/Taipei", "Asia/Taipei"), + ("Asia/Tashkent", "Asia/Tashkent"), + ("Asia/Tbilisi", "Asia/Tbilisi"), + ("Asia/Tehran", "Asia/Tehran"), + ("Asia/Tel_Aviv", "Asia/Tel_Aviv"), + ("Asia/Thimbu", "Asia/Thimbu"), + ("Asia/Thimphu", "Asia/Thimphu"), + ("Asia/Tokyo", "Asia/Tokyo"), + ("Asia/Tomsk", "Asia/Tomsk"), + ("Asia/Ujung_Pandang", "Asia/Ujung_Pandang"), + ("Asia/Ulaanbaatar", "Asia/Ulaanbaatar"), + ("Asia/Ulan_Bator", "Asia/Ulan_Bator"), + ("Asia/Urumqi", "Asia/Urumqi"), + ("Asia/Ust-Nera", "Asia/Ust-Nera"), + ("Asia/Vientiane", "Asia/Vientiane"), + ("Asia/Vladivostok", "Asia/Vladivostok"), + ("Asia/Yakutsk", "Asia/Yakutsk"), + ("Asia/Yangon", "Asia/Yangon"), + ("Asia/Yekaterinburg", "Asia/Yekaterinburg"), + ("Asia/Yerevan", "Asia/Yerevan"), + ("Atlantic/Azores", "Atlantic/Azores"), + ("Atlantic/Bermuda", "Atlantic/Bermuda"), + ("Atlantic/Canary", "Atlantic/Canary"), + ("Atlantic/Cape_Verde", "Atlantic/Cape_Verde"), + ("Atlantic/Faeroe", "Atlantic/Faeroe"), + ("Atlantic/Faroe", "Atlantic/Faroe"), + ("Atlantic/Jan_Mayen", "Atlantic/Jan_Mayen"), + ("Atlantic/Madeira", "Atlantic/Madeira"), + ("Atlantic/Reykjavik", "Atlantic/Reykjavik"), + ("Atlantic/South_Georgia", "Atlantic/South_Georgia"), + ("Atlantic/St_Helena", "Atlantic/St_Helena"), + ("Atlantic/Stanley", "Atlantic/Stanley"), + ("Australia/ACT", "Australia/ACT"), + ("Australia/Adelaide", "Australia/Adelaide"), + ("Australia/Brisbane", "Australia/Brisbane"), + ("Australia/Broken_Hill", "Australia/Broken_Hill"), + ("Australia/Canberra", "Australia/Canberra"), + ("Australia/Currie", "Australia/Currie"), + ("Australia/Darwin", "Australia/Darwin"), + ("Australia/Eucla", "Australia/Eucla"), + ("Australia/Hobart", "Australia/Hobart"), + ("Australia/LHI", "Australia/LHI"), + ("Australia/Lindeman", "Australia/Lindeman"), + ("Australia/Lord_Howe", "Australia/Lord_Howe"), + ("Australia/Melbourne", "Australia/Melbourne"), + ("Australia/NSW", "Australia/NSW"), + ("Australia/North", "Australia/North"), + ("Australia/Perth", "Australia/Perth"), + ("Australia/Queensland", "Australia/Queensland"), + ("Australia/South", "Australia/South"), + ("Australia/Sydney", "Australia/Sydney"), + ("Australia/Tasmania", "Australia/Tasmania"), + ("Australia/Victoria", "Australia/Victoria"), + ("Australia/West", "Australia/West"), + ("Australia/Yancowinna", "Australia/Yancowinna"), + ("Brazil/Acre", "Brazil/Acre"), + ("Brazil/DeNoronha", "Brazil/DeNoronha"), + ("Brazil/East", "Brazil/East"), + ("Brazil/West", "Brazil/West"), + ("CET", "CET"), + ("CST6CDT", "CST6CDT"), + ("Canada/Atlantic", "Canada/Atlantic"), + ("Canada/Central", "Canada/Central"), + ("Canada/Eastern", "Canada/Eastern"), + ("Canada/Mountain", "Canada/Mountain"), + ("Canada/Newfoundland", "Canada/Newfoundland"), + ("Canada/Pacific", "Canada/Pacific"), + ("Canada/Saskatchewan", "Canada/Saskatchewan"), + ("Canada/Yukon", "Canada/Yukon"), + ("Chile/Continental", "Chile/Continental"), + ("Chile/EasterIsland", "Chile/EasterIsland"), + ("Cuba", "Cuba"), + ("EET", "EET"), + ("EST", "EST"), + ("EST5EDT", "EST5EDT"), + ("Egypt", "Egypt"), + ("Eire", "Eire"), + ("Etc/GMT", "Etc/GMT"), + ("Etc/GMT+0", "Etc/GMT+0"), + ("Etc/GMT+1", "Etc/GMT+1"), + ("Etc/GMT+10", "Etc/GMT+10"), + ("Etc/GMT+11", "Etc/GMT+11"), + ("Etc/GMT+12", "Etc/GMT+12"), + ("Etc/GMT+2", "Etc/GMT+2"), + ("Etc/GMT+3", "Etc/GMT+3"), + ("Etc/GMT+4", "Etc/GMT+4"), + ("Etc/GMT+5", "Etc/GMT+5"), + ("Etc/GMT+6", "Etc/GMT+6"), + ("Etc/GMT+7", "Etc/GMT+7"), + ("Etc/GMT+8", "Etc/GMT+8"), + ("Etc/GMT+9", "Etc/GMT+9"), + ("Etc/GMT-0", "Etc/GMT-0"), + ("Etc/GMT-1", "Etc/GMT-1"), + ("Etc/GMT-10", "Etc/GMT-10"), + ("Etc/GMT-11", "Etc/GMT-11"), + ("Etc/GMT-12", "Etc/GMT-12"), + ("Etc/GMT-13", "Etc/GMT-13"), + ("Etc/GMT-14", "Etc/GMT-14"), + ("Etc/GMT-2", "Etc/GMT-2"), + ("Etc/GMT-3", "Etc/GMT-3"), + ("Etc/GMT-4", "Etc/GMT-4"), + ("Etc/GMT-5", "Etc/GMT-5"), + ("Etc/GMT-6", "Etc/GMT-6"), + ("Etc/GMT-7", "Etc/GMT-7"), + ("Etc/GMT-8", "Etc/GMT-8"), + ("Etc/GMT-9", "Etc/GMT-9"), + ("Etc/GMT0", "Etc/GMT0"), + ("Etc/Greenwich", "Etc/Greenwich"), + ("Etc/UCT", "Etc/UCT"), + ("Etc/UTC", "Etc/UTC"), + ("Etc/Universal", "Etc/Universal"), + ("Etc/Zulu", "Etc/Zulu"), + ("Europe/Amsterdam", "Europe/Amsterdam"), + ("Europe/Andorra", "Europe/Andorra"), + ("Europe/Astrakhan", "Europe/Astrakhan"), + ("Europe/Athens", "Europe/Athens"), + ("Europe/Belfast", "Europe/Belfast"), + ("Europe/Belgrade", "Europe/Belgrade"), + ("Europe/Berlin", "Europe/Berlin"), + ("Europe/Bratislava", "Europe/Bratislava"), + ("Europe/Brussels", "Europe/Brussels"), + ("Europe/Bucharest", "Europe/Bucharest"), + ("Europe/Budapest", "Europe/Budapest"), + ("Europe/Busingen", "Europe/Busingen"), + ("Europe/Chisinau", "Europe/Chisinau"), + ("Europe/Copenhagen", "Europe/Copenhagen"), + ("Europe/Dublin", "Europe/Dublin"), + ("Europe/Gibraltar", "Europe/Gibraltar"), + ("Europe/Guernsey", "Europe/Guernsey"), + ("Europe/Helsinki", "Europe/Helsinki"), + ("Europe/Isle_of_Man", "Europe/Isle_of_Man"), + ("Europe/Istanbul", "Europe/Istanbul"), + ("Europe/Jersey", "Europe/Jersey"), + ("Europe/Kaliningrad", "Europe/Kaliningrad"), + ("Europe/Kiev", "Europe/Kiev"), + ("Europe/Kirov", "Europe/Kirov"), + ("Europe/Kyiv", "Europe/Kyiv"), + ("Europe/Lisbon", "Europe/Lisbon"), + ("Europe/Ljubljana", "Europe/Ljubljana"), + ("Europe/London", "Europe/London"), + ("Europe/Luxembourg", "Europe/Luxembourg"), + ("Europe/Madrid", "Europe/Madrid"), + ("Europe/Malta", "Europe/Malta"), + ("Europe/Mariehamn", "Europe/Mariehamn"), + ("Europe/Minsk", "Europe/Minsk"), + ("Europe/Monaco", "Europe/Monaco"), + ("Europe/Moscow", "Europe/Moscow"), + ("Europe/Nicosia", "Europe/Nicosia"), + ("Europe/Oslo", "Europe/Oslo"), + ("Europe/Paris", "Europe/Paris"), + ("Europe/Podgorica", "Europe/Podgorica"), + ("Europe/Prague", "Europe/Prague"), + ("Europe/Riga", "Europe/Riga"), + ("Europe/Rome", "Europe/Rome"), + ("Europe/Samara", "Europe/Samara"), + ("Europe/San_Marino", "Europe/San_Marino"), + ("Europe/Sarajevo", "Europe/Sarajevo"), + ("Europe/Saratov", "Europe/Saratov"), + ("Europe/Simferopol", "Europe/Simferopol"), + ("Europe/Skopje", "Europe/Skopje"), + ("Europe/Sofia", "Europe/Sofia"), + ("Europe/Stockholm", "Europe/Stockholm"), + ("Europe/Tallinn", "Europe/Tallinn"), + ("Europe/Tirane", "Europe/Tirane"), + ("Europe/Tiraspol", "Europe/Tiraspol"), + ("Europe/Ulyanovsk", "Europe/Ulyanovsk"), + ("Europe/Uzhgorod", "Europe/Uzhgorod"), + ("Europe/Vaduz", "Europe/Vaduz"), + ("Europe/Vatican", "Europe/Vatican"), + ("Europe/Vienna", "Europe/Vienna"), + ("Europe/Vilnius", "Europe/Vilnius"), + ("Europe/Volgograd", "Europe/Volgograd"), + ("Europe/Warsaw", "Europe/Warsaw"), + ("Europe/Zagreb", "Europe/Zagreb"), + ("Europe/Zaporozhye", "Europe/Zaporozhye"), + ("Europe/Zurich", "Europe/Zurich"), + ("Factory", "Factory"), + ("GB", "GB"), + ("GB-Eire", "GB-Eire"), + ("GMT", "GMT"), + ("GMT+0", "GMT+0"), + ("GMT-0", "GMT-0"), + ("GMT0", "GMT0"), + ("Greenwich", "Greenwich"), + ("HST", "HST"), + ("Hongkong", "Hongkong"), + ("Iceland", "Iceland"), + ("Indian/Antananarivo", "Indian/Antananarivo"), + ("Indian/Chagos", "Indian/Chagos"), + ("Indian/Christmas", "Indian/Christmas"), + ("Indian/Cocos", "Indian/Cocos"), + ("Indian/Comoro", "Indian/Comoro"), + ("Indian/Kerguelen", "Indian/Kerguelen"), + ("Indian/Mahe", "Indian/Mahe"), + ("Indian/Maldives", "Indian/Maldives"), + ("Indian/Mauritius", "Indian/Mauritius"), + ("Indian/Mayotte", "Indian/Mayotte"), + ("Indian/Reunion", "Indian/Reunion"), + ("Iran", "Iran"), + ("Israel", "Israel"), + ("Jamaica", "Jamaica"), + ("Japan", "Japan"), + ("Kwajalein", "Kwajalein"), + ("Libya", "Libya"), + ("MET", "MET"), + ("MST", "MST"), + ("MST7MDT", "MST7MDT"), + ("Mexico/BajaNorte", "Mexico/BajaNorte"), + ("Mexico/BajaSur", "Mexico/BajaSur"), + ("Mexico/General", "Mexico/General"), + ("NZ", "NZ"), + ("NZ-CHAT", "NZ-CHAT"), + ("Navajo", "Navajo"), + ("PRC", "PRC"), + ("PST8PDT", "PST8PDT"), + ("Pacific/Apia", "Pacific/Apia"), + ("Pacific/Auckland", "Pacific/Auckland"), + ("Pacific/Bougainville", "Pacific/Bougainville"), + ("Pacific/Chatham", "Pacific/Chatham"), + ("Pacific/Chuuk", "Pacific/Chuuk"), + ("Pacific/Easter", "Pacific/Easter"), + ("Pacific/Efate", "Pacific/Efate"), + ("Pacific/Enderbury", "Pacific/Enderbury"), + ("Pacific/Fakaofo", "Pacific/Fakaofo"), + ("Pacific/Fiji", "Pacific/Fiji"), + ("Pacific/Funafuti", "Pacific/Funafuti"), + ("Pacific/Galapagos", "Pacific/Galapagos"), + ("Pacific/Gambier", "Pacific/Gambier"), + ("Pacific/Guadalcanal", "Pacific/Guadalcanal"), + ("Pacific/Guam", "Pacific/Guam"), + ("Pacific/Honolulu", "Pacific/Honolulu"), + ("Pacific/Johnston", "Pacific/Johnston"), + ("Pacific/Kanton", "Pacific/Kanton"), + ("Pacific/Kiritimati", "Pacific/Kiritimati"), + ("Pacific/Kosrae", "Pacific/Kosrae"), + ("Pacific/Kwajalein", "Pacific/Kwajalein"), + ("Pacific/Majuro", "Pacific/Majuro"), + ("Pacific/Marquesas", "Pacific/Marquesas"), + ("Pacific/Midway", "Pacific/Midway"), + ("Pacific/Nauru", "Pacific/Nauru"), + ("Pacific/Niue", "Pacific/Niue"), + ("Pacific/Norfolk", "Pacific/Norfolk"), + ("Pacific/Noumea", "Pacific/Noumea"), + ("Pacific/Pago_Pago", "Pacific/Pago_Pago"), + ("Pacific/Palau", "Pacific/Palau"), + ("Pacific/Pitcairn", "Pacific/Pitcairn"), + ("Pacific/Pohnpei", "Pacific/Pohnpei"), + ("Pacific/Ponape", "Pacific/Ponape"), + ("Pacific/Port_Moresby", "Pacific/Port_Moresby"), + ("Pacific/Rarotonga", "Pacific/Rarotonga"), + ("Pacific/Saipan", "Pacific/Saipan"), + ("Pacific/Samoa", "Pacific/Samoa"), + ("Pacific/Tahiti", "Pacific/Tahiti"), + ("Pacific/Tarawa", "Pacific/Tarawa"), + ("Pacific/Tongatapu", "Pacific/Tongatapu"), + ("Pacific/Truk", "Pacific/Truk"), + ("Pacific/Wake", "Pacific/Wake"), + ("Pacific/Wallis", "Pacific/Wallis"), + ("Pacific/Yap", "Pacific/Yap"), + ("Poland", "Poland"), + ("Portugal", "Portugal"), + ("ROC", "ROC"), + ("ROK", "ROK"), + ("Singapore", "Singapore"), + ("Turkey", "Turkey"), + ("UCT", "UCT"), + ("US/Alaska", "US/Alaska"), + ("US/Aleutian", "US/Aleutian"), + ("US/Arizona", "US/Arizona"), + ("US/Central", "US/Central"), + ("US/East-Indiana", "US/East-Indiana"), + ("US/Eastern", "US/Eastern"), + ("US/Hawaii", "US/Hawaii"), + ("US/Indiana-Starke", "US/Indiana-Starke"), + ("US/Michigan", "US/Michigan"), + ("US/Mountain", "US/Mountain"), + ("US/Pacific", "US/Pacific"), + ("US/Samoa", "US/Samoa"), + ("UTC", "UTC"), + ("Universal", "Universal"), + ("W-SU", "W-SU"), + ("WET", "WET"), + ("Zulu", "Zulu"), + ], + default=aircox.models.schedule.current_timezone_key, + help_text="timezone used for the date", + max_length=100, + verbose_name="timezone", + ), + ), + ] diff --git a/aircox/migrations/0024_rename_playlist_editor_columns_usersettings_tracklist_editor_columns_and_more.py b/aircox/migrations/0024_rename_playlist_editor_columns_usersettings_tracklist_editor_columns_and_more.py new file mode 100644 index 0000000..99e65e9 --- /dev/null +++ b/aircox/migrations/0024_rename_playlist_editor_columns_usersettings_tracklist_editor_columns_and_more.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.9 on 2024-03-19 22:38 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("aircox", "0023_station_legal_label_alter_schedule_timezone"), + ] + + operations = [ + migrations.RenameField( + model_name="usersettings", + old_name="playlist_editor_columns", + new_name="tracklist_editor_columns", + ), + migrations.RenameField( + model_name="usersettings", + old_name="playlist_editor_sep", + new_name="tracklist_editor_sep", + ), + ] diff --git a/aircox/migrations/0025_sound_is_removed_alter_sound_is_downloadable_and_more.py b/aircox/migrations/0025_sound_is_removed_alter_sound_is_downloadable_and_more.py new file mode 100644 index 0000000..474fa8c --- /dev/null +++ b/aircox/migrations/0025_sound_is_removed_alter_sound_is_downloadable_and_more.py @@ -0,0 +1,36 @@ +# Generated by Django 4.2.9 on 2024-03-25 20:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("aircox", "0024_rename_playlist_editor_columns_usersettings_tracklist_editor_columns_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="sound", + name="is_removed", + field=models.BooleanField(default=False, help_text="file has been removed", verbose_name="removed"), + ), + migrations.AlterField( + model_name="sound", + name="is_downloadable", + field=models.BooleanField( + default=False, + help_text="sound can be downloaded by visitors (sound must be public)", + verbose_name="downloadable", + ), + ), + migrations.AlterField( + model_name="sound", + name="is_public", + field=models.BooleanField(default=False, help_text="sound is available as podcast", verbose_name="public"), + ), + migrations.AlterField( + model_name="sound", + name="type", + field=models.SmallIntegerField(choices=[(0, "other"), (1, "archive"), (2, "excerpt")], verbose_name="type"), + ), + ] diff --git a/aircox/migrations/0026_alter_sound_options_remove_sound_episode_and_more.py b/aircox/migrations/0026_alter_sound_options_remove_sound_episode_and_more.py new file mode 100644 index 0000000..83d1916 --- /dev/null +++ b/aircox/migrations/0026_alter_sound_options_remove_sound_episode_and_more.py @@ -0,0 +1,162 @@ +# Generated by Django 4.2.9 on 2024-03-26 02:53 + +import aircox.models.file +from django.db import migrations, models +import django.db.models.deletion + + +sounds_info = {} + + +def get_sounds_info(apps, schema_editor): + Sound = apps.get_model("aircox", "Sound") + objs = Sound.objects.filter().values( + "pk", + "episode_id", + "position", + "type", + ) + sounds_info.update({obj["pk"]: obj for obj in objs}) + + +def restore_sounds_info(apps, schema_editor): + try: + Sound = apps.get_model("aircox", "Sound") + EpisodeSound = apps.get_model("aircox", "EpisodeSound") + TYPE_ARCHIVE = 0x01 + TYPE_REMOVED = 0x03 + + episode_sounds = [] + sounds = [] + for sound in Sound.objects.all(): + info = sounds_info.get(sound.pk) + if not info: + continue + + sound.broadcast = info["type"] == TYPE_ARCHIVE + sound.is_removed = info["type"] == TYPE_REMOVED + sounds.append(sound) + if not sound.is_removed and info["episode_id"]: + obj = EpisodeSound( + sound=sound, + episode_id=info["episode_id"], + position=info["position"], + broadcast=sound.broadcast, + ) + episode_sounds.append(obj) + + Sound.objects.bulk_update(sounds, ("broadcast", "is_removed")) + EpisodeSound.objects.bulk_create(episode_sounds) + + print(f"\n>>> {len(sounds)} Sound have been updated.") + print(f">>> {len(episode_sounds)} EpisodeSound have been created.") + except Exception as err: + print(err) + import traceback + + traceback.print_exc() + + +class Migration(migrations.Migration): + dependencies = [ + ("aircox", "0025_sound_is_removed_alter_sound_is_downloadable_and_more"), + ] + + operations = [ + migrations.RunPython(get_sounds_info), + migrations.AlterModelOptions( + name="sound", + options={"verbose_name": "Sound file", "verbose_name_plural": "Sound files"}, + ), + migrations.RemoveField( + model_name="sound", + name="episode", + ), + migrations.RemoveField( + model_name="sound", + name="position", + ), + migrations.RemoveField( + model_name="sound", + name="type", + ), + migrations.AddField( + model_name="sound", + name="broadcast", + field=models.BooleanField( + default=False, help_text="The sound is broadcasted on air", verbose_name="Broadcast" + ), + ), + migrations.AddField( + model_name="sound", + name="description", + field=models.TextField(blank=True, default="", max_length=256, verbose_name="description"), + ), + migrations.AlterField( + model_name="sound", + name="file", + field=models.FileField( + db_index=True, max_length=256, upload_to=aircox.models.file.File._upload_to, verbose_name="file" + ), + ), + migrations.AlterField( + model_name="sound", + name="is_downloadable", + field=models.BooleanField( + default=False, help_text="sound can be downloaded by visitors", verbose_name="downloadable" + ), + ), + migrations.AlterField( + model_name="sound", + name="is_public", + field=models.BooleanField(default=False, help_text="file is publicly accessible", verbose_name="public"), + ), + migrations.AlterField( + model_name="sound", + name="is_removed", + field=models.BooleanField( + db_index=True, default=False, help_text="file has been removed from server", verbose_name="removed" + ), + ), + migrations.AlterField( + model_name="sound", + name="name", + field=models.CharField(db_index=True, max_length=64, verbose_name="name"), + ), + migrations.AlterField( + model_name="sound", + name="program", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="aircox.program", + verbose_name="Program", + ), + ), + migrations.CreateModel( + name="EpisodeSound", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "position", + models.PositiveSmallIntegerField( + default=0, help_text="position in the playlist", verbose_name="order" + ), + ), + ( + "broadcast", + models.BooleanField( + blank=None, help_text="The sound is broadcasted on air", verbose_name="Broadcast" + ), + ), + ("episode", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="aircox.episode")), + ("sound", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="aircox.sound")), + ], + options={ + "verbose_name": "Episode Sound", + "verbose_name_plural": "Episode Sounds", + }, + ), + migrations.RunPython(restore_sounds_info), + ] diff --git a/aircox/migrations/0027_remove_page_parent_remove_staticpage_parent_and_more.py b/aircox/migrations/0027_remove_page_parent_remove_staticpage_parent_and_more.py new file mode 100644 index 0000000..b9a397f --- /dev/null +++ b/aircox/migrations/0027_remove_page_parent_remove_staticpage_parent_and_more.py @@ -0,0 +1,78 @@ +# Generated by Django 5.0 on 2024-04-10 08:38 + +import django.db.models.deletion +from django.db import migrations, models + + +children_infos = {} + + +def get_children_infos(apps, schema_editor): + Page = apps.get_model("aircox", "page") + query = Page.objects.filter(parent__isnull=False).values("pk", "parent_id", "category_id", "parent__category_id") + children_infos.update((r["pk"], r) for r in query) + + +def restore_children_infos(apps, schema_editor): + Episode = apps.get_model("aircox", "Episode") + + pks = set(children_infos.keys()) + eps = _restore_for_objs(Episode.objects.filter(pk__in=pks)) + Episode.objects.bulk_update(eps, ("parent_id", "category_id")) + print(f">> {len(eps)} episodes restored") + + +def _restore_for_objs(objs): + updated = [] + for obj in objs: + info = children_infos.get(obj.pk) + if info: + obj.parent_id = info["parent_id"] + obj.category_id = info["category_id"] or info["parent__category_id"] + updated.append(obj) + return updated + + +class Migration(migrations.Migration): + dependencies = [ + ("aircox", "0026_alter_sound_options_remove_sound_episode_and_more"), + ] + + operations = [ + migrations.RunPython(get_children_infos), + migrations.RemoveField( + model_name="page", + name="parent", + ), + migrations.RemoveField( + model_name="staticpage", + name="parent", + ), + migrations.RemoveField( + model_name="station", + name="path", + ), + migrations.AddField( + model_name="article", + name="parent", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_set", + to="aircox.page", + ), + ), + migrations.AddField( + model_name="episode", + name="parent", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_set", + to="aircox.page", + ), + ), + migrations.RunPython(restore_children_infos), + ] diff --git a/aircox/models/__init__.py b/aircox/models/__init__.py index 246ca3d..34b778a 100644 --- a/aircox/models/__init__.py +++ b/aircox/models/__init__.py @@ -1,28 +1,30 @@ from . import signals from .article import Article from .diffusion import Diffusion, DiffusionQuerySet -from .episode import Episode +from .episode import Episode, EpisodeSound from .log import Log, LogQuerySet from .page import Category, Comment, NavItem, Page, PageQuerySet, StaticPage from .program import Program, ProgramChildQuerySet, ProgramQuerySet, Stream from .schedule import Schedule -from .sound import Sound, SoundQuerySet, Track +from .sound import Sound, SoundQuerySet from .station import Port, Station, StationQuerySet +from .track import Track from .user_settings import UserSettings __all__ = ( "signals", "Article", - "Episode", + "Category", + "Comment", "Diffusion", "DiffusionQuerySet", + "Episode", + "EpisodeSound", "Log", "LogQuerySet", - "Category", "PageQuerySet", "Page", "StaticPage", - "Comment", "NavItem", "Program", "ProgramQuerySet", diff --git a/aircox/models/article.py b/aircox/models/article.py index 490faa1..c75387c 100644 --- a/aircox/models/article.py +++ b/aircox/models/article.py @@ -1,13 +1,14 @@ from django.utils.translation import gettext_lazy as _ -from .page import Page +from .page import ChildPage from .program import ProgramChildQuerySet __all__ = ("Article",) -class Article(Page): +class Article(ChildPage): detail_url_name = "article-detail" + template_prefix = "article" objects = ProgramChildQuerySet.as_manager() diff --git a/aircox/models/diffusion.py b/aircox/models/diffusion.py index e333449..d9beaff 100644 --- a/aircox/models/diffusion.py +++ b/aircox/models/diffusion.py @@ -17,6 +17,10 @@ __all__ = ("Diffusion", "DiffusionQuerySet") class DiffusionQuerySet(RerunQuerySet): + def editor(self, user): + episodes = Episode.objects.editor(user) + return self.filter(episode__in=episodes) + def episode(self, episode=None, id=None): """Diffusions for this episode.""" return self.filter(episode=episode) if id is None else self.filter(episode__id=id) @@ -89,6 +93,8 @@ class Diffusion(Rerun): - stop: the diffusion has been manually stopped """ + list_url_name = "timetable-list" + objects = DiffusionQuerySet.as_manager() TYPE_ON_AIR = 0x00 @@ -127,8 +133,6 @@ class Diffusion(Rerun): # help_text = _('use this input port'), # ) - item_template_name = "aircox/widgets/diffusion_item.html" - class Meta: verbose_name = _("Diffusion") verbose_name_plural = _("Diffusions") @@ -192,34 +196,15 @@ class Diffusion(Rerun): now = tz.now() return self.type == self.TYPE_ON_AIR and self.start <= now and self.end >= now + @property + def is_today(self): + """True if diffusion is currently today.""" + return self.start.date() == datetime.date.today() + @property def is_live(self): """True if Diffusion is live (False if there are sounds files).""" - return self.type == self.TYPE_ON_AIR and not self.episode.sound_set.archive().count() - - def get_playlist(self, **types): - """Returns sounds as a playlist (list of *local* archive file path). - - The given arguments are passed to ``get_sounds``. - """ - from .sound import Sound - - return list( - self.get_sounds(**types).filter(path__isnull=False, type=Sound.TYPE_ARCHIVE).values_list("path", flat=True) - ) - - def get_sounds(self, **types): - """Return a queryset of sounds related to this diffusion, ordered by - type then path. - - **types: filter on the given sound types name, as `archive=True` - """ - from .sound import Sound - - sounds = (self.initial or self).sound_set.order_by("type", "path") - _in = [getattr(Sound.Type, name) for name, value in types.items() if value] - - return sounds.filter(type__in=_in) + return self.type == self.TYPE_ON_AIR and not self.episode.episodesound_set.all().broadcast() def is_date_in_range(self, date=None): """Return true if the given date is in the diffusion's start-end diff --git a/aircox/models/episode.py b/aircox/models/episode.py index a94cb7b..2ecb9da 100644 --- a/aircox/models/episode.py +++ b/aircox/models/episode.py @@ -1,55 +1,65 @@ +import os + +from django.conf import settings as d_settings +from django.db import models from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ -from easy_thumbnails.files import get_thumbnailer from aircox.conf import settings -from .page import Page +from .page import ChildPage from .program import ProgramChildQuerySet +from .sound import Sound __all__ = ("Episode",) -class Episode(Page): - objects = ProgramChildQuerySet.as_manager() +class EpisodeQuerySet(ProgramChildQuerySet): + def with_podcasts(self): + return self.filter(episodesound__sound__is_public=True).distinct() + + +class Episode(ChildPage): + objects = EpisodeQuerySet.as_manager() detail_url_name = "episode-detail" - item_template_name = "aircox/widgets/episode_item.html" + list_url_name = "episode-list" + edit_url_name = "episode-edit" + template_prefix = "episode" @property def program(self): - return getattr(self.parent, "program", None) - - @cached_property - def podcasts(self): - """Return serialized data about podcasts.""" - from ..serializers import PodcastSerializer - - podcasts = [PodcastSerializer(s).data for s in self.sound_set.public().order_by("type")] - if self.cover: - options = {"size": (128, 128), "crop": "scale"} - cover = get_thumbnailer(self.cover).get_thumbnail(options).url - else: - cover = None - - for index, podcast in enumerate(podcasts): - podcasts[index]["cover"] = cover - podcasts[index]["page_url"] = self.get_absolute_url() - podcasts[index]["page_title"] = self.title - return podcasts + return self.parent_subclass @program.setter def program(self, value): self.parent = value + @cached_property + def podcasts(self): + """Return serialized data about podcasts.""" + query = self.episodesound_set.all().public().order_by("-broadcast", "position") + return self._to_podcasts(query) + + @cached_property + def sounds(self): + """Return serialized data about all related sounds.""" + query = self.episodesound_set.all().order_by("-broadcast", "position") + return self._to_podcasts(query) + + def _to_podcasts(self, query): + from ..serializers import EpisodeSoundSerializer as serializer_class + + query = query.select_related("sound") + podcasts = [serializer_class(s).data for s in query] + for index, podcast in enumerate(podcasts): + podcasts[index]["page_url"] = self.get_absolute_url() + podcasts[index]["page_title"] = self.title + return podcasts + class Meta: verbose_name = _("Episode") verbose_name_plural = _("Episodes") - def get_absolute_url(self): - if not self.is_published: - return self.program.get_absolute_url() - return super().get_absolute_url() - def save(self, *args, **kwargs): if self.parent is None: raise ValueError("missing parent program") @@ -74,3 +84,54 @@ class Episode(Page): else title ) return super().get_init_kwargs_from(page, title=title, program=page, **kwargs) + + +class EpisodeSoundQuerySet(models.QuerySet): + def episode(self, episode): + if isinstance(episode, int): + return self.filter(episode_id=episode) + return self.filter(episode=episode) + + def available(self): + return self.filter(sound__is_removed=False) + + def public(self): + return self.filter(sound__is_public=True) + + def broadcast(self): + return self.available().filter(broadcast=True) + + def playlist(self, order="position"): + # TODO: subquery expression + if order: + self = self.order_by(order) + query = self.filter(sound__file__isnull=False, sound__is_removed=False).values_list("sound__file", flat=True) + return [os.path.join(d_settings.MEDIA_ROOT, file) for file in query] + + +class EpisodeSound(models.Model): + """Element of an episode playlist.""" + + episode = models.ForeignKey(Episode, on_delete=models.CASCADE) + sound = models.ForeignKey(Sound, on_delete=models.CASCADE) + position = models.PositiveSmallIntegerField( + _("order"), + default=0, + help_text=_("position in the playlist"), + ) + broadcast = models.BooleanField( + _("Broadcast"), + blank=None, + help_text=_("The sound is broadcasted on air"), + ) + + objects = EpisodeSoundQuerySet.as_manager() + + class Meta: + verbose_name = _("Podcast") + verbose_name_plural = _("Podcasts") + + def save(self, *args, **kwargs): + if self.broadcast is None: + self.broadcast = self.sound.broadcast + super().save(*args, **kwargs) diff --git a/aircox/models/file.py b/aircox/models/file.py new file mode 100644 index 0000000..8d35d95 --- /dev/null +++ b/aircox/models/file.py @@ -0,0 +1,153 @@ +import os +from pathlib import Path + +from django.conf import settings +from django.db import models +from django.db.models import Q +from django.utils.translation import gettext_lazy as _ +from django.utils import timezone as tz + +from .program import Program + + +class FileQuerySet(models.QuerySet): + def station(self, station=None, id=None): + id = station.pk if id is None else id + return self.filter(program__station__id=id) + + def available(self): + return self.exclude(is_removed=False) + + def public(self): + """Return sounds available as podcasts.""" + return self.filter(is_public=True) + + def path(self, paths): + if isinstance(paths, str): + return self.filter(file=paths.replace(settings.MEDIA_ROOT + "/", "")) + return self.filter(file__in=(p.replace(settings.MEDIA_ROOT + "/", "") for p in paths)) + + def search(self, query): + return self.filter(Q(name__icontains=query) | Q(file__icontains=query) | Q(program__title__icontains=query)) + + +class File(models.Model): + def _upload_to(self, filename): + dir = self.program and self.program.path or self.default_upload_path + subdir = self.get_upload_dir() + if subdir: + return os.path.join(dir, subdir, filename) + return os.path.join(dir, filename) + + program = models.ForeignKey( + Program, + models.SET_NULL, + verbose_name=_("Program"), + null=True, + blank=True, + ) + file = models.FileField( + _("file"), + upload_to=_upload_to, + max_length=256, + db_index=True, + ) + name = models.CharField( + _("name"), + max_length=64, + db_index=True, + ) + description = models.TextField( + _("description"), + max_length=256, + blank=True, + default="", + ) + mtime = models.DateTimeField( + _("modification time"), + blank=True, + null=True, + help_text=_("last modification date and time"), + ) + is_public = models.BooleanField( + _("public"), + help_text=_("file is publicly accessible"), + default=False, + ) + is_removed = models.BooleanField( + _("removed"), + help_text=_("file has been removed from server"), + default=False, + db_index=True, + ) + + class Meta: + abstract = True + + objects = FileQuerySet.as_manager() + + default_upload_path = Path(settings.MEDIA_ROOT) + """Default upload directory when no program is provided.""" + upload_dir = "uploads" + """Upload sub-directory.""" + + @property + def url(self): + return self.file and self.file.url + + def get_upload_dir(self): + return self.upload_dir + + def get_mtime(self): + """Get the last modification date from file.""" + mtime = os.stat(self.file.path).st_mtime + mtime = tz.datetime.fromtimestamp(mtime) + mtime = mtime.replace(microsecond=0) + return tz.make_aware(mtime, tz.get_current_timezone()) + + def file_updated(self): + """Return True when file has been updated on filesystem.""" + exists = self.file_exists() + if self.is_removed != (not exists): + return True + return exists and self.mtime != self.get_mtime() + + def file_exists(self): + """Return true if the file still exists.""" + return os.path.exists(self.file.path) + + def sync_fs(self, on_update=False): + """Sync model to file on the filesystem. + + :param bool on_update: only check if `file_updated`. + :return True wether a change happened. + """ + if on_update and not self.file_updated(): + return + + # check on name/remove/modification time + name = self.name + if not self.name and self.file and self.file.name: + name = os.path.basename(self.file.name) + name = os.path.splitext(name)[0] + name = name.replace("_", " ").strip() + + is_removed = not self.file_exists() + mtime = (not is_removed and self.get_mtime()) or None + + changed = is_removed != self.is_removed or mtime != self.mtime or name != self.name + self.name, self.is_removed, self.mtime = name, is_removed, mtime + + # read metadata + if changed and not self.is_removed: + metadata = self.read_metadata() + metadata and self.__dict__.update(metadata) + return changed + + def read_metadata(self): + return {} + + def save(self, sync=True, *args, **kwargs): + if sync and self.file_exists(): + self.sync_fs(on_update=True) + super().save(*args, **kwargs) diff --git a/aircox/models/log.py b/aircox/models/log.py index c924bea..2a21fd3 100644 --- a/aircox/models/log.py +++ b/aircox/models/log.py @@ -1,5 +1,6 @@ import datetime import logging +import operator from collections import deque from django.db import models @@ -7,8 +8,10 @@ from django.utils import timezone as tz from django.utils.translation import gettext_lazy as _ from .diffusion import Diffusion -from .sound import Sound, Track +from .sound import Sound from .station import Station +from .track import Track +from .page import Renderable logger = logging.getLogger("aircox") @@ -30,6 +33,9 @@ class LogQuerySet(models.QuerySet): def after(self, date): return self.filter(date__gte=date) if isinstance(date, tz.datetime) else self.filter(date__date__gte=date) + def before(self, date): + return self.filter(date__lte=date) if isinstance(date, tz.datetime) else self.filter(date__date__lte=date) + def on_air(self): return self.filter(type=Log.TYPE_ON_AIR) @@ -46,13 +52,15 @@ class LogQuerySet(models.QuerySet): return self.filter(track__isnull=not with_it) -class Log(models.Model): +class Log(Renderable, models.Model): """Log sounds and diffusions that are played on the station. This only remember what has been played on the outputs, not on each source; Source designate here which source is responsible of that. """ + template_prefix = "log" + TYPE_STOP = 0x00 """Source has been stopped, e.g. manually.""" # Rule: \/ diffusion != null \/ sound != null @@ -90,7 +98,7 @@ class Log(models.Model): blank=True, null=True, verbose_name=_("source"), - help_text=_("identifier of the source related to this log"), + help_text=_("Identifier of the log's source."), ) comment = models.CharField( max_length=512, @@ -160,21 +168,22 @@ class Log(models.Model): object_list += [cls(obj) for obj in items] @classmethod - def merge_diffusions(cls, logs, diffs, count=None): + def merge_diffusions(cls, logs, diffs, count=None, diff_count=None, group_logs=False): """Merge logs and diffusions together. `logs` can either be a queryset or a list ordered by `Log.date`. """ - # TODO: limit count - # FIXME: log may be iterable (in stats view) if isinstance(logs, models.QuerySet): logs = list(logs.order_by("-date")) - diffs = deque(diffs.on_air().before().order_by("-start")) + diffs = diffs.on_air().order_by("-start") + if diff_count: + diffs = diffs[:diff_count] + diffs = deque(diffs) object_list = [] while True: if not len(diffs): - object_list += logs + cls._append_logs(object_list, logs, len(logs), group=group_logs) break if not len(logs): @@ -184,13 +193,8 @@ class Log(models.Model): diff = diffs.popleft() # - takes all logs after diff start - index = next( - (i for i, v in enumerate(logs) if v.date <= diff.end), - len(logs), - ) - if index is not None and index > 0: - object_list += logs[:index] - logs = logs[index:] + index = cls._next_index(logs, diff.end, len(logs), pred=operator.le) + cls._append_logs(object_list, logs, index, group=group_logs) if len(logs): # FIXME @@ -199,10 +203,7 @@ class Log(models.Model): # object_list.append(logs[0]) # - skips logs while diff is running - index = next( - (i for i, v in enumerate(logs) if v.date < diff.start), - len(logs), - ) + index = cls._next_index(logs, diff.start, len(logs)) if index is not None and index > 0: logs = logs[index:] @@ -211,6 +212,40 @@ class Log(models.Model): return object_list if count is None else object_list[:count] + @classmethod + def _next_index(cls, items, date, default, pred=operator.lt): + iter = (i for i, v in enumerate(items) if pred(v.date, date)) + return next(iter, default) + + @classmethod + def _append_logs(cls, object_list, logs, count, group=False): + logs = logs[:count] + if not logs: + return object_list + + if group: + grouped = cls._group_logs_by_time(logs) + object_list.extend(grouped) + else: + object_list += logs + return object_list + + @classmethod + def _group_logs_by_time(cls, logs): + last_time = -1 + cum = [] + for log in logs: + hour = log.date.time().hour + if hour != last_time: + if cum: + yield cum + cum = [] + last_time = hour + # reverse from lowest to highest date + cum.insert(0, log) + if cum: + yield cum + def print(self): r = [] if self.diffusion: diff --git a/aircox/models/page.py b/aircox/models/page.py index 58bbd43..b5a8348 100644 --- a/aircox/models/page.py +++ b/aircox/models/page.py @@ -16,6 +16,7 @@ from model_utils.managers import InheritanceQuerySet from .station import Station __all__ = ( + "Renderable", "Category", "PageQuerySet", "Page", @@ -25,7 +26,17 @@ __all__ = ( ) -headline_re = re.compile(r"(

)?" r"(?P[^\n]{1,140}(\n|[^\.]*?\.))" r"(

)?") +headline_clean_re = re.compile(r"\n(\s| )+", re.MULTILINE) +headline_re = re.compile(r"(?P([\S+]|\s+){1,240}\S+)", re.MULTILINE) + + +class Renderable: + template_prefix = "page" + template_name = "aircox/widgets/{prefix}.html" + + def get_template_name(self, widget): + """Return template name for the provided widget.""" + return self.template_name.format(prefix=self.template_prefix, widget=widget) class Category(models.Model): @@ -50,6 +61,9 @@ class BasePageQuerySet(InheritanceQuerySet): def trash(self): return self.filter(status=Page.STATUS_TRASH) + def by_last(self): + return self.order_by("-pub_date") + def parent(self, parent=None, id=None): """Return pages having this parent.""" return self.filter(parent=parent) if id is None else self.filter(parent__id=id) @@ -60,7 +74,7 @@ class BasePageQuerySet(InheritanceQuerySet): return self.filter(title__icontains=q) -class BasePage(models.Model): +class BasePage(Renderable, models.Model): """Base class for publishable content.""" STATUS_DRAFT = 0x00 @@ -72,14 +86,6 @@ class BasePage(models.Model): (STATUS_TRASH, _("trash")), ) - parent = models.ForeignKey( - "self", - models.CASCADE, - blank=True, - null=True, - db_index=True, - related_name="child_set", - ) title = models.CharField(max_length=100) slug = models.SlugField(_("slug"), max_length=120, blank=True, unique=True, db_index=True) status = models.PositiveSmallIntegerField( @@ -102,11 +108,14 @@ class BasePage(models.Model): objects = BasePageQuerySet.as_manager() detail_url_name = None - item_template_name = "aircox/widgets/page_item.html" class Meta: abstract = True + @property + def cover_url(self): + return self.cover_id and self.cover.url + def __str__(self): return "{}".format(self.title or self.pk) @@ -116,13 +125,12 @@ class BasePage(models.Model): count = Page.objects.filter(slug__startswith=self.slug).count() if count: self.slug += "-" + str(count) - - if self.parent and not self.cover: - self.cover = self.parent.cover super().save(*args, **kwargs) def get_absolute_url(self): - return reverse(self.detail_url_name, kwargs={"slug": self.slug}) if self.is_published else "#" + if self.is_published: + return reverse(self.detail_url_name, kwargs={"slug": self.slug}) + return "" @property def is_draft(self): @@ -138,17 +146,35 @@ class BasePage(models.Model): @property def display_title(self): - if self.is_published(): - return self.title - return self.parent.display_title() + return self.is_published and self.title or "" @cached_property - def headline(self): - if not self.content: - return "" + def display_headline(self): content = bleach.clean(self.content, tags=[], strip=True) + content = headline_clean_re.sub("\n", content) + if content.startswith("\n"): + content = content[1:] headline = headline_re.search(content) - return mark_safe(headline.groupdict()["headline"]) if headline else "" + if not headline: + return "" + + headline = headline.groupdict()["headline"] + suffix = "..." if len(headline) < len(content) else "" + + headline = headline.split("\n")[:3] + headline[-1] += suffix + return mark_safe(" ".join(headline)) + + _url_re = re.compile( + "((http|https)\:\/\/)?[a-zA-Z0-9\.\/\?\:@\-_=#]+\.([a-zA-Z]){2,6}([a-zA-Z0-9\.\&\/\?\:@\-_=#])*" + ) + + @cached_property + def display_content(self): + if "

" in self.content: + return self.content + content = self._url_re.sub(r'\1', self.content) + return content.replace("\n\n", "\n").replace("\n", "
") @classmethod def get_init_kwargs_from(cls, page, **kwargs): @@ -161,6 +187,7 @@ class BasePage(models.Model): return cls(**cls.get_init_kwargs_from(page, **kwargs)) +# FIXME: rename class PageQuerySet(BasePageQuerySet): def published(self): return self.filter(status=Page.STATUS_PUBLISHED, pub_date__lte=tz.now()) @@ -189,18 +216,67 @@ class Page(BasePage): objects = PageQuerySet.as_manager() + detail_url_name = "" + list_url_name = "page-list" + edit_url_name = "" + + @classmethod + def get_list_url(cls, kwargs={}): + return reverse(cls.list_url_name, kwargs=kwargs) + class Meta: verbose_name = _("Publication") verbose_name_plural = _("Publications") + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.__initial_cover = self.cover + def save(self, *args, **kwargs): if self.is_published and self.pub_date is None: self.pub_date = tz.now() elif not self.is_published: self.pub_date = None + super().save(*args, **kwargs) - if self.parent and not self.category: - self.category = self.parent.category + +class ChildPage(Page): + parent = models.ForeignKey(Page, models.CASCADE, blank=True, null=True, db_index=True, related_name="%(class)s_set") + + class Meta: + abstract = True + + @property + def display_title(self): + if self.is_published: + return self.title + return self.parent and self.parent.title or "" + + @property + def display_headline(self): + if not self.content or not self.is_published: + return self.parent and self.parent.display_headline or "" + return super().display_headline + + @cached_property + def parent_subclass(self): + if self.parent_id: + return Page.objects.get_subclass(id=self.parent_id) + return None + + def get_absolute_url(self): + if not self.is_published and self.parent_subclass: + return self.parent_subclass.get_absolute_url() + return super().get_absolute_url() + + def save(self, *args, **kwargs): + if self.parent: + if self.parent == self: + self.parent = None + if not self.cover: + self.cover = self.parent.cover + if not self.category: + self.category = self.parent.category super().save(*args, **kwargs) @@ -209,45 +285,37 @@ class StaticPage(BasePage): detail_url_name = "static-page-detail" - ATTACH_TO_HOME = 0x00 - ATTACH_TO_DIFFUSIONS = 0x01 - ATTACH_TO_LOGS = 0x02 - ATTACH_TO_PROGRAMS = 0x03 - ATTACH_TO_EPISODES = 0x04 - ATTACH_TO_ARTICLES = 0x05 + class Target(models.TextChoices): + NONE = "", _("None") + HOME = "home", _("Home Page") + TIMETABLE = "timetable-list", _("Timetable") + PROGRAMS = "program-list", _("Programs list") + EPISODES = "episode-list", _("Episodes list") + ARTICLES = "article-list", _("Articles list") + PAGES = "page-list", _("Publications list") + PODCASTS = "podcast-list", _("Podcasts list") - ATTACH_TO_CHOICES = ( - (ATTACH_TO_HOME, _("Home page")), - (ATTACH_TO_DIFFUSIONS, _("Diffusions page")), - (ATTACH_TO_LOGS, _("Logs page")), - (ATTACH_TO_PROGRAMS, _("Programs list")), - (ATTACH_TO_EPISODES, _("Episodes list")), - (ATTACH_TO_ARTICLES, _("Articles list")), - ) - VIEWS = { - ATTACH_TO_HOME: "home", - ATTACH_TO_DIFFUSIONS: "diffusion-list", - ATTACH_TO_LOGS: "log-list", - ATTACH_TO_PROGRAMS: "program-list", - ATTACH_TO_EPISODES: "episode-list", - ATTACH_TO_ARTICLES: "article-list", - } - - attach_to = models.SmallIntegerField( + attach_to = models.CharField( _("attach to"), - choices=ATTACH_TO_CHOICES, + choices=Target.choices, + max_length=32, blank=True, null=True, help_text=_("display this page content to related element"), ) + def get_related_view(self): + from ..views.page import attached_views + + return self.attach_to and attached_views.get(self.attach_to) or None + def get_absolute_url(self): if self.attach_to: - return reverse(self.VIEWS[self.attach_to]) + return reverse(self.attach_to) return super().get_absolute_url() -class Comment(models.Model): +class Comment(Renderable, models.Model): page = models.ForeignKey( Page, models.CASCADE, @@ -260,7 +328,7 @@ class Comment(models.Model): date = models.DateTimeField(auto_now_add=True) content = models.TextField(_("content"), max_length=1024) - item_template_name = "aircox/widgets/comment_item.html" + template_prefix = "comment" @cached_property def parent(self): @@ -268,7 +336,7 @@ class Comment(models.Model): return Page.objects.select_subclasses().filter(id=self.page_id).first() def get_absolute_url(self): - return self.parent.get_absolute_url() + return self.parent.get_absolute_url() + f"#{self._meta.label_lower}-{self.pk}" class Meta: verbose_name = _("Comment") @@ -281,7 +349,7 @@ class NavItem(models.Model): station = models.ForeignKey(Station, models.CASCADE, verbose_name=_("station")) menu = models.SlugField(_("menu"), max_length=24) order = models.PositiveSmallIntegerField(_("order")) - text = models.CharField(_("title"), max_length=64) + text = models.CharField(_("title"), max_length=64, blank=True, null=True) url = models.CharField(_("url"), max_length=256, blank=True, null=True) page = models.ForeignKey( StaticPage, @@ -300,14 +368,21 @@ class NavItem(models.Model): def get_url(self): return self.url if self.url else self.page.get_absolute_url() if self.page else None + def get_label(self): + if self.text: + return self.text + elif self.page: + return self.page.title + def render(self, request, css_class="", active_class=""): url = self.get_url() + label = self.get_label() if active_class and request.path.startswith(url): css_class += " " + active_class if not url: - return self.text + return label elif not css_class: - return format_html('{}', url, self.text) + return format_html('{}', url, label) else: - return format_html('{}', url, css_class, self.text) + return format_html('{}', url, css_class, label) diff --git a/aircox/models/program.py b/aircox/models/program.py index 7a4fd16..68ca556 100644 --- a/aircox/models/program.py +++ b/aircox/models/program.py @@ -1,11 +1,8 @@ -import logging import os -import shutil from django.conf import settings as conf +from django.contrib.auth.models import Group from django.db import models -from django.db.models import F -from django.db.models.functions import Concat, Substr from django.utils.translation import gettext_lazy as _ from aircox.conf import settings @@ -13,13 +10,11 @@ from aircox.conf import settings from .page import Page, PageQuerySet from .station import Station -logger = logging.getLogger("aircox") - __all__ = ( + "ProgramQuerySet", "Program", "ProgramChildQuerySet", - "ProgramQuerySet", "Stream", ) @@ -32,6 +27,16 @@ class ProgramQuerySet(PageQuerySet): def active(self): return self.filter(active=True) + def editor(self, user): + """Return programs for which user is an editor. + + Superuser is considered as editor of all groups. + """ + if user.is_superuser: + return self + groups = self.request.user.groups.all() + return self.filter(editors_group__in=groups) + class Program(Page): """A Program can either be a Streamed or a Scheduled program. @@ -58,9 +63,12 @@ class Program(Page): default=True, help_text=_("update later diffusions according to schedule changes"), ) + editors_group = models.ForeignKey(Group, models.CASCADE, verbose_name=_("editors")) objects = ProgramQuerySet.as_manager() detail_url_name = "program-detail" + list_url_name = "program-list" + edit_url_name = "program-edit" @property def path(self): @@ -80,11 +88,10 @@ class Program(Page): def excerpts_path(self): return os.path.join(self.path, settings.SOUND_ARCHIVES_SUBDIR) - def __init__(self, *kargs, **kwargs): - super().__init__(*kargs, **kwargs) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) if self.slug: self.__initial_path = self.path - self.__initial_cover = self.cover @classmethod def get_from_path(cl, path): @@ -116,27 +123,21 @@ class Program(Page): def __str__(self): return self.title - def save(self, *kargs, **kwargs): - from .sound import Sound + def save(self, *args, **kwargs): + if not self.editors_group_id: + from aircox import permissions - super().save(*kargs, **kwargs) + saved = permissions.program.init(self) + if saved: + return - # TODO: move in signals - path_ = getattr(self, "__initial_path", None) - abspath = path_ and os.path.join(conf.MEDIA_ROOT, path_) - if path_ is not None and path_ != self.path and os.path.exists(abspath) and not os.path.exists(self.abspath): - logger.info( - "program #%s's dir changed to %s - update it.", - self.id, - self.title, - ) - - shutil.move(abspath, self.abspath) - Sound.objects.filter(path__startswith=path_).update(file=Concat("file", Substr(F("file"), len(path_)))) + super().save() class ProgramChildQuerySet(PageQuerySet): def station(self, station=None, id=None): + # lookup `__program` is due to parent being a page subclass (page is + # concrete). return ( self.filter(parent__program__station=station) if id is None @@ -146,6 +147,10 @@ class ProgramChildQuerySet(PageQuerySet): def program(self, program=None, id=None): return self.parent(program, id) + def editor(self, user): + programs = Program.objects.editor(user) + return self.filter(parent__program__in=programs) + class Stream(models.Model): """When there are no program scheduled, it is possible to play sounds in diff --git a/aircox/models/rerun.py b/aircox/models/rerun.py index 068b22c..f641117 100644 --- a/aircox/models/rerun.py +++ b/aircox/models/rerun.py @@ -1,5 +1,6 @@ from django.core.exceptions import ValidationError from django.db import models +from django.db.models import F, Q from django.utils.translation import gettext_lazy as _ from .program import Program @@ -45,7 +46,7 @@ class Rerun(models.Model): models.SET_NULL, related_name="rerun_set", verbose_name=_("rerun of"), - limit_choices_to={"initial__isnull": True}, + limit_choices_to=Q(initial__isnull=True) & Q(program=F("program")), blank=True, null=True, db_index=True, @@ -74,7 +75,10 @@ class Rerun(models.Model): raise ValidationError({"initial": _("rerun must happen after original")}) def save_rerun(self): - self.program = self.initial.program + if not self.program_id: + self.program = self.initial.program + if self.program != self.initial.program: + raise ValidationError("Program for the rerun should be the same") def save_initial(self): pass diff --git a/aircox/models/schedule.py b/aircox/models/schedule.py index 7513d70..8e67001 100644 --- a/aircox/models/schedule.py +++ b/aircox/models/schedule.py @@ -42,6 +42,7 @@ class Schedule(Rerun): second_and_fourth = 0b001010, _("2nd and 4th {day} of the month") every = 0b011111, _("{day}") one_on_two = 0b100000, _("one {day} on two") + # every_weekday = 0b10000000 _("from Monday to Friday") date = models.DateField( _("date"), @@ -71,6 +72,10 @@ class Schedule(Rerun): verbose_name = _("Schedule") verbose_name_plural = _("Schedules") + def __init__(self, *args, **kwargs): + self._initial = kwargs + super().__init__(*args, **kwargs) + def __str__(self): return "{} - {}, {}".format( self.program.title, @@ -110,16 +115,28 @@ class Schedule(Rerun): date = tz.datetime.combine(date, self.time) return date.replace(tzinfo=self.tz) - def dates_of_month(self, date): - """Return normalized diffusion dates of provided date's month.""" - if self.frequency == Schedule.Frequency.ponctual: + def dates_of_month(self, date, frequency=None, sched_date=None): + """Return normalized diffusion dates of provided date's month. + + :param Date date: date of the month to get dates from; + :param Schedule.Frequency frequency: frequency (defaults to ``self.frequency``) + :param Date sched_date: schedule start date (defaults to ``self.date``) + :return list of diffusion dates + """ + if frequency is None: + frequency = self.frequency + + if sched_date is None: + sched_date = self.date + + if frequency == Schedule.Frequency.ponctual: return [] - sched_wday, freq = self.date.weekday(), self.frequency + sched_wday = sched_date.weekday() date = date.replace(day=1) # last of the month - if freq == Schedule.Frequency.last: + if frequency == Schedule.Frequency.last: date = date.replace(day=calendar.monthrange(date.year, date.month)[1]) date_wday = date.weekday() @@ -134,33 +151,42 @@ class Schedule(Rerun): date_wday, month = date.weekday(), date.month date += tz.timedelta(days=(7 if date_wday > sched_wday else 0) - date_wday + sched_wday) - if freq == Schedule.Frequency.one_on_two: + if frequency == Schedule.Frequency.one_on_two: # - adjust date with modulo 14 (= 2 weeks in days) # - there are max 3 "weeks on two" per month - if (date - self.date).days % 14: + if (date - sched_date).days % 14: date += tz.timedelta(days=7) dates = (date + tz.timedelta(days=14 * i) for i in range(0, 3)) else: - dates = (date + tz.timedelta(days=7 * week) for week in range(0, 5) if freq & (0b1 << week)) + dates = (date + tz.timedelta(days=7 * week) for week in range(0, 5) if frequency & (0b1 << week)) return [self.normalize(date) for date in dates if date.month == month] - def diffusions_of_month(self, date): + def diffusions_of_month(self, date, frequency=None, sched_date=None): """Get episodes and diffusions for month of provided date, including reruns. + :param Date date: date of the month to get diffusions from; + :param Schedule.Frequency frequency: frequency (defaults to ``self.frequency``) + :param Date sched_date: schedule start date (defaults to ``self.date``) :returns: tuple([Episode], [Diffusion]) """ from .diffusion import Diffusion from .episode import Episode - if self.initial is not None or self.frequency == Schedule.Frequency.ponctual: + if frequency is None: + frequency = self.frequency + + if sched_date is None: + sched_date = self.date + + if self.initial is not None or frequency == Schedule.Frequency.ponctual: return [], [] # dates for self and reruns as (date, initial) - reruns = [(rerun, rerun.date - self.date) for rerun in self.rerun_set.all()] + reruns = [(rerun, rerun.date - sched_date) for rerun in self.rerun_set.all()] - dates = {date: None for date in self.dates_of_month(date)} + dates = {date: None for date in self.dates_of_month(date, frequency, sched_date)} dates.update( (rerun.normalize(date.date() + delta), date) for date in list(dates.keys()) for rerun, delta in reruns ) diff --git a/aircox/models/signals.py b/aircox/models/signals.py index a30353a..b465310 100755 --- a/aircox/models/signals.py +++ b/aircox/models/signals.py @@ -1,16 +1,27 @@ +import logging +import os +import shutil + +from django.conf import settings as conf from django.contrib.auth.models import Group, Permission, User from django.db import transaction -from django.db.models import signals +from django.db.models import signals, F +from django.db.models.functions import Concat, Substr from django.dispatch import receiver from django.utils import timezone as tz from aircox import utils from aircox.conf import settings +from .article import Article from .diffusion import Diffusion from .episode import Episode from .page import Page from .program import Program from .schedule import Schedule +from .sound import Sound + + +logger = logging.getLogger("aircox") # Add a default group to a user when it is created. It also assigns a list @@ -39,27 +50,43 @@ def user_default_groups(sender, instance, created, *args, **kwargs): instance.groups.add(group) +# ---- page @receiver(signals.post_save, sender=Page) -def page_post_save(sender, instance, created, *args, **kwargs): - if not created and instance.cover: - Page.objects.filter(parent=instance, cover__isnull=True).update(cover=instance.cover) +def page_post_save__child_page_defaults(sender, instance, created, *args, **kwargs): + initial_cover = getattr(instance, "__initial_cover", None) + if initial_cover is None and instance.cover is not None: + Episode.objects.filter(parent=instance, cover__isnull=True).update(cover=instance.cover) + Article.objects.filter(parent=instance, cover__isnull=True).update(cover=instance.cover) +# ---- program @receiver(signals.post_save, sender=Program) -def program_post_save(sender, instance, created, *args, **kwargs): - """Clean-up later diffusions when a program becomes inactive.""" +def program_post_save__clean_later_episodes(sender, instance, created, *args, **kwargs): if not instance.active: Diffusion.objects.program(instance).after(tz.now()).delete() Episode.objects.parent(instance).filter(diffusion__isnull=True).delete() - cover = getattr(instance, "__initial_cover", None) - if cover is None and instance.cover is not None: - Episode.objects.parent(instance).filter(cover__isnull=True).update(cover=instance.cover) + +@receiver(signals.post_save, sender=Program) +def program_post_save__mv_sounds(sender, instance, created, *args, **kwargs): + path_ = getattr(instance, "__initial_path", None) + if path_ in (None, instance.path): + return + + abspath = path_ and os.path.join(conf.MEDIA_ROOT, path_) + if os.path.exists(abspath) and not os.path.exists(instance.abspath): + logger.info( + f"program #{instance.pk}'s dir changed to {instance.title} - update it.", instance.id, instance.title + ) + + shutil.move(abspath, instance.abspath) + Sound.objects.filter(path__startswith=path_).update(file=Concat("file", Substr(F("file"), len(path_)))) +# ---- schedule @receiver(signals.pre_save, sender=Schedule) def schedule_pre_save(sender, instance, *args, **kwargs): - if getattr(instance, "pk") is not None: + if getattr(instance, "pk") is not None and "raw" not in kwargs: instance._initial = Schedule.objects.get(pk=instance.pk) @@ -88,9 +115,23 @@ def schedule_post_save(sender, instance, created, *args, **kwargs): def schedule_pre_delete(sender, instance, *args, **kwargs): """Delete later corresponding diffusion to a changed schedule.""" Diffusion.objects.filter(schedule=instance).after(tz.now()).delete() - Episode.objects.filter(diffusion__isnull=True, content__isnull=True, sound__isnull=True).delete() + Episode.objects.filter(diffusion__isnull=True, content__isnull=True, episodesound__isnull=True).delete() +# ---- diffusion @receiver(signals.post_delete, sender=Diffusion) def diffusion_post_delete(sender, instance, *args, **kwargs): - Episode.objects.filter(diffusion__isnull=True, content__isnull=True, sound__isnull=True).delete() + Episode.objects.filter(diffusion__isnull=True, content__isnull=True, episodesound__isnull=True).delete() + + +# ---- files +@receiver(signals.post_delete, sender=Sound) +def delete_file(sender, instance, *args, **kwargs): + """Deletes file on `post_delete`""" + if not instance.file: + return + + path = instance.file.path + qs = sender.objects.filter(file=path) + if not qs.exists() and os.path.exists(path): + os.remove(path) diff --git a/aircox/models/sound.py b/aircox/models/sound.py index 18133cc..83e6261 100644 --- a/aircox/models/sound.py +++ b/aircox/models/sound.py @@ -1,304 +1,195 @@ -import logging +from datetime import date import os +import re from django.conf import settings as conf from django.db import models -from django.db.models import Q from django.utils import timezone as tz from django.utils.translation import gettext_lazy as _ -from taggit.managers import TaggableManager +from aircox import utils from aircox.conf import settings -from .episode import Episode from .program import Program - -logger = logging.getLogger("aircox") +from .file import File, FileQuerySet -__all__ = ("Sound", "SoundQuerySet", "Track") +__all__ = ("Sound", "SoundQuerySet") -class SoundQuerySet(models.QuerySet): - def station(self, station=None, id=None): - id = station.pk if id is None else id - return self.filter(program__station__id=id) - - def episode(self, episode=None, id=None): - id = episode.pk if id is None else id - return self.filter(episode__id=id) - - def diffusion(self, diffusion=None, id=None): - id = diffusion.pk if id is None else id - return self.filter(episode__diffusion__id=id) - - def available(self): - return self.exclude(type=Sound.TYPE_REMOVED) - - def public(self): - """Return sounds available as podcasts.""" - return self.filter(is_public=True) - +class SoundQuerySet(FileQuerySet): def downloadable(self): """Return sounds available as podcasts.""" return self.filter(is_downloadable=True) - def archive(self): + def broadcast(self): """Return sounds that are archives.""" - return self.filter(type=Sound.TYPE_ARCHIVE) + return self.filter(broadcast=True, is_removed=False) - def path(self, paths): - if isinstance(paths, str): - return self.filter(file=paths.replace(conf.MEDIA_ROOT + "/", "")) - return self.filter(file__in=(p.replace(conf.MEDIA_ROOT + "/", "") for p in paths)) - - def playlist(self, archive=True, order_by=True): + def playlist(self, order_by="file"): """Return files absolute paths as a flat list (exclude sound without - path). - - If `order_by` is True, order by path. - """ - if archive: - self = self.archive() + path).""" if order_by: - self = self.order_by("file") + self = self.order_by(order_by) return [ os.path.join(conf.MEDIA_ROOT, file) for file in self.filter(file__isnull=False).values_list("file", flat=True) ] - def search(self, query): - return self.filter( - Q(name__icontains=query) - | Q(file__icontains=query) - | Q(program__title__icontains=query) - | Q(episode__title__icontains=query) - ) - -# TODO: -# - provide a default name based on program and episode -class Sound(models.Model): - """A Sound is the representation of a sound file that can be either an - excerpt or a complete archive of the related diffusion.""" - - TYPE_OTHER = 0x00 - TYPE_ARCHIVE = 0x01 - TYPE_EXCERPT = 0x02 - TYPE_REMOVED = 0x03 - TYPE_CHOICES = ( - (TYPE_OTHER, _("other")), - (TYPE_ARCHIVE, _("archive")), - (TYPE_EXCERPT, _("excerpt")), - (TYPE_REMOVED, _("removed")), - ) - - name = models.CharField(_("name"), max_length=64) - program = models.ForeignKey( - Program, - models.CASCADE, - blank=True, # NOT NULL - verbose_name=_("program"), - help_text=_("program related to it"), - db_index=True, - ) - episode = models.ForeignKey( - Episode, - models.SET_NULL, - blank=True, - null=True, - verbose_name=_("episode"), - db_index=True, - ) - type = models.SmallIntegerField(_("type"), choices=TYPE_CHOICES) - position = models.PositiveSmallIntegerField( - _("order"), - default=0, - help_text=_("position in the playlist"), - ) - - def _upload_to(self, filename): - subdir = settings.SOUND_ARCHIVES_SUBDIR if self.type == self.TYPE_ARCHIVE else settings.SOUND_EXCERPTS_SUBDIR - return os.path.join(self.program.path, subdir, filename) - - file = models.FileField( - _("file"), - upload_to=_upload_to, - max_length=256, - db_index=True, - unique=True, - ) +class Sound(File): duration = models.TimeField( _("duration"), blank=True, null=True, help_text=_("duration of the sound"), ) - mtime = models.DateTimeField( - _("modification time"), - blank=True, - null=True, - help_text=_("last modification date and time"), - ) is_good_quality = models.BooleanField( _("good quality"), help_text=_("sound meets quality requirements"), blank=True, null=True, ) - is_public = models.BooleanField( - _("public"), - help_text=_("whether it is publicly available as podcast"), - default=False, - ) is_downloadable = models.BooleanField( _("downloadable"), - help_text=_("whether it can be publicly downloaded by visitors (sound must be " "public)"), + help_text=_("Sound can be downloaded by website visitors."), default=False, ) + broadcast = models.BooleanField( + _("Broadcast"), + default=False, + help_text=_("The sound is broadcasted on air"), + ) objects = SoundQuerySet.as_manager() class Meta: - verbose_name = _("Sound") - verbose_name_plural = _("Sounds") + verbose_name = _("Sound file") + verbose_name_plural = _("Sound files") - @property - def url(self): - return self.file and self.file.url + _path_re = re.compile( + "^(?P[0-9]{4})(?P[0-9]{2})(?P[0-9]{2})" + "(_(?P[0-9]{2})h(?P[0-9]{2}))?" + "(_(?P[0-9]+))?" + "_?[ -]*(?P.*)$" + ) - def __str__(self): - return "/".join(self.file.path.split("/")[-3:]) + @classmethod + def read_path(cls, path): + """Parse path name returning dictionary of extracted info. It can + contain: - def save(self, check=True, *args, **kwargs): - if self.episode is not None and self.program is None: - self.program = self.episode.program - if check: - self.check_on_file() - if not self.is_public: - self.is_downloadable = False - self.__check_name() - super().save(*args, **kwargs) - - # TODO: rename get_file_mtime(self) - def get_mtime(self): - """Get the last modification date from file.""" - mtime = os.stat(self.file.path).st_mtime - mtime = tz.datetime.fromtimestamp(mtime) - mtime = mtime.replace(microsecond=0) - return tz.make_aware(mtime, tz.get_current_timezone()) - - def file_exists(self): - """Return true if the file still exists.""" - - return os.path.exists(self.file.path) - - # TODO: rename to sync_fs() - def check_on_file(self): - """Check sound file info again'st self, and update informations if - needed (do not save). - - Return True if there was changes. + - `year`, `month`, `day`: diffusion date + - `hour`, `minute`: diffusion time + - `n`: sound arbitrary number (used for sound ordering) + - `name`: cleaned name extracted or file name (without extension) """ - if not self.file_exists(): - if self.type == self.TYPE_REMOVED: - return - logger.debug("sound %s: has been removed", self.file.name) - self.type = self.TYPE_REMOVED - return True + basename = os.path.basename(path) + basename = os.path.splitext(basename)[0] + reg_match = cls._path_re.search(basename) + if reg_match: + info = reg_match.groupdict() + for k in ("year", "month", "day", "hour", "minute", "n"): + if info.get(k) is not None: + info[k] = int(info[k]) - # not anymore removed - changed = False + name = info.get("name") + info["name"] = name and cls._as_name(name) or basename + else: + info = {"name": basename} + return info - if self.type == self.TYPE_REMOVED and self.program: - changed = True - self.type = ( - self.TYPE_ARCHIVE if self.file.name.startswith(self.program.archives_path) else self.TYPE_EXCERPT + @classmethod + def _as_name(cls, name): + name = name.replace("_", " ") + return " ".join(r.capitalize() for r in name.split(" ")) + + def find_episode(self, path_info=None): + """Base on self's file name, match date to an initial diffusion and + return corresponding episode or ``None``.""" + pi = path_info or self.read_path(self.file.path) + if "year" not in pi: + return None + + year, month, day = pi.get("year"), pi.get("month"), pi.get("day") + if pi.get("hour") is not None: + at = tz.datetime(year, month, day, pi.get("hour", 0), pi.get("minute", 0)) + at = tz.make_aware(at) + else: + at = date(year, month, day) + + diffusion = self.program.diffusion_set.at(at).first() + return diffusion and diffusion.episode or None + + def find_playlist(self, meta=None): + """Find a playlist file corresponding to the sound path, such as: + my_sound.ogg => my_sound.csv. + + Use provided sound's metadata if any and no csv file has been + found. + """ + from aircox.controllers.playlist_import import PlaylistImport + from .track import Track + + if self.track_set.count() > 1: + return + + # import playlist + path_noext, ext = os.path.splitext(self.file.path) + path = path_noext + ".csv" + if os.path.exists(path): + PlaylistImport(path, sound=self).run() + # use metadata + elif meta and meta.tags: + title, artist, album, year = tuple( + t and ", ".join(t) for t in (meta.tags.get(k) for k in ("title", "artist", "album", "year")) ) - - # check mtime -> reset quality if changed (assume file changed) - mtime = self.get_mtime() - - if self.mtime != mtime: - self.mtime = mtime - self.is_good_quality = None - logger.debug( - "sound %s: m_time has changed. Reset quality info", - self.file.name, + title = title or path_noext + info = "{} ({})".format(album, year) if album and year else album or year or "" + track = Track( + sound=self, + position=int(meta.tags.get("tracknumber", 0)), + title=title, + artist=artist or _("unknown"), + info=info, ) - return True + track.save() + def get_upload_dir(self): + if self.broadcast: + return settings.SOUND_BROADCASTS_SUBDIR + return settings.SOUND_EXCERPTS_SUBDIR + + meta = None + """Provided by read_metadata: Mutagen's metadata.""" + + def sync_fs(self, *args, find_playlist=False, **kwargs): + changed = super().sync_fs(*args, **kwargs) + if changed and not self.is_removed: + if not self.program: + self.program = Program.get_from_path(self.file.path) + changed = True + if find_playlist and self.meta: + not self.pk and self.save(sync=False) + self.find_playlist(self.meta) return changed - def __check_name(self): - if not self.name and self.file and self.file.name: - # FIXME: later, remove date? - name = os.path.basename(self.file.name) - name = os.path.splitext(name)[0] - self.name = name.replace("_", " ").strip() + def read_metadata(self): + import mutagen - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.__check_name() + meta = mutagen.File(self.file.path) + metadata = {"duration": utils.seconds_to_time(meta.info.length), "meta": meta} -class Track(models.Model): - """Track of a playlist of an object. - - The position can either be expressed as the position in the playlist - or as the moment in seconds it started. - """ - - episode = models.ForeignKey( - Episode, - models.CASCADE, - blank=True, - null=True, - verbose_name=_("episode"), - ) - sound = models.ForeignKey( - Sound, - models.CASCADE, - blank=True, - null=True, - verbose_name=_("sound"), - ) - position = models.PositiveSmallIntegerField( - _("order"), - default=0, - help_text=_("position in the playlist"), - ) - timestamp = models.PositiveSmallIntegerField( - _("timestamp"), - blank=True, - null=True, - help_text=_("position (in seconds)"), - ) - title = models.CharField(_("title"), max_length=128) - artist = models.CharField(_("artist"), max_length=128) - album = models.CharField(_("album"), max_length=128, null=True, blank=True) - tags = TaggableManager(verbose_name=_("tags"), blank=True) - year = models.IntegerField(_("year"), blank=True, null=True) - # FIXME: remove? - info = models.CharField( - _("information"), - max_length=128, - blank=True, - null=True, - help_text=_( - "additional informations about this track, such as " "the version, if is it a remix, features, etc." - ), - ) - - class Meta: - verbose_name = _("Track") - verbose_name_plural = _("Tracks") - ordering = ("position",) + path_info = self.read_path(self.file.path) + if name := path_info.get("name"): + metadata["name"] = name + return metadata def __str__(self): - return "{self.artist} -- {self.title} -- {self.position}".format(self=self) - - def save(self, *args, **kwargs): - if (self.sound is None and self.episode is None) or (self.sound is not None and self.episode is not None): - raise ValueError("sound XOR episode is required") - super().save(*args, **kwargs) + infos = "" + if self.is_removed: + infos += _("removed") + if infos: + return f"{self.file.name} [{infos}]" + return f"{self.file.name}" diff --git a/aircox/models/station.py b/aircox/models/station.py index da31d40..65c1090 100644 --- a/aircox/models/station.py +++ b/aircox/models/station.py @@ -1,11 +1,8 @@ -import os - from django.db import models from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ from filer.fields.image import FilerImageField -from aircox.conf import settings __all__ = ("Station", "StationQuerySet", "Port") @@ -32,13 +29,6 @@ class Station(models.Model): name = models.CharField(_("name"), max_length=64) slug = models.SlugField(_("slug"), max_length=64, unique=True) - # FIXME: remove - should be decided only by Streamer controller + settings - path = models.CharField( - _("path"), - help_text=_("path to the working directory"), - max_length=256, - blank=True, - ) default = models.BooleanField( _("default station"), default=False, @@ -67,7 +57,7 @@ class Station(models.Model): max_length=2048, null=True, blank=True, - help_text=_("Audio streams urls used by station's player. One url " "a line."), + help_text=_("Audio streams urls used by station's player. One url a line."), ) default_cover = FilerImageField( on_delete=models.SET_NULL, @@ -76,6 +66,14 @@ class Station(models.Model): blank=True, related_name="+", ) + music_stream_title = models.CharField( + _("Music stream's title"), + max_length=64, + default=_("Music stream"), + ) + legal_label = models.CharField( + _("Legal label"), max_length=64, blank=True, default="", help_text=_("Displayed at the bottom of pages.") + ) objects = StationQuerySet.as_manager() @@ -88,12 +86,6 @@ class Station(models.Model): return self.name def save(self, make_sources=True, *args, **kwargs): - if not self.path: - self.path = os.path.join( - settings.CONTROLLERS_WORKING_DIR, - self.slug.replace("-", "_"), - ) - if self.default: qs = Station.objects.filter(default=True) if self.pk is not None: diff --git a/aircox/models/track.py b/aircox/models/track.py new file mode 100644 index 0000000..8421d2c --- /dev/null +++ b/aircox/models/track.py @@ -0,0 +1,72 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ +from taggit.managers import TaggableManager + + +from .episode import Episode +from .sound import Sound + + +__all__ = ("Track",) + + +class Track(models.Model): + """Track of a playlist of an object. + + The position can either be expressed as the position in the playlist + or as the moment in seconds it started. + """ + + episode = models.ForeignKey( + Episode, + models.CASCADE, + blank=True, + null=True, + verbose_name=_("episode"), + ) + sound = models.ForeignKey( + Sound, + models.CASCADE, + blank=True, + null=True, + verbose_name=_("sound"), + ) + position = models.PositiveSmallIntegerField( + _("order"), + default=0, + help_text=_("position in the playlist"), + ) + timestamp = models.PositiveSmallIntegerField( + _("timestamp"), + blank=True, + null=True, + help_text=_("position (in seconds)"), + ) + title = models.CharField(_("title"), max_length=128) + artist = models.CharField(_("artist"), max_length=128) + album = models.CharField(_("album"), max_length=128, null=True, blank=True) + tags = TaggableManager(verbose_name=_("tags"), blank=True) + year = models.IntegerField(_("year"), blank=True, null=True) + # FIXME: remove? + info = models.CharField( + _("information"), + max_length=128, + blank=True, + null=True, + help_text=_( + "additional informations about this track, such as " "the version, if is it a remix, features, etc." + ), + ) + + class Meta: + verbose_name = _("Track") + verbose_name_plural = _("Tracks") + ordering = ("position",) + + def __str__(self): + return "{self.artist} -- {self.title} -- {self.position}".format(self=self) + + def save(self, *args, **kwargs): + if (self.sound is None and self.episode is None) or (self.sound is not None and self.episode is not None): + raise ValueError("sound XOR episode is required") + super().save(*args, **kwargs) diff --git a/aircox/models/user_settings.py b/aircox/models/user_settings.py index 44089e5..48803ad 100644 --- a/aircox/models/user_settings.py +++ b/aircox/models/user_settings.py @@ -14,5 +14,5 @@ class UserSettings(models.Model): verbose_name=_("User"), related_name="aircox_settings", ) - playlist_editor_columns = models.JSONField(_("Playlist Editor Columns")) - playlist_editor_sep = models.CharField(_("Playlist Editor Separator"), max_length=16) + tracklist_editor_columns = models.JSONField(_("Playlist Editor Columns")) + tracklist_editor_sep = models.CharField(_("Playlist Editor Separator"), max_length=16) diff --git a/aircox/permissions.py b/aircox/permissions.py new file mode 100644 index 0000000..9320b23 --- /dev/null +++ b/aircox/permissions.py @@ -0,0 +1,89 @@ +# Provide permissions handling +# we don't import models at module level in order to avoid migration problems +from django.utils.translation import gettext_lazy as _ +from django.contrib.auth.models import Group, Permission +from django.contrib.contenttypes.models import ContentType + +from .models import Program + + +__all__ = ("PagePermissions", "program") + + +class PagePermissions: + """Handles obj permissions initialization of page subclass.""" + + model = None + # TODO: move values to subclass + groups = ({"label": _("editors"), "field": "editors_group_id", "perms": ["update"]},) + """Groups informations initialized.""" + groups_name_format = "{obj.title}: {group_label}" + """Format used for groups name.""" + perms_name_format = "{obj.title}: can {perm}" + """Format used for permission name (displayed to humans).""" + perms_codename_format = "{obj._meta.label_lower}_{obj.pk}_{perm}" + """Format used for permissions codename.""" + + def __init__(self, model): + self.model = model + + def can(self, user, perm, obj): + """Return True wether if user can edit Program or its children.""" + from .models.page import ChildPage + + if isinstance(obj, ChildPage): + obj = obj.parent_subclass + + if not isinstance(obj, self.model): + return False + + if user.is_superuser: + return True + + perm = self.perms_codename_format.format(self=self, perm=perm) + return user.has_perm(perm) + + def init(self, obj, model=None): + """Initialize permissions for the provided obj. + + Return True if group or permission have been created (`obj` has + thus been saved). + """ + updated = False + created_groups = [] + + # init groups + for infos in self.groups: + group = getattr(obj, infos["field"]) + if not group: + group, created = self.init_group(obj, infos) + setattr(obj, infos["field"], group.pk) + updated = True + created and created_groups.append((group, infos)) + + if updated: + obj.save() + + # init perms + for group, infos in created_groups: + self.init_perms(obj, group, infos) + + return updated + + def init_group(self, obj, infos): + name = self.groups_name_format.format(obj=obj, group_label=infos["label"]) + return Group.objects.get_or_create(name=name) + + def init_perms(self, obj, group, infos): + # TODO: avoid multiple database hits + for name in infos["perms"]: + perm, _ = Permission.objects.get_or_create( + codename=self.perms_codename_format.format(obj=obj, perm=name), + content_type=ContentType.objects.get_for_model(obj), + defaults={"name": self.perms_name_format.format(obj=obj, perm=name)}, + ) + if perm not in group.permissions.all(): + group.permissions.add(perm) + + +program = PagePermissions(Program) diff --git a/aircox/serializers/__init__.py b/aircox/serializers/__init__.py index 531a3db..f450d35 100644 --- a/aircox/serializers/__init__.py +++ b/aircox/serializers/__init__.py @@ -1,12 +1,18 @@ +from . import auth from .admin import TrackSerializer, UserSettingsSerializer +from .episode import EpisodeSoundSerializer, EpisodeSerializer from .log import LogInfo, LogInfoSerializer -from .sound import PodcastSerializer, SoundSerializer +from .page import CommentSerializer +from .sound import SoundSerializer __all__ = ( - "TrackSerializer", - "UserSettingsSerializer", + "auth", + "CommentSerializer", "LogInfo", "LogInfoSerializer", + "EpisodeSoundSerializer", + "EpisodeSerializer", "SoundSerializer", - "PodcastSerializer", + "TrackSerializer", + "UserSettingsSerializer", ) diff --git a/aircox/serializers/admin.py b/aircox/serializers/admin.py index 1fbccef..f58f28b 100644 --- a/aircox/serializers/admin.py +++ b/aircox/serializers/admin.py @@ -1,9 +1,17 @@ from rest_framework import serializers + +from filer.models.imagemodels import Image from taggit.serializers import TaggitSerializer, TagListSerializerField from ..models import Track, UserSettings -__all__ = ("TrackSerializer", "UserSettingsSerializer") +__all__ = ("ImageSerializer", "TrackSerializer", "UserSettingsSerializer") + + +class ImageSerializer(serializers.ModelSerializer): + class Meta: + model = Image + fields = "__all__" class TrackSerializer(TaggitSerializer, serializers.ModelSerializer): @@ -27,10 +35,10 @@ class TrackSerializer(TaggitSerializer, serializers.ModelSerializer): class UserSettingsSerializer(serializers.ModelSerializer): - # TODO: validate fields values (playlist_editor_columns at least) + # TODO: validate fields values (tracklist_editor_columns at least) class Meta: model = UserSettings - fields = ("playlist_editor_columns", "playlist_editor_sep") + fields = ("tracklist_editor_columns", "tracklist_editor_sep") def create(self, validated_data): user = self.context.get("user") diff --git a/aircox/serializers/auth.py b/aircox/serializers/auth.py new file mode 100644 index 0000000..904a6e9 --- /dev/null +++ b/aircox/serializers/auth.py @@ -0,0 +1,28 @@ +from django.contrib.auth.models import User, Group +from rest_framework import serializers + + +__all__ = ("UserSerializer", "GroupSerializer", "UserGroupSerializer") + + +class UserSerializer(serializers.ModelSerializer): + class Meta: + exclude = ("password",) + model = User + + +class GroupSerializer(serializers.ModelSerializer): + class Meta: + exclude = ("permissions",) + model = Group + + +class UserGroupSerializer(serializers.ModelSerializer): + group = GroupSerializer(read_only=True) + user = UserSerializer(read_only=True) + user_id = serializers.IntegerField() + group_id = serializers.IntegerField() + + class Meta: + model = User.groups.through + fields = ("id", "group_id", "user_id", "group", "user") diff --git a/aircox/serializers/episode.py b/aircox/serializers/episode.py new file mode 100644 index 0000000..fd413f5 --- /dev/null +++ b/aircox/serializers/episode.py @@ -0,0 +1,36 @@ +from rest_framework import serializers + +from .. import models +from .sound import SoundSerializer +from .admin import TrackSerializer + + +class EpisodeSoundSerializer(serializers.ModelSerializer): + sound = SoundSerializer(read_only=True) + + class Meta: + model = models.EpisodeSound + fields = [ + "id", + "position", + "episode", + "broadcast", + "sound", + "sound_id", + ] + + +class EpisodeSerializer(serializers.ModelSerializer): + playlist = EpisodeSoundSerializer(source="episodesound_set", many=True, read_only=True) + tracks = TrackSerializer(source="track_set", many=True, read_only=True) + + class Meta: + model = models.Episode + fields = [ + "id", + "title", + "content", + "pub_date", + "playlist", + "tracks", + ] diff --git a/aircox/serializers/page.py b/aircox/serializers/page.py new file mode 100644 index 0000000..3f52fff --- /dev/null +++ b/aircox/serializers/page.py @@ -0,0 +1,12 @@ +from rest_framework import serializers + +from aircox import models + + +__all__ = ("CommentSerializer",) + + +class CommentSerializer(serializers.ModelSerializer): + class Meta: + model = models.Comment + fields = ["page", "nickname", "email", "date", "content"] diff --git a/aircox/serializers/sound.py b/aircox/serializers/sound.py index e91ab9c..37b5eb4 100644 --- a/aircox/serializers/sound.py +++ b/aircox/serializers/sound.py @@ -1,43 +1,24 @@ from rest_framework import serializers -from ..models import Sound +from .. import models -__all__ = ("SoundSerializer", "PodcastSerializer") +__all__ = ("SoundSerializer",) class SoundSerializer(serializers.ModelSerializer): file = serializers.FileField(use_url=False) class Meta: - model = Sound + model = models.Sound fields = [ - "pk", + "id", "name", "program", - "episode", - "type", "file", "duration", "mtime", "is_good_quality", "is_public", - "url", - ] - - -class PodcastSerializer(serializers.ModelSerializer): - # serializers.HyperlinkedIdentityField(view_name='sound', format='html') - - class Meta: - model = Sound - fields = [ - "pk", - "name", - "program", - "episode", - "type", - "duration", - "mtime", - "url", "is_downloadable", + "url", ] diff --git a/aircox/static/aircox/admin.css b/aircox/static/aircox/admin.css new file mode 100644 index 0000000..c8f9aad --- /dev/null +++ b/aircox/static/aircox/admin.css @@ -0,0 +1 @@ +:root{--title-1-sz: 1.6rem;--title-2-sz: 1.4rem;--title-3-sz: 1.2rem;--subtitle-1-sz: 1.6rem;--subtitle-2-sz: 1.4rem;--subtitle-3-sz: 1.2rem;--heading-font-family: default;--heading-bg: var(--main-color);--heading-fg: var(--text-color);--heading-hg-fg: var(--text-color);--heading-hg-bg: var(--secondary-color);--heading-link-hv-fg: var(--link-fg);--cover-w: 14rem;--cover-h: 14rem;--cover-small-w: 10rem;--cover-small-h: 10rem;--cover-tiny-w: 10rem;--cover-tiny-h: 10rem;--card-w: var(--cover-w);--preview-bg: var(--body-bg);--preview-title-sz: var(--title-3-sz);--preview-subtitle-sz: var(--title-3-sz);--preview-cover-size: 14rem;--preview-cover-small-size: 10rem;--preview-cover-tiny-size: 4rem;--preview-wide-content-sz: 1.2rem;--preview-heading-bg-color: var(--main-color);--header-height: var(--cover-h);--a-carousel-p: 1.4rem;--a-carousel-ml: .7rem ;--a-carousel-gap: 1.2rem;--a-carousel-nav-x: -.6em;--a-carousel-bg: none;--a-progress-bg: transparent;--a-progress-bar-bg: var(--secondary-color);--a-progress-bar-color: var(--text-color);--a-progress-bar-pd: .4rem;--a-playlist-header-bg: var(--secondary-color);--a-playlist-header-fg: var(--text-color);--a-playlist-title-sz: 1rem;--a-playlist-title-pd: .6rem;--a-playlist-item-border: 1px var(--secondary-color) solid;--a-sound-bg: var(--main-color);--a-sound-hv-bg: var(--main-color);--a-sound-hv-fg: var(--secondary-color);--a-sound-playing-fg: var(--secondary-color-dark);--a-sound-text-sz: 1rem;--a-player-url-fg: var(--text-color);--a-player-panel-bg: var(--main-color);--a-player-bar-height: var(--nav-primary-height);--a-player-bar-bg: var(--main-color);--a-player-bar-title-alone-sz: 1.4rem;--a-player-bar-button-fg: var(--button-fg);--a-player-bar-button-fg: var(--button-bg);--a-player-bar-button-hv-fg: var(--button-hv-fg);--a-player-bar-button-hv-bg: var(--button-hv-bg);--button-fg: var(--text-color);--button-bg: var(--main-color);--button-sec-bg: var(--main-color-light);--button-hv-fg: var(--text-color);--button-hv-bg: var(--secondary-color-light);--button-active-fg: var(--text-color);--button-active-bg: var(--secondary-color)}@media screen and (max-width: 1380px){:root{--cover-w: 10rem;--cover-h: 10rem;--cover-small-w: 6rem;--cover-small-h: 6rem;--cover-tiny-w: 4rem;--cover-tiny-h: 4rem;--section-content-sz: 1rem}}.title.is-1,.header.preview .title.is-1{font-size:var(--title-1-sz)}.title.is-2,.header.preview .title.is-2{font-size:var(--title-2-sz)}.title.is-3,.header.preview .title.is-3{font-size:var(--title-3-sz)}.subtitle,.header.preview .subtitle{color:var(--text-color-light)}.subtitle.is-1,.header.preview .subtitle.is-1{font-size:var(--subtitle-1-sz)}.subtitle.is-2,.header.preview .subtitle.is-2{font-size:var(--subtitle-2-sz)}.subtitle.is-3,.header.preview .subtitle.is-3{font-size:var(--subtitle-3-sz)}.title+.subtitle{padding-top:0!important}.headings a,a.heading,a.subtitle{text-decoration:none!important}.heading{display:inline-block}.heading:not(:empty){padding:.4rem;margin-top:0!important;vertical-align:top}.heading:not(:empty).highlight,.heading:not(:empty).active,.preview.active .heading:not(:empty){color:var(--heading-hg-fg)}.modal-card{max-width:1380px}.modal-card{max-height:calc(100% - 10rem)}.preview{position:relative;background-size:cover;background-color:var(--preview-bg)!important}.preview.preview-item{width:100%}.preview.columns,.preview .headings.columns{margin-left:0;margin-right:0}.preview.columns .column,.preview .headings.columns .column{padding:0}.preview .title,.preview .title:not(:last-child){font-weight:700;font-size:var(--preview-title-sz);margin-bottom:unset}.preview .subtitle{font-weight:500;font-size:var(--preview-subtitle-sz);margin-bottom:unset}.preview .headings{background-size:cover}.preview .headings>*{margin:0}.preview .headings .column{padding:0}.preview .headings a{color:var(--text-color)}.preview .headings a:hover{color:var(--heading-link-hv-fg)!important}.preview.tiny .title{font-size:calc(var(--preview-title-sz) * .8)}.preview.tiny .subtitle{font-size:calc(var(--preview-subtitle-sz) * .8)}.preview.tiny .content{font-size:1rem;max-height:3rem;overflow:hidden}.preview-cover{background:var(--preview-bg);background-size:cover;background-repeat:no-repeat;height:var(--cover-h);max-width:calc(var(--cover-w) * 1.5);min-width:var(--cover-w);overflow:hidden;border:1px #c4c4c4 solid}.preview-cover img{height:var(--cover-h);max-width:calc(var(--cover-w) * 1.5);min-width:var(--cover-w)}.preview-cover img.hide{visibility:hidden}.preview-cover.small,.preview.small .preview-cover{min-width:unset;height:var(--preview-cover-small-size);width:var(--preview-cover-small-size)!important;min-width:var(--preview-cover-small-size)}.preview-cover.tiny,.preview.tiny .preview-cover{min-width:unset;height:var(--preview-cover-tiny-size);width:var(--preview-cover-tiny-size)!important;min-width:var(--preview-cover-tiny-size)}.preview-header{width:100%}.preview-header.no-cover{height:unset}.preview-header .headings{padding-top:2rem}.preview-header .headings,.preview-header>.container{width:100%}.preview-header>.container{height:100%}.list-item{display:flex;flex-direction:column;width:100%}.list-item .headings{display:flex;flex-direction:row;padding:0;margin-bottom:.4rem!important}.list-item .headings .heading{padding:0rem}.list-item .title{flex-grow:1}.list-item .subtitle{font-size:var(--preview-title-sz);text-align:right}.list-item .subtitle:not(:empty){min-width:9rem}.list-item .media-content{height:100%;margin-bottom:unset}.list-item:not(.no-cover) .list-item .media-content{min-height:var(--preview-cover-small-size)}.list-item .actions{text-align:right;align-items:center}.list-item:not(.wide) .media{padding:.6rem;border:1px solid var(--break-color)!important}@media screen and (max-width: 400px){.list-item .headings{flex-direction:column}.list-item .headings .heading{display:inline;text-align:left}.list-item .headings .subtitle{color:unset!important;background:none!important}}.list-item.wide .preview-cover{box-shadow:0 0 1em #0003}.list-item.wide .content{font-size:var(--preview-wide-content-sz);flex-grow:1}.preview-card{display:flex;flex-direction:column;width:var(--card-w);padding:0rem!important;margin-bottom:auto;background-color:var(--preview-bg)!important;transition:box-shadow .2s}.preview-card:hover figure{box-shadow:0 0 1em #0003}.preview-card:hover a{color:var(--heading-link-hv-fg)}.preview-card .headings{margin-top:.4rem}.preview-card .headings .heading{display:block!important}.preview-card .headings .subtitle{font-size:1.2rem}.preview-card .card-content{flex-grow:1;position:relative}.preview-card .card-content figure{height:var(--cover-h);width:var(--cover-w)}.preview-card .card-content .actions{position:absolute;padding:.4rem;bottom:0rem;right:0rem}.a-carousel .a-carousel-viewport{box-shadow:inset 0 0 20rem var(--a-carousel-bg);padding:0rem;padding-top:var(--a-carousel-p);margin-top:calc(0rem - var(--a-carousel-p))}.a-carousel-container{width:100%;gap:var(--a-carousel-gap);transition:margin-left 1s}.a-carousel-container>*{flex-shrink:0}.a-carousel-bullets-container{padding-left:var(--a-carousel-ml)}.a-carousel-bullets-container .bullet{margin:.2rem;cursor:pointer}.a-carousel-bullets-container .bullet:hover{color:var(--link-fg)}.a-progress{display:flex;flex-direction:row;margin:0;padding:0}.a-progress:hover{background-color:var(--a-progress-bg)}.a-progress .a-progress-bar-container{flex-grow:1;margin:0}.a-progress>time,.a-progress .a-progress-bar{height:100%;padding:var(--a-progress-bar-pd)}.a-progress .a-progress-bar{background-color:var(--a-progress-bar-bg);color:var(--a-progress-bar-color)}.playlist .header,.a-playlist .header{display:flex;flex-direction:row}.playlist .header .title,.playlist .header .button,.a-playlist .header .title,.a-playlist .header .button{background-color:var(--a-playlist-header-bg);color:var(--a-playlist-header-fg)}.playlist .header .title,.a-playlist .header .title{font-size:var(--a-playlist-title-sz);margin:0;padding:var(--a-playlist-title-pd)}.playlist li,.a-playlist li{list-style:none;border-bottom:var(--a-playlist-item-border)}.playlist li:last-child,.a-playlist li:last-child{border-bottom:0px}.a-sound-item{display:flex;align-items:center;flex-direction:row;height:3rem;background-color:var(--a-sound-bg)}.a-sound-item.playing .label{color:var(--a-sound-playing-fg)!important}.a-sound-item:hover{background-color:var(--a-sound-hv-bg)}.a-sound-item:hover .label{color:var(--a-sound-hv-fg)!important}.a-sound-item .label:hover:before,.a-sound-item.playing .label:before{content:"";font-family:"Font Awesome 6 Free";margin-right:.6em}.a-sound-item.playing .label:hover:before{content:"";margin:0}.a-sound-item .label{cursor:pointer;margin:0!important;padding:.6em;font-size:var(--a-sound-text-sz);font-family:var(--heading-font-family)}.a-sound-item .label .icon{padding:0em .6rem}.a-sound-item .button{width:3em;font-size:var(--a-sound-text-sz)}.a-sound-item .button:hover{color:var(--a-sound-hv-fg)!important;background-color:unset}.player-container{z-index:1000000}.a-player{box-shadow:0 -.5em .5em #0000000d}.a-player a{color:var(--a-player-url-fg)}.a-player .button{color:var(--text-black)}.a-player .button:hover{color:var(--button-fg)}.a-player-panels{background:var(--a-player-panel-bg);height:0%;transition:height 1s}.a-player-panels.is-open{height:auto}.a-player-panel{padding-bottom:.6rem;max-height:80%;overflow-y:auto}.a-player-panel .a-sound-item:not(:hover){background-color:transparent}.a-player-progress{height:.4em;overflow:hidden}.a-player-progress time{display:none}.a-player-progress:hover,.a-player-panels.is-open+.a-player-progress{background:var(--a-player-bar-bg);height:2em}.a-player-progress:hover time,.a-player-panels.is-open+.a-player-progress time{display:unset}.a-player-bar{display:flex;flex-direction:row;justify-content:center;height:var(--a-player-bar-height);border-top:1px #ddd solid;background:var(--a-player-bar-bg)}.a-player-bar>*{height:100%}.a-player-bar .cover{height:100%}.a-player-bar .title{font-size:1rem;margin:0}.a-player-bar .title:last-child{font-size:var(--a-player-bar-title-alone-sz)}.a-player-bar .button{font-size:1.4rem;height:100%;padding:.4rem!important;min-width:calc(var(--a-player-bar-height) + .8rem);border-radius:0}.a-player-bar .button.open{background-color:var(--button-active-bg);color:var(--button-active-fg)}.a-player-bar-content{display:flex;flex-direction:vertical;align-items:center;flex-grow:1;padding:0 .6rem;border-right:1px black solid}.a-player-bar-content .title{max-height:calc(var(--a-player-bar-height) - .6rem);overflow:hidden}.a-tracklist-editor .dropdown{display:unset!important}.a-select-file>*:not(:last-child){margin-bottom:.6rem}.a-select-file .upload-preview{max-width:100%}.a-select-file .a-select-file-list{display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:.6rem}.a-select-file .file-preview{width:100%;overflow:hidden}.a-select-file .file-preview:hover{box-shadow:0 0 1em #0003}.a-select-file .file-preview.active{box-shadow:0 0 1em #0006}.a-select-file .file-preview img{width:100%;max-height:10rem}.button{-moz-appearance:none;-webkit-appearance:none;align-items:center;border:1px solid transparent;border-radius:4px;box-shadow:none;display:inline-flex;font-size:1rem;height:2.5em;justify-content:flex-start;line-height:1.5;padding-bottom:calc(.5em - 1px);padding-left:calc(.75em - 1px);padding-right:calc(.75em - 1px);padding-top:calc(.5em - 1px);position:relative;vertical-align:top}.button:focus,.is-focused.button,.button:active,.is-active.button{outline:none}[disabled].button,fieldset[disabled] .button{cursor:not-allowed}.button{-webkit-touch-callout:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.navbar-link:not(.is-arrowless):after{border:3px solid transparent;border-radius:2px;border-right:0;border-top:0;content:" ";display:block;height:.625em;margin-top:-.4375em;pointer-events:none;position:absolute;top:50%;transform:rotate(-45deg);transform-origin:center;width:.625em}.button.is-loading:after{animation:spinAround .5s infinite linear;border:2px solid hsl(0,0%,86%);border-radius:9999px;border-right-color:transparent;border-top-color:transparent;content:"";display:block;height:1em;position:relative;width:1em}.navbar-burger{-moz-appearance:none;-webkit-appearance:none;appearance:none;background:none;border:none;color:currentColor;font-family:inherit;font-size:1em;margin:0;padding:0}.button{background-color:#fff;border-color:#dbdbdb;border-width:1px;color:#363636;cursor:pointer;justify-content:center;padding-bottom:calc(.5em - 1px);padding-left:1em;padding-right:1em;padding-top:calc(.5em - 1px);text-align:center;white-space:nowrap}.button strong{color:inherit}.button .icon,.button .icon.is-small,.button .icon.is-medium,.button .icon.is-large{height:1.5em;width:1.5em}.button .icon:first-child:not(:last-child){margin-left:calc(-.5em - 1px);margin-right:.25em}.button .icon:last-child:not(:first-child){margin-left:.25em;margin-right:calc(-.5em - 1px)}.button .icon:first-child:last-child{margin-left:calc(-.5em - 1px);margin-right:calc(-.5em - 1px)}.button:hover,.button.is-hovered{border-color:#b5b5b5;color:#363636}.button:focus,.button.is-focused{border-color:#485fc7;color:#363636}.button:focus:not(:active),.button.is-focused:not(:active){box-shadow:0 0 0 .125em #485fc740}.button:active,.button.is-active{border-color:#4a4a4a;color:#363636}.button.is-text{background-color:transparent;border-color:transparent;color:#4a4a4a;text-decoration:underline}.button.is-text:hover,.button.is-text.is-hovered,.button.is-text:focus,.button.is-text.is-focused{background-color:#f5f5f5;color:#363636}.button.is-text:active,.button.is-text.is-active{background-color:#e8e8e8;color:#363636}.button.is-text[disabled],fieldset[disabled] .button.is-text{background-color:transparent;border-color:transparent;box-shadow:none}.button.is-ghost{background:none;border-color:transparent;color:#485fc7;text-decoration:none}.button.is-ghost:hover,.button.is-ghost.is-hovered{color:#485fc7;text-decoration:underline}.button.is-white{background-color:#fff;border-color:transparent;color:#0a0a0a}.button.is-white:hover,.button.is-white.is-hovered{background-color:#f9f9f9;border-color:transparent;color:#0a0a0a}.button.is-white:focus,.button.is-white.is-focused{border-color:transparent;color:#0a0a0a}.button.is-white:focus:not(:active),.button.is-white.is-focused:not(:active){box-shadow:0 0 0 .125em #ffffff40}.button.is-white:active,.button.is-white.is-active{background-color:#f2f2f2;border-color:transparent;color:#0a0a0a}.button.is-white[disabled],fieldset[disabled] .button.is-white{background-color:#fff;border-color:#fff;box-shadow:none}.button.is-white.is-inverted{background-color:#0a0a0a;color:#fff}.button.is-white.is-inverted:hover,.button.is-white.is-inverted.is-hovered{background-color:#000}.button.is-white.is-inverted[disabled],fieldset[disabled] .button.is-white.is-inverted{background-color:#0a0a0a;border-color:transparent;box-shadow:none;color:#fff}.button.is-white.is-loading:after{border-color:transparent transparent hsl(0,0%,4%) hsl(0,0%,4%)!important}.button.is-white.is-outlined{background-color:transparent;border-color:#fff;color:#fff}.button.is-white.is-outlined:hover,.button.is-white.is-outlined.is-hovered,.button.is-white.is-outlined:focus,.button.is-white.is-outlined.is-focused{background-color:#fff;border-color:#fff;color:#0a0a0a}.button.is-white.is-outlined.is-loading:after{border-color:transparent transparent hsl(0,0%,100%) hsl(0,0%,100%)!important}.button.is-white.is-outlined.is-loading:hover:after,.button.is-white.is-outlined.is-loading.is-hovered:after,.button.is-white.is-outlined.is-loading:focus:after,.button.is-white.is-outlined.is-loading.is-focused:after{border-color:transparent transparent hsl(0,0%,4%) hsl(0,0%,4%)!important}.button.is-white.is-outlined[disabled],fieldset[disabled] .button.is-white.is-outlined{background-color:transparent;border-color:#fff;box-shadow:none;color:#fff}.button.is-white.is-inverted.is-outlined{background-color:transparent;border-color:#0a0a0a;color:#0a0a0a}.button.is-white.is-inverted.is-outlined:hover,.button.is-white.is-inverted.is-outlined.is-hovered,.button.is-white.is-inverted.is-outlined:focus,.button.is-white.is-inverted.is-outlined.is-focused{background-color:#0a0a0a;color:#fff}.button.is-white.is-inverted.is-outlined.is-loading:hover:after,.button.is-white.is-inverted.is-outlined.is-loading.is-hovered:after,.button.is-white.is-inverted.is-outlined.is-loading:focus:after,.button.is-white.is-inverted.is-outlined.is-loading.is-focused:after{border-color:transparent transparent hsl(0,0%,100%) hsl(0,0%,100%)!important}.button.is-white.is-inverted.is-outlined[disabled],fieldset[disabled] .button.is-white.is-inverted.is-outlined{background-color:transparent;border-color:#0a0a0a;box-shadow:none;color:#0a0a0a}.button.is-black{background-color:#0a0a0a;border-color:transparent;color:#fff}.button.is-black:hover,.button.is-black.is-hovered{background-color:#040404;border-color:transparent;color:#fff}.button.is-black:focus,.button.is-black.is-focused{border-color:transparent;color:#fff}.button.is-black:focus:not(:active),.button.is-black.is-focused:not(:active){box-shadow:0 0 0 .125em #0a0a0a40}.button.is-black:active,.button.is-black.is-active{background-color:#000;border-color:transparent;color:#fff}.button.is-black[disabled],fieldset[disabled] .button.is-black{background-color:#0a0a0a;border-color:#0a0a0a;box-shadow:none}.button.is-black.is-inverted{background-color:#fff;color:#0a0a0a}.button.is-black.is-inverted:hover,.button.is-black.is-inverted.is-hovered{background-color:#f2f2f2}.button.is-black.is-inverted[disabled],fieldset[disabled] .button.is-black.is-inverted{background-color:#fff;border-color:transparent;box-shadow:none;color:#0a0a0a}.button.is-black.is-loading:after{border-color:transparent transparent hsl(0,0%,100%) hsl(0,0%,100%)!important}.button.is-black.is-outlined{background-color:transparent;border-color:#0a0a0a;color:#0a0a0a}.button.is-black.is-outlined:hover,.button.is-black.is-outlined.is-hovered,.button.is-black.is-outlined:focus,.button.is-black.is-outlined.is-focused{background-color:#0a0a0a;border-color:#0a0a0a;color:#fff}.button.is-black.is-outlined.is-loading:after{border-color:transparent transparent hsl(0,0%,4%) hsl(0,0%,4%)!important}.button.is-black.is-outlined.is-loading:hover:after,.button.is-black.is-outlined.is-loading.is-hovered:after,.button.is-black.is-outlined.is-loading:focus:after,.button.is-black.is-outlined.is-loading.is-focused:after{border-color:transparent transparent hsl(0,0%,100%) hsl(0,0%,100%)!important}.button.is-black.is-outlined[disabled],fieldset[disabled] .button.is-black.is-outlined{background-color:transparent;border-color:#0a0a0a;box-shadow:none;color:#0a0a0a}.button.is-black.is-inverted.is-outlined{background-color:transparent;border-color:#fff;color:#fff}.button.is-black.is-inverted.is-outlined:hover,.button.is-black.is-inverted.is-outlined.is-hovered,.button.is-black.is-inverted.is-outlined:focus,.button.is-black.is-inverted.is-outlined.is-focused{background-color:#fff;color:#0a0a0a}.button.is-black.is-inverted.is-outlined.is-loading:hover:after,.button.is-black.is-inverted.is-outlined.is-loading.is-hovered:after,.button.is-black.is-inverted.is-outlined.is-loading:focus:after,.button.is-black.is-inverted.is-outlined.is-loading.is-focused:after{border-color:transparent transparent hsl(0,0%,4%) hsl(0,0%,4%)!important}.button.is-black.is-inverted.is-outlined[disabled],fieldset[disabled] .button.is-black.is-inverted.is-outlined{background-color:transparent;border-color:#fff;box-shadow:none;color:#fff}.button.is-light{background-color:#f5f5f5;border-color:transparent;color:#000000b3}.button.is-light:hover,.button.is-light.is-hovered{background-color:#eee;border-color:transparent;color:#000000b3}.button.is-light:focus,.button.is-light.is-focused{border-color:transparent;color:#000000b3}.button.is-light:focus:not(:active),.button.is-light.is-focused:not(:active){box-shadow:0 0 0 .125em #f5f5f540}.button.is-light:active,.button.is-light.is-active{background-color:#e8e8e8;border-color:transparent;color:#000000b3}.button.is-light[disabled],fieldset[disabled] .button.is-light{background-color:#f5f5f5;border-color:#f5f5f5;box-shadow:none}.button.is-light.is-inverted{background-color:#000000b3;color:#f5f5f5}.button.is-light.is-inverted:hover,.button.is-light.is-inverted.is-hovered{background-color:#000000b3}.button.is-light.is-inverted[disabled],fieldset[disabled] .button.is-light.is-inverted{background-color:#000000b3;border-color:transparent;box-shadow:none;color:#f5f5f5}.button.is-light.is-loading:after{border-color:transparent transparent rgba(0,0,0,.7) rgba(0,0,0,.7)!important}.button.is-light.is-outlined{background-color:transparent;border-color:#f5f5f5;color:#f5f5f5}.button.is-light.is-outlined:hover,.button.is-light.is-outlined.is-hovered,.button.is-light.is-outlined:focus,.button.is-light.is-outlined.is-focused{background-color:#f5f5f5;border-color:#f5f5f5;color:#000000b3}.button.is-light.is-outlined.is-loading:after{border-color:transparent transparent hsl(0,0%,96%) hsl(0,0%,96%)!important}.button.is-light.is-outlined.is-loading:hover:after,.button.is-light.is-outlined.is-loading.is-hovered:after,.button.is-light.is-outlined.is-loading:focus:after,.button.is-light.is-outlined.is-loading.is-focused:after{border-color:transparent transparent rgba(0,0,0,.7) rgba(0,0,0,.7)!important}.button.is-light.is-outlined[disabled],fieldset[disabled] .button.is-light.is-outlined{background-color:transparent;border-color:#f5f5f5;box-shadow:none;color:#f5f5f5}.button.is-light.is-inverted.is-outlined{background-color:transparent;border-color:#000000b3;color:#000000b3}.button.is-light.is-inverted.is-outlined:hover,.button.is-light.is-inverted.is-outlined.is-hovered,.button.is-light.is-inverted.is-outlined:focus,.button.is-light.is-inverted.is-outlined.is-focused{background-color:#000000b3;color:#f5f5f5}.button.is-light.is-inverted.is-outlined.is-loading:hover:after,.button.is-light.is-inverted.is-outlined.is-loading.is-hovered:after,.button.is-light.is-inverted.is-outlined.is-loading:focus:after,.button.is-light.is-inverted.is-outlined.is-loading.is-focused:after{border-color:transparent transparent hsl(0,0%,96%) hsl(0,0%,96%)!important}.button.is-light.is-inverted.is-outlined[disabled],fieldset[disabled] .button.is-light.is-inverted.is-outlined{background-color:transparent;border-color:#000000b3;box-shadow:none;color:#000000b3}.button.is-dark{background-color:#363636;border-color:transparent;color:#fff}.button.is-dark:hover,.button.is-dark.is-hovered{background-color:#2f2f2f;border-color:transparent;color:#fff}.button.is-dark:focus,.button.is-dark.is-focused{border-color:transparent;color:#fff}.button.is-dark:focus:not(:active),.button.is-dark.is-focused:not(:active){box-shadow:0 0 0 .125em #36363640}.button.is-dark:active,.button.is-dark.is-active{background-color:#292929;border-color:transparent;color:#fff}.button.is-dark[disabled],fieldset[disabled] .button.is-dark{background-color:#363636;border-color:#363636;box-shadow:none}.button.is-dark.is-inverted{background-color:#fff;color:#363636}.button.is-dark.is-inverted:hover,.button.is-dark.is-inverted.is-hovered{background-color:#f2f2f2}.button.is-dark.is-inverted[disabled],fieldset[disabled] .button.is-dark.is-inverted{background-color:#fff;border-color:transparent;box-shadow:none;color:#363636}.button.is-dark.is-loading:after{border-color:transparent transparent #fff #fff!important}.button.is-dark.is-outlined{background-color:transparent;border-color:#363636;color:#363636}.button.is-dark.is-outlined:hover,.button.is-dark.is-outlined.is-hovered,.button.is-dark.is-outlined:focus,.button.is-dark.is-outlined.is-focused{background-color:#363636;border-color:#363636;color:#fff}.button.is-dark.is-outlined.is-loading:after{border-color:transparent transparent hsl(0,0%,21%) hsl(0,0%,21%)!important}.button.is-dark.is-outlined.is-loading:hover:after,.button.is-dark.is-outlined.is-loading.is-hovered:after,.button.is-dark.is-outlined.is-loading:focus:after,.button.is-dark.is-outlined.is-loading.is-focused:after{border-color:transparent transparent #fff #fff!important}.button.is-dark.is-outlined[disabled],fieldset[disabled] .button.is-dark.is-outlined{background-color:transparent;border-color:#363636;box-shadow:none;color:#363636}.button.is-dark.is-inverted.is-outlined{background-color:transparent;border-color:#fff;color:#fff}.button.is-dark.is-inverted.is-outlined:hover,.button.is-dark.is-inverted.is-outlined.is-hovered,.button.is-dark.is-inverted.is-outlined:focus,.button.is-dark.is-inverted.is-outlined.is-focused{background-color:#fff;color:#363636}.button.is-dark.is-inverted.is-outlined.is-loading:hover:after,.button.is-dark.is-inverted.is-outlined.is-loading.is-hovered:after,.button.is-dark.is-inverted.is-outlined.is-loading:focus:after,.button.is-dark.is-inverted.is-outlined.is-loading.is-focused:after{border-color:transparent transparent hsl(0,0%,21%) hsl(0,0%,21%)!important}.button.is-dark.is-inverted.is-outlined[disabled],fieldset[disabled] .button.is-dark.is-inverted.is-outlined{background-color:transparent;border-color:#fff;box-shadow:none;color:#fff}.button.is-primary{background-color:#00d1b2;border-color:transparent;color:#fff}.button.is-primary:hover,.button.is-primary.is-hovered{background-color:#00c4a7;border-color:transparent;color:#fff}.button.is-primary:focus,.button.is-primary.is-focused{border-color:transparent;color:#fff}.button.is-primary:focus:not(:active),.button.is-primary.is-focused:not(:active){box-shadow:0 0 0 .125em #00d1b240}.button.is-primary:active,.button.is-primary.is-active{background-color:#00b89c;border-color:transparent;color:#fff}.button.is-primary[disabled],fieldset[disabled] .button.is-primary{background-color:#00d1b2;border-color:#00d1b2;box-shadow:none}.button.is-primary.is-inverted{background-color:#fff;color:#00d1b2}.button.is-primary.is-inverted:hover,.button.is-primary.is-inverted.is-hovered{background-color:#f2f2f2}.button.is-primary.is-inverted[disabled],fieldset[disabled] .button.is-primary.is-inverted{background-color:#fff;border-color:transparent;box-shadow:none;color:#00d1b2}.button.is-primary.is-loading:after{border-color:transparent transparent #fff #fff!important}.button.is-primary.is-outlined{background-color:transparent;border-color:#00d1b2;color:#00d1b2}.button.is-primary.is-outlined:hover,.button.is-primary.is-outlined.is-hovered,.button.is-primary.is-outlined:focus,.button.is-primary.is-outlined.is-focused{background-color:#00d1b2;border-color:#00d1b2;color:#fff}.button.is-primary.is-outlined.is-loading:after{border-color:transparent transparent hsl(171,100%,41%) hsl(171,100%,41%)!important}.button.is-primary.is-outlined.is-loading:hover:after,.button.is-primary.is-outlined.is-loading.is-hovered:after,.button.is-primary.is-outlined.is-loading:focus:after,.button.is-primary.is-outlined.is-loading.is-focused:after{border-color:transparent transparent #fff #fff!important}.button.is-primary.is-outlined[disabled],fieldset[disabled] .button.is-primary.is-outlined{background-color:transparent;border-color:#00d1b2;box-shadow:none;color:#00d1b2}.button.is-primary.is-inverted.is-outlined{background-color:transparent;border-color:#fff;color:#fff}.button.is-primary.is-inverted.is-outlined:hover,.button.is-primary.is-inverted.is-outlined.is-hovered,.button.is-primary.is-inverted.is-outlined:focus,.button.is-primary.is-inverted.is-outlined.is-focused{background-color:#fff;color:#00d1b2}.button.is-primary.is-inverted.is-outlined.is-loading:hover:after,.button.is-primary.is-inverted.is-outlined.is-loading.is-hovered:after,.button.is-primary.is-inverted.is-outlined.is-loading:focus:after,.button.is-primary.is-inverted.is-outlined.is-loading.is-focused:after{border-color:transparent transparent hsl(171,100%,41%) hsl(171,100%,41%)!important}.button.is-primary.is-inverted.is-outlined[disabled],fieldset[disabled] .button.is-primary.is-inverted.is-outlined{background-color:transparent;border-color:#fff;box-shadow:none;color:#fff}.button.is-primary.is-light{background-color:#ebfffc;color:#00947e}.button.is-primary.is-light:hover,.button.is-primary.is-light.is-hovered{background-color:#defffa;border-color:transparent;color:#00947e}.button.is-primary.is-light:active,.button.is-primary.is-light.is-active{background-color:#d1fff8;border-color:transparent;color:#00947e}.button.is-link{background-color:#485fc7;border-color:transparent;color:#fff}.button.is-link:hover,.button.is-link.is-hovered{background-color:#3e56c4;border-color:transparent;color:#fff}.button.is-link:focus,.button.is-link.is-focused{border-color:transparent;color:#fff}.button.is-link:focus:not(:active),.button.is-link.is-focused:not(:active){box-shadow:0 0 0 .125em #485fc740}.button.is-link:active,.button.is-link.is-active{background-color:#3a51bb;border-color:transparent;color:#fff}.button.is-link[disabled],fieldset[disabled] .button.is-link{background-color:#485fc7;border-color:#485fc7;box-shadow:none}.button.is-link.is-inverted{background-color:#fff;color:#485fc7}.button.is-link.is-inverted:hover,.button.is-link.is-inverted.is-hovered{background-color:#f2f2f2}.button.is-link.is-inverted[disabled],fieldset[disabled] .button.is-link.is-inverted{background-color:#fff;border-color:transparent;box-shadow:none;color:#485fc7}.button.is-link.is-loading:after{border-color:transparent transparent #fff #fff!important}.button.is-link.is-outlined{background-color:transparent;border-color:#485fc7;color:#485fc7}.button.is-link.is-outlined:hover,.button.is-link.is-outlined.is-hovered,.button.is-link.is-outlined:focus,.button.is-link.is-outlined.is-focused{background-color:#485fc7;border-color:#485fc7;color:#fff}.button.is-link.is-outlined.is-loading:after{border-color:transparent transparent hsl(229,53%,53%) hsl(229,53%,53%)!important}.button.is-link.is-outlined.is-loading:hover:after,.button.is-link.is-outlined.is-loading.is-hovered:after,.button.is-link.is-outlined.is-loading:focus:after,.button.is-link.is-outlined.is-loading.is-focused:after{border-color:transparent transparent #fff #fff!important}.button.is-link.is-outlined[disabled],fieldset[disabled] .button.is-link.is-outlined{background-color:transparent;border-color:#485fc7;box-shadow:none;color:#485fc7}.button.is-link.is-inverted.is-outlined{background-color:transparent;border-color:#fff;color:#fff}.button.is-link.is-inverted.is-outlined:hover,.button.is-link.is-inverted.is-outlined.is-hovered,.button.is-link.is-inverted.is-outlined:focus,.button.is-link.is-inverted.is-outlined.is-focused{background-color:#fff;color:#485fc7}.button.is-link.is-inverted.is-outlined.is-loading:hover:after,.button.is-link.is-inverted.is-outlined.is-loading.is-hovered:after,.button.is-link.is-inverted.is-outlined.is-loading:focus:after,.button.is-link.is-inverted.is-outlined.is-loading.is-focused:after{border-color:transparent transparent hsl(229,53%,53%) hsl(229,53%,53%)!important}.button.is-link.is-inverted.is-outlined[disabled],fieldset[disabled] .button.is-link.is-inverted.is-outlined{background-color:transparent;border-color:#fff;box-shadow:none;color:#fff}.button.is-link.is-light{background-color:#eff1fa;color:#3850b7}.button.is-link.is-light:hover,.button.is-link.is-light.is-hovered{background-color:#e6e9f7;border-color:transparent;color:#3850b7}.button.is-link.is-light:active,.button.is-link.is-light.is-active{background-color:#dce0f4;border-color:transparent;color:#3850b7}.button.is-info{background-color:#3e8ed0;border-color:transparent;color:#fff}.button.is-info:hover,.button.is-info.is-hovered{background-color:#3488ce;border-color:transparent;color:#fff}.button.is-info:focus,.button.is-info.is-focused{border-color:transparent;color:#fff}.button.is-info:focus:not(:active),.button.is-info.is-focused:not(:active){box-shadow:0 0 0 .125em #3e8ed040}.button.is-info:active,.button.is-info.is-active{background-color:#3082c5;border-color:transparent;color:#fff}.button.is-info[disabled],fieldset[disabled] .button.is-info{background-color:#3e8ed0;border-color:#3e8ed0;box-shadow:none}.button.is-info.is-inverted{background-color:#fff;color:#3e8ed0}.button.is-info.is-inverted:hover,.button.is-info.is-inverted.is-hovered{background-color:#f2f2f2}.button.is-info.is-inverted[disabled],fieldset[disabled] .button.is-info.is-inverted{background-color:#fff;border-color:transparent;box-shadow:none;color:#3e8ed0}.button.is-info.is-loading:after{border-color:transparent transparent #fff #fff!important}.button.is-info.is-outlined{background-color:transparent;border-color:#3e8ed0;color:#3e8ed0}.button.is-info.is-outlined:hover,.button.is-info.is-outlined.is-hovered,.button.is-info.is-outlined:focus,.button.is-info.is-outlined.is-focused{background-color:#3e8ed0;border-color:#3e8ed0;color:#fff}.button.is-info.is-outlined.is-loading:after{border-color:transparent transparent hsl(207,61%,53%) hsl(207,61%,53%)!important}.button.is-info.is-outlined.is-loading:hover:after,.button.is-info.is-outlined.is-loading.is-hovered:after,.button.is-info.is-outlined.is-loading:focus:after,.button.is-info.is-outlined.is-loading.is-focused:after{border-color:transparent transparent #fff #fff!important}.button.is-info.is-outlined[disabled],fieldset[disabled] .button.is-info.is-outlined{background-color:transparent;border-color:#3e8ed0;box-shadow:none;color:#3e8ed0}.button.is-info.is-inverted.is-outlined{background-color:transparent;border-color:#fff;color:#fff}.button.is-info.is-inverted.is-outlined:hover,.button.is-info.is-inverted.is-outlined.is-hovered,.button.is-info.is-inverted.is-outlined:focus,.button.is-info.is-inverted.is-outlined.is-focused{background-color:#fff;color:#3e8ed0}.button.is-info.is-inverted.is-outlined.is-loading:hover:after,.button.is-info.is-inverted.is-outlined.is-loading.is-hovered:after,.button.is-info.is-inverted.is-outlined.is-loading:focus:after,.button.is-info.is-inverted.is-outlined.is-loading.is-focused:after{border-color:transparent transparent hsl(207,61%,53%) hsl(207,61%,53%)!important}.button.is-info.is-inverted.is-outlined[disabled],fieldset[disabled] .button.is-info.is-inverted.is-outlined{background-color:transparent;border-color:#fff;box-shadow:none;color:#fff}.button.is-info.is-light{background-color:#eff5fb;color:#296fa8}.button.is-info.is-light:hover,.button.is-info.is-light.is-hovered{background-color:#e4eff9;border-color:transparent;color:#296fa8}.button.is-info.is-light:active,.button.is-info.is-light.is-active{background-color:#dae9f6;border-color:transparent;color:#296fa8}.button.is-success{background-color:#48c78e;border-color:transparent;color:#fff}.button.is-success:hover,.button.is-success.is-hovered{background-color:#3ec487;border-color:transparent;color:#fff}.button.is-success:focus,.button.is-success.is-focused{border-color:transparent;color:#fff}.button.is-success:focus:not(:active),.button.is-success.is-focused:not(:active){box-shadow:0 0 0 .125em #48c78e40}.button.is-success:active,.button.is-success.is-active{background-color:#3abb81;border-color:transparent;color:#fff}.button.is-success[disabled],fieldset[disabled] .button.is-success{background-color:#48c78e;border-color:#48c78e;box-shadow:none}.button.is-success.is-inverted{background-color:#fff;color:#48c78e}.button.is-success.is-inverted:hover,.button.is-success.is-inverted.is-hovered{background-color:#f2f2f2}.button.is-success.is-inverted[disabled],fieldset[disabled] .button.is-success.is-inverted{background-color:#fff;border-color:transparent;box-shadow:none;color:#48c78e}.button.is-success.is-loading:after{border-color:transparent transparent #fff #fff!important}.button.is-success.is-outlined{background-color:transparent;border-color:#48c78e;color:#48c78e}.button.is-success.is-outlined:hover,.button.is-success.is-outlined.is-hovered,.button.is-success.is-outlined:focus,.button.is-success.is-outlined.is-focused{background-color:#48c78e;border-color:#48c78e;color:#fff}.button.is-success.is-outlined.is-loading:after{border-color:transparent transparent hsl(153,53%,53%) hsl(153,53%,53%)!important}.button.is-success.is-outlined.is-loading:hover:after,.button.is-success.is-outlined.is-loading.is-hovered:after,.button.is-success.is-outlined.is-loading:focus:after,.button.is-success.is-outlined.is-loading.is-focused:after{border-color:transparent transparent #fff #fff!important}.button.is-success.is-outlined[disabled],fieldset[disabled] .button.is-success.is-outlined{background-color:transparent;border-color:#48c78e;box-shadow:none;color:#48c78e}.button.is-success.is-inverted.is-outlined{background-color:transparent;border-color:#fff;color:#fff}.button.is-success.is-inverted.is-outlined:hover,.button.is-success.is-inverted.is-outlined.is-hovered,.button.is-success.is-inverted.is-outlined:focus,.button.is-success.is-inverted.is-outlined.is-focused{background-color:#fff;color:#48c78e}.button.is-success.is-inverted.is-outlined.is-loading:hover:after,.button.is-success.is-inverted.is-outlined.is-loading.is-hovered:after,.button.is-success.is-inverted.is-outlined.is-loading:focus:after,.button.is-success.is-inverted.is-outlined.is-loading.is-focused:after{border-color:transparent transparent hsl(153,53%,53%) hsl(153,53%,53%)!important}.button.is-success.is-inverted.is-outlined[disabled],fieldset[disabled] .button.is-success.is-inverted.is-outlined{background-color:transparent;border-color:#fff;box-shadow:none;color:#fff}.button.is-success.is-light{background-color:#effaf5;color:#257953}.button.is-success.is-light:hover,.button.is-success.is-light.is-hovered{background-color:#e6f7ef;border-color:transparent;color:#257953}.button.is-success.is-light:active,.button.is-success.is-light.is-active{background-color:#dcf4e9;border-color:transparent;color:#257953}.button.is-warning{background-color:#ffe08a;border-color:transparent;color:#000000b3}.button.is-warning:hover,.button.is-warning.is-hovered{background-color:#ffdc7d;border-color:transparent;color:#000000b3}.button.is-warning:focus,.button.is-warning.is-focused{border-color:transparent;color:#000000b3}.button.is-warning:focus:not(:active),.button.is-warning.is-focused:not(:active){box-shadow:0 0 0 .125em #ffe08a40}.button.is-warning:active,.button.is-warning.is-active{background-color:#ffd970;border-color:transparent;color:#000000b3}.button.is-warning[disabled],fieldset[disabled] .button.is-warning{background-color:#ffe08a;border-color:#ffe08a;box-shadow:none}.button.is-warning.is-inverted{background-color:#000000b3;color:#ffe08a}.button.is-warning.is-inverted:hover,.button.is-warning.is-inverted.is-hovered{background-color:#000000b3}.button.is-warning.is-inverted[disabled],fieldset[disabled] .button.is-warning.is-inverted{background-color:#000000b3;border-color:transparent;box-shadow:none;color:#ffe08a}.button.is-warning.is-loading:after{border-color:transparent transparent rgba(0,0,0,.7) rgba(0,0,0,.7)!important}.button.is-warning.is-outlined{background-color:transparent;border-color:#ffe08a;color:#ffe08a}.button.is-warning.is-outlined:hover,.button.is-warning.is-outlined.is-hovered,.button.is-warning.is-outlined:focus,.button.is-warning.is-outlined.is-focused{background-color:#ffe08a;border-color:#ffe08a;color:#000000b3}.button.is-warning.is-outlined.is-loading:after{border-color:transparent transparent hsl(44,100%,77%) hsl(44,100%,77%)!important}.button.is-warning.is-outlined.is-loading:hover:after,.button.is-warning.is-outlined.is-loading.is-hovered:after,.button.is-warning.is-outlined.is-loading:focus:after,.button.is-warning.is-outlined.is-loading.is-focused:after{border-color:transparent transparent rgba(0,0,0,.7) rgba(0,0,0,.7)!important}.button.is-warning.is-outlined[disabled],fieldset[disabled] .button.is-warning.is-outlined{background-color:transparent;border-color:#ffe08a;box-shadow:none;color:#ffe08a}.button.is-warning.is-inverted.is-outlined{background-color:transparent;border-color:#000000b3;color:#000000b3}.button.is-warning.is-inverted.is-outlined:hover,.button.is-warning.is-inverted.is-outlined.is-hovered,.button.is-warning.is-inverted.is-outlined:focus,.button.is-warning.is-inverted.is-outlined.is-focused{background-color:#000000b3;color:#ffe08a}.button.is-warning.is-inverted.is-outlined.is-loading:hover:after,.button.is-warning.is-inverted.is-outlined.is-loading.is-hovered:after,.button.is-warning.is-inverted.is-outlined.is-loading:focus:after,.button.is-warning.is-inverted.is-outlined.is-loading.is-focused:after{border-color:transparent transparent hsl(44,100%,77%) hsl(44,100%,77%)!important}.button.is-warning.is-inverted.is-outlined[disabled],fieldset[disabled] .button.is-warning.is-inverted.is-outlined{background-color:transparent;border-color:#000000b3;box-shadow:none;color:#000000b3}.button.is-warning.is-light{background-color:#fffaeb;color:#946c00}.button.is-warning.is-light:hover,.button.is-warning.is-light.is-hovered{background-color:#fff6de;border-color:transparent;color:#946c00}.button.is-warning.is-light:active,.button.is-warning.is-light.is-active{background-color:#fff3d1;border-color:transparent;color:#946c00}.button.is-danger{background-color:#f14668;border-color:transparent;color:#fff}.button.is-danger:hover,.button.is-danger.is-hovered{background-color:#f03a5f;border-color:transparent;color:#fff}.button.is-danger:focus,.button.is-danger.is-focused{border-color:transparent;color:#fff}.button.is-danger:focus:not(:active),.button.is-danger.is-focused:not(:active){box-shadow:0 0 0 .125em #f1466840}.button.is-danger:active,.button.is-danger.is-active{background-color:#ef2e55;border-color:transparent;color:#fff}.button.is-danger[disabled],fieldset[disabled] .button.is-danger{background-color:#f14668;border-color:#f14668;box-shadow:none}.button.is-danger.is-inverted{background-color:#fff;color:#f14668}.button.is-danger.is-inverted:hover,.button.is-danger.is-inverted.is-hovered{background-color:#f2f2f2}.button.is-danger.is-inverted[disabled],fieldset[disabled] .button.is-danger.is-inverted{background-color:#fff;border-color:transparent;box-shadow:none;color:#f14668}.button.is-danger.is-loading:after{border-color:transparent transparent #fff #fff!important}.button.is-danger.is-outlined{background-color:transparent;border-color:#f14668;color:#f14668}.button.is-danger.is-outlined:hover,.button.is-danger.is-outlined.is-hovered,.button.is-danger.is-outlined:focus,.button.is-danger.is-outlined.is-focused{background-color:#f14668;border-color:#f14668;color:#fff}.button.is-danger.is-outlined.is-loading:after{border-color:transparent transparent hsl(348,86%,61%) hsl(348,86%,61%)!important}.button.is-danger.is-outlined.is-loading:hover:after,.button.is-danger.is-outlined.is-loading.is-hovered:after,.button.is-danger.is-outlined.is-loading:focus:after,.button.is-danger.is-outlined.is-loading.is-focused:after{border-color:transparent transparent #fff #fff!important}.button.is-danger.is-outlined[disabled],fieldset[disabled] .button.is-danger.is-outlined{background-color:transparent;border-color:#f14668;box-shadow:none;color:#f14668}.button.is-danger.is-inverted.is-outlined{background-color:transparent;border-color:#fff;color:#fff}.button.is-danger.is-inverted.is-outlined:hover,.button.is-danger.is-inverted.is-outlined.is-hovered,.button.is-danger.is-inverted.is-outlined:focus,.button.is-danger.is-inverted.is-outlined.is-focused{background-color:#fff;color:#f14668}.button.is-danger.is-inverted.is-outlined.is-loading:hover:after,.button.is-danger.is-inverted.is-outlined.is-loading.is-hovered:after,.button.is-danger.is-inverted.is-outlined.is-loading:focus:after,.button.is-danger.is-inverted.is-outlined.is-loading.is-focused:after{border-color:transparent transparent hsl(348,86%,61%) hsl(348,86%,61%)!important}.button.is-danger.is-inverted.is-outlined[disabled],fieldset[disabled] .button.is-danger.is-inverted.is-outlined{background-color:transparent;border-color:#fff;box-shadow:none;color:#fff}.button.is-danger.is-light{background-color:#feecf0;color:#cc0f35}.button.is-danger.is-light:hover,.button.is-danger.is-light.is-hovered{background-color:#fde0e6;border-color:transparent;color:#cc0f35}.button.is-danger.is-light:active,.button.is-danger.is-light.is-active{background-color:#fcd4dc;border-color:transparent;color:#cc0f35}.button.is-small{font-size:.75rem}.button.is-small:not(.is-rounded){border-radius:2px}.button.is-normal{font-size:1rem}.button.is-medium{font-size:1.25rem}.button.is-large{font-size:1.5rem}.button[disabled],fieldset[disabled] .button{background-color:#fff;border-color:#dbdbdb;box-shadow:none;opacity:.5}.button.is-fullwidth{display:flex;width:100%}.button.is-loading{color:transparent!important;pointer-events:none}.button.is-loading:after{position:absolute;left:calc(50% - .5em);top:calc(50% - .5em);position:absolute!important}.button.is-static{background-color:#f5f5f5;border-color:#dbdbdb;color:#7a7a7a;box-shadow:none;pointer-events:none}.button.is-rounded{border-radius:9999px;padding-left:1.25em;padding-right:1.25em}.buttons{align-items:center;display:flex;flex-wrap:wrap;justify-content:flex-start}.buttons .button{margin-bottom:.5rem}.buttons .button:not(:last-child):not(.is-fullwidth){margin-right:.5rem}.buttons:last-child{margin-bottom:-.5rem}.buttons:not(:last-child){margin-bottom:1rem}.buttons.are-small .button:not(.is-normal):not(.is-medium):not(.is-large){font-size:.75rem}.buttons.are-small .button:not(.is-normal):not(.is-medium):not(.is-large):not(.is-rounded){border-radius:2px}.buttons.are-medium .button:not(.is-small):not(.is-normal):not(.is-large){font-size:1.25rem}.buttons.are-large .button:not(.is-small):not(.is-normal):not(.is-medium){font-size:1.5rem}.buttons.has-addons .button:not(:first-child){border-bottom-left-radius:0;border-top-left-radius:0}.buttons.has-addons .button:not(:last-child){border-bottom-right-radius:0;border-top-right-radius:0;margin-right:-1px}.buttons.has-addons .button:last-child{margin-right:0}.buttons.has-addons .button:hover,.buttons.has-addons .button.is-hovered{z-index:2}.buttons.has-addons .button:focus,.buttons.has-addons .button.is-focused,.buttons.has-addons .button:active,.buttons.has-addons .button.is-active,.buttons.has-addons .button.is-selected{z-index:3}.buttons.has-addons .button:focus:hover,.buttons.has-addons .button.is-focused:hover,.buttons.has-addons .button:active:hover,.buttons.has-addons .button.is-active:hover,.buttons.has-addons .button.is-selected:hover{z-index:4}.buttons.has-addons .button.is-expanded{flex-grow:1;flex-shrink:1}.buttons.is-centered{justify-content:center}.buttons.is-centered:not(.has-addons) .button:not(.is-fullwidth){margin-left:.25rem;margin-right:.25rem}.buttons.is-right{justify-content:flex-end}.buttons.is-right:not(.has-addons) .button:not(.is-fullwidth){margin-left:.25rem;margin-right:.25rem}@media screen and (max-width: 768px){.button.is-responsive.is-small{font-size:.5625rem}.button.is-responsive,.button.is-responsive.is-normal{font-size:.65625rem}.button.is-responsive.is-medium{font-size:.75rem}.button.is-responsive.is-large{font-size:1rem}}@media screen and (min-width: 769px) and (max-width: 1023px){.button.is-responsive.is-small{font-size:.65625rem}.button.is-responsive,.button.is-responsive.is-normal{font-size:.75rem}.button.is-responsive.is-medium{font-size:1rem}.button.is-responsive.is-large{font-size:1.25rem}}.navbar{background-color:#fff;min-height:3.25rem;position:relative;z-index:30}.navbar.is-white{background-color:#fff;color:#0a0a0a}.navbar.is-white .navbar-brand>.navbar-item,.navbar.is-white .navbar-brand .navbar-link{color:#0a0a0a}.navbar.is-white .navbar-brand>a.navbar-item:focus,.navbar.is-white .navbar-brand>a.navbar-item:hover,.navbar.is-white .navbar-brand>a.navbar-item.is-active,.navbar.is-white .navbar-brand .navbar-link:focus,.navbar.is-white .navbar-brand .navbar-link:hover,.navbar.is-white .navbar-brand .navbar-link.is-active{background-color:#f2f2f2;color:#0a0a0a}.navbar.is-white .navbar-brand .navbar-link:after{border-color:#0a0a0a}.navbar.is-white .navbar-burger{color:#0a0a0a}@media screen and (min-width: 1024px){.navbar.is-white .navbar-start>.navbar-item,.navbar.is-white .navbar-start .navbar-link,.navbar.is-white .navbar-end>.navbar-item,.navbar.is-white .navbar-end .navbar-link{color:#0a0a0a}.navbar.is-white .navbar-start>a.navbar-item:focus,.navbar.is-white .navbar-start>a.navbar-item:hover,.navbar.is-white .navbar-start>a.navbar-item.is-active,.navbar.is-white .navbar-start .navbar-link:focus,.navbar.is-white .navbar-start .navbar-link:hover,.navbar.is-white .navbar-start .navbar-link.is-active,.navbar.is-white .navbar-end>a.navbar-item:focus,.navbar.is-white .navbar-end>a.navbar-item:hover,.navbar.is-white .navbar-end>a.navbar-item.is-active,.navbar.is-white .navbar-end .navbar-link:focus,.navbar.is-white .navbar-end .navbar-link:hover,.navbar.is-white .navbar-end .navbar-link.is-active{background-color:#f2f2f2;color:#0a0a0a}.navbar.is-white .navbar-start .navbar-link:after,.navbar.is-white .navbar-end .navbar-link:after{border-color:#0a0a0a}.navbar.is-white .navbar-item.has-dropdown:focus .navbar-link,.navbar.is-white .navbar-item.has-dropdown:hover .navbar-link,.navbar.is-white .navbar-item.has-dropdown.is-active .navbar-link{background-color:#f2f2f2;color:#0a0a0a}.navbar.is-white .navbar-dropdown a.navbar-item.is-active{background-color:#fff;color:#0a0a0a}}.navbar.is-black{background-color:#0a0a0a;color:#fff}.navbar.is-black .navbar-brand>.navbar-item,.navbar.is-black .navbar-brand .navbar-link{color:#fff}.navbar.is-black .navbar-brand>a.navbar-item:focus,.navbar.is-black .navbar-brand>a.navbar-item:hover,.navbar.is-black .navbar-brand>a.navbar-item.is-active,.navbar.is-black .navbar-brand .navbar-link:focus,.navbar.is-black .navbar-brand .navbar-link:hover,.navbar.is-black .navbar-brand .navbar-link.is-active{background-color:#000;color:#fff}.navbar.is-black .navbar-brand .navbar-link:after{border-color:#fff}.navbar.is-black .navbar-burger{color:#fff}@media screen and (min-width: 1024px){.navbar.is-black .navbar-start>.navbar-item,.navbar.is-black .navbar-start .navbar-link,.navbar.is-black .navbar-end>.navbar-item,.navbar.is-black .navbar-end .navbar-link{color:#fff}.navbar.is-black .navbar-start>a.navbar-item:focus,.navbar.is-black .navbar-start>a.navbar-item:hover,.navbar.is-black .navbar-start>a.navbar-item.is-active,.navbar.is-black .navbar-start .navbar-link:focus,.navbar.is-black .navbar-start .navbar-link:hover,.navbar.is-black .navbar-start .navbar-link.is-active,.navbar.is-black .navbar-end>a.navbar-item:focus,.navbar.is-black .navbar-end>a.navbar-item:hover,.navbar.is-black .navbar-end>a.navbar-item.is-active,.navbar.is-black .navbar-end .navbar-link:focus,.navbar.is-black .navbar-end .navbar-link:hover,.navbar.is-black .navbar-end .navbar-link.is-active{background-color:#000;color:#fff}.navbar.is-black .navbar-start .navbar-link:after,.navbar.is-black .navbar-end .navbar-link:after{border-color:#fff}.navbar.is-black .navbar-item.has-dropdown:focus .navbar-link,.navbar.is-black .navbar-item.has-dropdown:hover .navbar-link,.navbar.is-black .navbar-item.has-dropdown.is-active .navbar-link{background-color:#000;color:#fff}.navbar.is-black .navbar-dropdown a.navbar-item.is-active{background-color:#0a0a0a;color:#fff}}.navbar.is-light{background-color:#f5f5f5;color:#000000b3}.navbar.is-light .navbar-brand>.navbar-item,.navbar.is-light .navbar-brand .navbar-link{color:#000000b3}.navbar.is-light .navbar-brand>a.navbar-item:focus,.navbar.is-light .navbar-brand>a.navbar-item:hover,.navbar.is-light .navbar-brand>a.navbar-item.is-active,.navbar.is-light .navbar-brand .navbar-link:focus,.navbar.is-light .navbar-brand .navbar-link:hover,.navbar.is-light .navbar-brand .navbar-link.is-active{background-color:#e8e8e8;color:#000000b3}.navbar.is-light .navbar-brand .navbar-link:after{border-color:#000000b3}.navbar.is-light .navbar-burger{color:#000000b3}@media screen and (min-width: 1024px){.navbar.is-light .navbar-start>.navbar-item,.navbar.is-light .navbar-start .navbar-link,.navbar.is-light .navbar-end>.navbar-item,.navbar.is-light .navbar-end .navbar-link{color:#000000b3}.navbar.is-light .navbar-start>a.navbar-item:focus,.navbar.is-light .navbar-start>a.navbar-item:hover,.navbar.is-light .navbar-start>a.navbar-item.is-active,.navbar.is-light .navbar-start .navbar-link:focus,.navbar.is-light .navbar-start .navbar-link:hover,.navbar.is-light .navbar-start .navbar-link.is-active,.navbar.is-light .navbar-end>a.navbar-item:focus,.navbar.is-light .navbar-end>a.navbar-item:hover,.navbar.is-light .navbar-end>a.navbar-item.is-active,.navbar.is-light .navbar-end .navbar-link:focus,.navbar.is-light .navbar-end .navbar-link:hover,.navbar.is-light .navbar-end .navbar-link.is-active{background-color:#e8e8e8;color:#000000b3}.navbar.is-light .navbar-start .navbar-link:after,.navbar.is-light .navbar-end .navbar-link:after{border-color:#000000b3}.navbar.is-light .navbar-item.has-dropdown:focus .navbar-link,.navbar.is-light .navbar-item.has-dropdown:hover .navbar-link,.navbar.is-light .navbar-item.has-dropdown.is-active .navbar-link{background-color:#e8e8e8;color:#000000b3}.navbar.is-light .navbar-dropdown a.navbar-item.is-active{background-color:#f5f5f5;color:#000000b3}}.navbar.is-dark{background-color:#363636;color:#fff}.navbar.is-dark .navbar-brand>.navbar-item,.navbar.is-dark .navbar-brand .navbar-link{color:#fff}.navbar.is-dark .navbar-brand>a.navbar-item:focus,.navbar.is-dark .navbar-brand>a.navbar-item:hover,.navbar.is-dark .navbar-brand>a.navbar-item.is-active,.navbar.is-dark .navbar-brand .navbar-link:focus,.navbar.is-dark .navbar-brand .navbar-link:hover,.navbar.is-dark .navbar-brand .navbar-link.is-active{background-color:#292929;color:#fff}.navbar.is-dark .navbar-brand .navbar-link:after{border-color:#fff}.navbar.is-dark .navbar-burger{color:#fff}@media screen and (min-width: 1024px){.navbar.is-dark .navbar-start>.navbar-item,.navbar.is-dark .navbar-start .navbar-link,.navbar.is-dark .navbar-end>.navbar-item,.navbar.is-dark .navbar-end .navbar-link{color:#fff}.navbar.is-dark .navbar-start>a.navbar-item:focus,.navbar.is-dark .navbar-start>a.navbar-item:hover,.navbar.is-dark .navbar-start>a.navbar-item.is-active,.navbar.is-dark .navbar-start .navbar-link:focus,.navbar.is-dark .navbar-start .navbar-link:hover,.navbar.is-dark .navbar-start .navbar-link.is-active,.navbar.is-dark .navbar-end>a.navbar-item:focus,.navbar.is-dark .navbar-end>a.navbar-item:hover,.navbar.is-dark .navbar-end>a.navbar-item.is-active,.navbar.is-dark .navbar-end .navbar-link:focus,.navbar.is-dark .navbar-end .navbar-link:hover,.navbar.is-dark .navbar-end .navbar-link.is-active{background-color:#292929;color:#fff}.navbar.is-dark .navbar-start .navbar-link:after,.navbar.is-dark .navbar-end .navbar-link:after{border-color:#fff}.navbar.is-dark .navbar-item.has-dropdown:focus .navbar-link,.navbar.is-dark .navbar-item.has-dropdown:hover .navbar-link,.navbar.is-dark .navbar-item.has-dropdown.is-active .navbar-link{background-color:#292929;color:#fff}.navbar.is-dark .navbar-dropdown a.navbar-item.is-active{background-color:#363636;color:#fff}}.navbar.is-primary{background-color:#00d1b2;color:#fff}.navbar.is-primary .navbar-brand>.navbar-item,.navbar.is-primary .navbar-brand .navbar-link{color:#fff}.navbar.is-primary .navbar-brand>a.navbar-item:focus,.navbar.is-primary .navbar-brand>a.navbar-item:hover,.navbar.is-primary .navbar-brand>a.navbar-item.is-active,.navbar.is-primary .navbar-brand .navbar-link:focus,.navbar.is-primary .navbar-brand .navbar-link:hover,.navbar.is-primary .navbar-brand .navbar-link.is-active{background-color:#00b89c;color:#fff}.navbar.is-primary .navbar-brand .navbar-link:after{border-color:#fff}.navbar.is-primary .navbar-burger{color:#fff}@media screen and (min-width: 1024px){.navbar.is-primary .navbar-start>.navbar-item,.navbar.is-primary .navbar-start .navbar-link,.navbar.is-primary .navbar-end>.navbar-item,.navbar.is-primary .navbar-end .navbar-link{color:#fff}.navbar.is-primary .navbar-start>a.navbar-item:focus,.navbar.is-primary .navbar-start>a.navbar-item:hover,.navbar.is-primary .navbar-start>a.navbar-item.is-active,.navbar.is-primary .navbar-start .navbar-link:focus,.navbar.is-primary .navbar-start .navbar-link:hover,.navbar.is-primary .navbar-start .navbar-link.is-active,.navbar.is-primary .navbar-end>a.navbar-item:focus,.navbar.is-primary .navbar-end>a.navbar-item:hover,.navbar.is-primary .navbar-end>a.navbar-item.is-active,.navbar.is-primary .navbar-end .navbar-link:focus,.navbar.is-primary .navbar-end .navbar-link:hover,.navbar.is-primary .navbar-end .navbar-link.is-active{background-color:#00b89c;color:#fff}.navbar.is-primary .navbar-start .navbar-link:after,.navbar.is-primary .navbar-end .navbar-link:after{border-color:#fff}.navbar.is-primary .navbar-item.has-dropdown:focus .navbar-link,.navbar.is-primary .navbar-item.has-dropdown:hover .navbar-link,.navbar.is-primary .navbar-item.has-dropdown.is-active .navbar-link{background-color:#00b89c;color:#fff}.navbar.is-primary .navbar-dropdown a.navbar-item.is-active{background-color:#00d1b2;color:#fff}}.navbar.is-link{background-color:#485fc7;color:#fff}.navbar.is-link .navbar-brand>.navbar-item,.navbar.is-link .navbar-brand .navbar-link{color:#fff}.navbar.is-link .navbar-brand>a.navbar-item:focus,.navbar.is-link .navbar-brand>a.navbar-item:hover,.navbar.is-link .navbar-brand>a.navbar-item.is-active,.navbar.is-link .navbar-brand .navbar-link:focus,.navbar.is-link .navbar-brand .navbar-link:hover,.navbar.is-link .navbar-brand .navbar-link.is-active{background-color:#3a51bb;color:#fff}.navbar.is-link .navbar-brand .navbar-link:after{border-color:#fff}.navbar.is-link .navbar-burger{color:#fff}@media screen and (min-width: 1024px){.navbar.is-link .navbar-start>.navbar-item,.navbar.is-link .navbar-start .navbar-link,.navbar.is-link .navbar-end>.navbar-item,.navbar.is-link .navbar-end .navbar-link{color:#fff}.navbar.is-link .navbar-start>a.navbar-item:focus,.navbar.is-link .navbar-start>a.navbar-item:hover,.navbar.is-link .navbar-start>a.navbar-item.is-active,.navbar.is-link .navbar-start .navbar-link:focus,.navbar.is-link .navbar-start .navbar-link:hover,.navbar.is-link .navbar-start .navbar-link.is-active,.navbar.is-link .navbar-end>a.navbar-item:focus,.navbar.is-link .navbar-end>a.navbar-item:hover,.navbar.is-link .navbar-end>a.navbar-item.is-active,.navbar.is-link .navbar-end .navbar-link:focus,.navbar.is-link .navbar-end .navbar-link:hover,.navbar.is-link .navbar-end .navbar-link.is-active{background-color:#3a51bb;color:#fff}.navbar.is-link .navbar-start .navbar-link:after,.navbar.is-link .navbar-end .navbar-link:after{border-color:#fff}.navbar.is-link .navbar-item.has-dropdown:focus .navbar-link,.navbar.is-link .navbar-item.has-dropdown:hover .navbar-link,.navbar.is-link .navbar-item.has-dropdown.is-active .navbar-link{background-color:#3a51bb;color:#fff}.navbar.is-link .navbar-dropdown a.navbar-item.is-active{background-color:#485fc7;color:#fff}}.navbar.is-info{background-color:#3e8ed0;color:#fff}.navbar.is-info .navbar-brand>.navbar-item,.navbar.is-info .navbar-brand .navbar-link{color:#fff}.navbar.is-info .navbar-brand>a.navbar-item:focus,.navbar.is-info .navbar-brand>a.navbar-item:hover,.navbar.is-info .navbar-brand>a.navbar-item.is-active,.navbar.is-info .navbar-brand .navbar-link:focus,.navbar.is-info .navbar-brand .navbar-link:hover,.navbar.is-info .navbar-brand .navbar-link.is-active{background-color:#3082c5;color:#fff}.navbar.is-info .navbar-brand .navbar-link:after{border-color:#fff}.navbar.is-info .navbar-burger{color:#fff}@media screen and (min-width: 1024px){.navbar.is-info .navbar-start>.navbar-item,.navbar.is-info .navbar-start .navbar-link,.navbar.is-info .navbar-end>.navbar-item,.navbar.is-info .navbar-end .navbar-link{color:#fff}.navbar.is-info .navbar-start>a.navbar-item:focus,.navbar.is-info .navbar-start>a.navbar-item:hover,.navbar.is-info .navbar-start>a.navbar-item.is-active,.navbar.is-info .navbar-start .navbar-link:focus,.navbar.is-info .navbar-start .navbar-link:hover,.navbar.is-info .navbar-start .navbar-link.is-active,.navbar.is-info .navbar-end>a.navbar-item:focus,.navbar.is-info .navbar-end>a.navbar-item:hover,.navbar.is-info .navbar-end>a.navbar-item.is-active,.navbar.is-info .navbar-end .navbar-link:focus,.navbar.is-info .navbar-end .navbar-link:hover,.navbar.is-info .navbar-end .navbar-link.is-active{background-color:#3082c5;color:#fff}.navbar.is-info .navbar-start .navbar-link:after,.navbar.is-info .navbar-end .navbar-link:after{border-color:#fff}.navbar.is-info .navbar-item.has-dropdown:focus .navbar-link,.navbar.is-info .navbar-item.has-dropdown:hover .navbar-link,.navbar.is-info .navbar-item.has-dropdown.is-active .navbar-link{background-color:#3082c5;color:#fff}.navbar.is-info .navbar-dropdown a.navbar-item.is-active{background-color:#3e8ed0;color:#fff}}.navbar.is-success{background-color:#48c78e;color:#fff}.navbar.is-success .navbar-brand>.navbar-item,.navbar.is-success .navbar-brand .navbar-link{color:#fff}.navbar.is-success .navbar-brand>a.navbar-item:focus,.navbar.is-success .navbar-brand>a.navbar-item:hover,.navbar.is-success .navbar-brand>a.navbar-item.is-active,.navbar.is-success .navbar-brand .navbar-link:focus,.navbar.is-success .navbar-brand .navbar-link:hover,.navbar.is-success .navbar-brand .navbar-link.is-active{background-color:#3abb81;color:#fff}.navbar.is-success .navbar-brand .navbar-link:after{border-color:#fff}.navbar.is-success .navbar-burger{color:#fff}@media screen and (min-width: 1024px){.navbar.is-success .navbar-start>.navbar-item,.navbar.is-success .navbar-start .navbar-link,.navbar.is-success .navbar-end>.navbar-item,.navbar.is-success .navbar-end .navbar-link{color:#fff}.navbar.is-success .navbar-start>a.navbar-item:focus,.navbar.is-success .navbar-start>a.navbar-item:hover,.navbar.is-success .navbar-start>a.navbar-item.is-active,.navbar.is-success .navbar-start .navbar-link:focus,.navbar.is-success .navbar-start .navbar-link:hover,.navbar.is-success .navbar-start .navbar-link.is-active,.navbar.is-success .navbar-end>a.navbar-item:focus,.navbar.is-success .navbar-end>a.navbar-item:hover,.navbar.is-success .navbar-end>a.navbar-item.is-active,.navbar.is-success .navbar-end .navbar-link:focus,.navbar.is-success .navbar-end .navbar-link:hover,.navbar.is-success .navbar-end .navbar-link.is-active{background-color:#3abb81;color:#fff}.navbar.is-success .navbar-start .navbar-link:after,.navbar.is-success .navbar-end .navbar-link:after{border-color:#fff}.navbar.is-success .navbar-item.has-dropdown:focus .navbar-link,.navbar.is-success .navbar-item.has-dropdown:hover .navbar-link,.navbar.is-success .navbar-item.has-dropdown.is-active .navbar-link{background-color:#3abb81;color:#fff}.navbar.is-success .navbar-dropdown a.navbar-item.is-active{background-color:#48c78e;color:#fff}}.navbar.is-warning{background-color:#ffe08a;color:#000000b3}.navbar.is-warning .navbar-brand>.navbar-item,.navbar.is-warning .navbar-brand .navbar-link{color:#000000b3}.navbar.is-warning .navbar-brand>a.navbar-item:focus,.navbar.is-warning .navbar-brand>a.navbar-item:hover,.navbar.is-warning .navbar-brand>a.navbar-item.is-active,.navbar.is-warning .navbar-brand .navbar-link:focus,.navbar.is-warning .navbar-brand .navbar-link:hover,.navbar.is-warning .navbar-brand .navbar-link.is-active{background-color:#ffd970;color:#000000b3}.navbar.is-warning .navbar-brand .navbar-link:after{border-color:#000000b3}.navbar.is-warning .navbar-burger{color:#000000b3}@media screen and (min-width: 1024px){.navbar.is-warning .navbar-start>.navbar-item,.navbar.is-warning .navbar-start .navbar-link,.navbar.is-warning .navbar-end>.navbar-item,.navbar.is-warning .navbar-end .navbar-link{color:#000000b3}.navbar.is-warning .navbar-start>a.navbar-item:focus,.navbar.is-warning .navbar-start>a.navbar-item:hover,.navbar.is-warning .navbar-start>a.navbar-item.is-active,.navbar.is-warning .navbar-start .navbar-link:focus,.navbar.is-warning .navbar-start .navbar-link:hover,.navbar.is-warning .navbar-start .navbar-link.is-active,.navbar.is-warning .navbar-end>a.navbar-item:focus,.navbar.is-warning .navbar-end>a.navbar-item:hover,.navbar.is-warning .navbar-end>a.navbar-item.is-active,.navbar.is-warning .navbar-end .navbar-link:focus,.navbar.is-warning .navbar-end .navbar-link:hover,.navbar.is-warning .navbar-end .navbar-link.is-active{background-color:#ffd970;color:#000000b3}.navbar.is-warning .navbar-start .navbar-link:after,.navbar.is-warning .navbar-end .navbar-link:after{border-color:#000000b3}.navbar.is-warning .navbar-item.has-dropdown:focus .navbar-link,.navbar.is-warning .navbar-item.has-dropdown:hover .navbar-link,.navbar.is-warning .navbar-item.has-dropdown.is-active .navbar-link{background-color:#ffd970;color:#000000b3}.navbar.is-warning .navbar-dropdown a.navbar-item.is-active{background-color:#ffe08a;color:#000000b3}}.navbar.is-danger{background-color:#f14668;color:#fff}.navbar.is-danger .navbar-brand>.navbar-item,.navbar.is-danger .navbar-brand .navbar-link{color:#fff}.navbar.is-danger .navbar-brand>a.navbar-item:focus,.navbar.is-danger .navbar-brand>a.navbar-item:hover,.navbar.is-danger .navbar-brand>a.navbar-item.is-active,.navbar.is-danger .navbar-brand .navbar-link:focus,.navbar.is-danger .navbar-brand .navbar-link:hover,.navbar.is-danger .navbar-brand .navbar-link.is-active{background-color:#ef2e55;color:#fff}.navbar.is-danger .navbar-brand .navbar-link:after{border-color:#fff}.navbar.is-danger .navbar-burger{color:#fff}@media screen and (min-width: 1024px){.navbar.is-danger .navbar-start>.navbar-item,.navbar.is-danger .navbar-start .navbar-link,.navbar.is-danger .navbar-end>.navbar-item,.navbar.is-danger .navbar-end .navbar-link{color:#fff}.navbar.is-danger .navbar-start>a.navbar-item:focus,.navbar.is-danger .navbar-start>a.navbar-item:hover,.navbar.is-danger .navbar-start>a.navbar-item.is-active,.navbar.is-danger .navbar-start .navbar-link:focus,.navbar.is-danger .navbar-start .navbar-link:hover,.navbar.is-danger .navbar-start .navbar-link.is-active,.navbar.is-danger .navbar-end>a.navbar-item:focus,.navbar.is-danger .navbar-end>a.navbar-item:hover,.navbar.is-danger .navbar-end>a.navbar-item.is-active,.navbar.is-danger .navbar-end .navbar-link:focus,.navbar.is-danger .navbar-end .navbar-link:hover,.navbar.is-danger .navbar-end .navbar-link.is-active{background-color:#ef2e55;color:#fff}.navbar.is-danger .navbar-start .navbar-link:after,.navbar.is-danger .navbar-end .navbar-link:after{border-color:#fff}.navbar.is-danger .navbar-item.has-dropdown:focus .navbar-link,.navbar.is-danger .navbar-item.has-dropdown:hover .navbar-link,.navbar.is-danger .navbar-item.has-dropdown.is-active .navbar-link{background-color:#ef2e55;color:#fff}.navbar.is-danger .navbar-dropdown a.navbar-item.is-active{background-color:#f14668;color:#fff}}.navbar>.container{align-items:stretch;display:flex;min-height:3.25rem;width:100%}.navbar.has-shadow{box-shadow:0 2px #f5f5f5}.navbar.is-fixed-bottom,.navbar.is-fixed-top{left:0;position:fixed;right:0;z-index:30}.navbar.is-fixed-bottom{bottom:0}.navbar.is-fixed-bottom.has-shadow{box-shadow:0 -2px #f5f5f5}.navbar.is-fixed-top{top:0}html.has-navbar-fixed-top,body.has-navbar-fixed-top{padding-top:3.25rem}html.has-navbar-fixed-bottom,body.has-navbar-fixed-bottom{padding-bottom:3.25rem}.navbar-brand,.navbar-tabs{align-items:stretch;display:flex;flex-shrink:0;min-height:3.25rem}.navbar-brand a.navbar-item:focus,.navbar-brand a.navbar-item:hover{background-color:transparent}.navbar-tabs{-webkit-overflow-scrolling:touch;max-width:100vw;overflow-x:auto;overflow-y:hidden}.navbar-burger{color:#4a4a4a;-moz-appearance:none;-webkit-appearance:none;appearance:none;background:none;border:none;cursor:pointer;display:block;height:3.25rem;position:relative;width:3.25rem;margin-left:auto}.navbar-burger span{background-color:currentColor;display:block;height:1px;left:calc(50% - 8px);position:absolute;transform-origin:center;transition-duration:86ms;transition-property:background-color,opacity,transform;transition-timing-function:ease-out;width:16px}.navbar-burger span:nth-child(1){top:calc(50% - 6px)}.navbar-burger span:nth-child(2){top:calc(50% - 1px)}.navbar-burger span:nth-child(3){top:calc(50% + 4px)}.navbar-burger:hover{background-color:#0000000d}.navbar-burger.is-active span:nth-child(1){transform:translateY(5px) rotate(45deg)}.navbar-burger.is-active span:nth-child(2){opacity:0}.navbar-burger.is-active span:nth-child(3){transform:translateY(-5px) rotate(-45deg)}.navbar-menu{display:none}.navbar-item,.navbar-link{color:#4a4a4a;display:block;line-height:1.5;padding:.5rem .75rem;position:relative}.navbar-item .icon:only-child,.navbar-link .icon:only-child{margin-left:-.25rem;margin-right:-.25rem}a.navbar-item,.navbar-link{cursor:pointer}a.navbar-item:focus,a.navbar-item:focus-within,a.navbar-item:hover,a.navbar-item.is-active,.navbar-link:focus,.navbar-link:focus-within,.navbar-link:hover,.navbar-link.is-active{background-color:#fafafa;color:#485fc7}.navbar-item{flex-grow:0;flex-shrink:0}.navbar-item img{max-height:1.75rem}.navbar-item.has-dropdown{padding:0}.navbar-item.is-expanded{flex-grow:1;flex-shrink:1}.navbar-item.is-tab{border-bottom:1px solid transparent;min-height:3.25rem;padding-bottom:calc(.5rem - 1px)}.navbar-item.is-tab:focus,.navbar-item.is-tab:hover{background-color:transparent;border-bottom-color:#485fc7}.navbar-item.is-tab.is-active{background-color:transparent;border-bottom-color:#485fc7;border-bottom-style:solid;border-bottom-width:3px;color:#485fc7;padding-bottom:calc(.5rem - 3px)}.navbar-content{flex-grow:1;flex-shrink:1}.navbar-link:not(.is-arrowless){padding-right:2.5em}.navbar-link:not(.is-arrowless):after{border-color:#485fc7;margin-top:-.375em;right:1.125em}.navbar-dropdown{font-size:.875rem;padding-bottom:.5rem;padding-top:.5rem}.navbar-dropdown .navbar-item{padding-left:1.5rem;padding-right:1.5rem}.navbar-divider{background-color:#f5f5f5;border:none;display:none;height:2px;margin:.5rem 0}@media screen and (max-width: 1023px){.navbar>.container{display:block}.navbar-brand .navbar-item,.navbar-tabs .navbar-item{align-items:center;display:flex}.navbar-link:after{display:none}.navbar-menu{background-color:#fff;box-shadow:0 8px 16px #0a0a0a1a;padding:.5rem 0}.navbar-menu.is-active{display:block}.navbar.is-fixed-bottom-touch,.navbar.is-fixed-top-touch{left:0;position:fixed;right:0;z-index:30}.navbar.is-fixed-bottom-touch{bottom:0}.navbar.is-fixed-bottom-touch.has-shadow{box-shadow:0 -2px 3px #0a0a0a1a}.navbar.is-fixed-top-touch{top:0}.navbar.is-fixed-top .navbar-menu,.navbar.is-fixed-top-touch .navbar-menu{-webkit-overflow-scrolling:touch;max-height:calc(100vh - 3.25rem);overflow:auto}html.has-navbar-fixed-top-touch,body.has-navbar-fixed-top-touch{padding-top:3.25rem}html.has-navbar-fixed-bottom-touch,body.has-navbar-fixed-bottom-touch{padding-bottom:3.25rem}}@media screen and (min-width: 1024px){.navbar,.navbar-menu,.navbar-start,.navbar-end{align-items:stretch;display:flex}.navbar{min-height:3.25rem}.navbar.is-spaced{padding:1rem 2rem}.navbar.is-spaced .navbar-start,.navbar.is-spaced .navbar-end{align-items:center}.navbar.is-spaced a.navbar-item,.navbar.is-spaced .navbar-link{border-radius:4px}.navbar.is-transparent a.navbar-item:focus,.navbar.is-transparent a.navbar-item:hover,.navbar.is-transparent a.navbar-item.is-active,.navbar.is-transparent .navbar-link:focus,.navbar.is-transparent .navbar-link:hover,.navbar.is-transparent .navbar-link.is-active{background-color:transparent!important}.navbar.is-transparent .navbar-item.has-dropdown.is-active .navbar-link,.navbar.is-transparent .navbar-item.has-dropdown.is-hoverable:focus .navbar-link,.navbar.is-transparent .navbar-item.has-dropdown.is-hoverable:focus-within .navbar-link,.navbar.is-transparent .navbar-item.has-dropdown.is-hoverable:hover .navbar-link{background-color:transparent!important}.navbar.is-transparent .navbar-dropdown a.navbar-item:focus,.navbar.is-transparent .navbar-dropdown a.navbar-item:hover{background-color:#f5f5f5;color:#0a0a0a}.navbar.is-transparent .navbar-dropdown a.navbar-item.is-active{background-color:#f5f5f5;color:#485fc7}.navbar-burger{display:none}.navbar-item,.navbar-link{align-items:center;display:flex}.navbar-item.has-dropdown{align-items:stretch}.navbar-item.has-dropdown-up .navbar-link:after{transform:rotate(135deg) translate(.25em,-.25em)}.navbar-item.has-dropdown-up .navbar-dropdown{border-bottom:2px solid hsl(0,0%,86%);border-radius:6px 6px 0 0;border-top:none;bottom:100%;box-shadow:0 -8px 8px #0a0a0a1a;top:auto}.navbar-item.is-active .navbar-dropdown,.navbar-item.is-hoverable:focus .navbar-dropdown,.navbar-item.is-hoverable:focus-within .navbar-dropdown,.navbar-item.is-hoverable:hover .navbar-dropdown{display:block}.navbar.is-spaced .navbar-item.is-active .navbar-dropdown,.navbar-item.is-active .navbar-dropdown.is-boxed,.navbar.is-spaced .navbar-item.is-hoverable:focus .navbar-dropdown,.navbar-item.is-hoverable:focus .navbar-dropdown.is-boxed,.navbar.is-spaced .navbar-item.is-hoverable:focus-within .navbar-dropdown,.navbar-item.is-hoverable:focus-within .navbar-dropdown.is-boxed,.navbar.is-spaced .navbar-item.is-hoverable:hover .navbar-dropdown,.navbar-item.is-hoverable:hover .navbar-dropdown.is-boxed{opacity:1;pointer-events:auto;transform:translateY(0)}.navbar-menu{flex-grow:1;flex-shrink:0}.navbar-start{justify-content:flex-start;margin-right:auto}.navbar-end{justify-content:flex-end;margin-left:auto}.navbar-dropdown{background-color:#fff;border-bottom-left-radius:6px;border-bottom-right-radius:6px;border-top:2px solid hsl(0,0%,86%);box-shadow:0 8px 8px #0a0a0a1a;display:none;font-size:.875rem;left:0;min-width:100%;position:absolute;top:100%;z-index:20}.navbar-dropdown .navbar-item{padding:.375rem 1rem;white-space:nowrap}.navbar-dropdown a.navbar-item{padding-right:3rem}.navbar-dropdown a.navbar-item:focus,.navbar-dropdown a.navbar-item:hover{background-color:#f5f5f5;color:#0a0a0a}.navbar-dropdown a.navbar-item.is-active{background-color:#f5f5f5;color:#485fc7}.navbar.is-spaced .navbar-dropdown,.navbar-dropdown.is-boxed{border-radius:6px;border-top:none;box-shadow:0 8px 8px #0a0a0a1a,0 0 0 1px #0a0a0a1a;display:block;opacity:0;pointer-events:none;top:calc(100% - 4px);transform:translateY(-5px);transition-duration:86ms;transition-property:opacity,transform}.navbar-dropdown.is-right{left:auto;right:0}.navbar-divider{display:block}.navbar>.container .navbar-brand,.container>.navbar .navbar-brand{margin-left:-.75rem}.navbar>.container .navbar-menu,.container>.navbar .navbar-menu{margin-right:-.75rem}.navbar.is-fixed-bottom-desktop,.navbar.is-fixed-top-desktop{left:0;position:fixed;right:0;z-index:30}.navbar.is-fixed-bottom-desktop{bottom:0}.navbar.is-fixed-bottom-desktop.has-shadow{box-shadow:0 -2px 3px #0a0a0a1a}.navbar.is-fixed-top-desktop{top:0}html.has-navbar-fixed-top-desktop,body.has-navbar-fixed-top-desktop{padding-top:3.25rem}html.has-navbar-fixed-bottom-desktop,body.has-navbar-fixed-bottom-desktop{padding-bottom:3.25rem}html.has-spaced-navbar-fixed-top,body.has-spaced-navbar-fixed-top{padding-top:5.25rem}html.has-spaced-navbar-fixed-bottom,body.has-spaced-navbar-fixed-bottom{padding-bottom:5.25rem}a.navbar-item.is-active,.navbar-link.is-active{color:#0a0a0a}a.navbar-item.is-active:not(:focus):not(:hover),.navbar-link.is-active:not(:focus):not(:hover){background-color:transparent}.navbar-item.has-dropdown:focus .navbar-link,.navbar-item.has-dropdown:hover .navbar-link,.navbar-item.has-dropdown.is-active .navbar-link{background-color:#fafafa}}.hero.is-fullheight-with-navbar{min-height:calc(100vh - 3.25rem)}#player .button,#player a.button,#player button.button,.ax .button,.ax a.button,.ax button.button{font-size:1rem;display:inline-block;padding:.4em;border:none;justify-content:center;text-align:center;cursor:pointer;text-decoration:none;color:var(--button-fg);background-color:var(--button-bg)}#player .button.square,#player a.button.square,#player button.button.square,.ax .button.square,.ax a.button.square,.ax button.button.square{min-width:2.5em}#player .button.secondary,#player a.button.secondary,#player button.button.secondary,.ax .button.secondary,.ax a.button.secondary,.ax button.button.secondary{background-color:var(--button-sec-bg)}#player .button .label,#player .button label,#player a.button .label,#player a.button label,#player button.button .label,#player button.button label,.ax .button .label,.ax .button label,.ax a.button .label,.ax a.button label,.ax button.button .label,.ax button.button label{cursor:pointer}#player .button .icon,#player a.button .icon,#player button.button .icon,.ax .button .icon,.ax a.button .icon,.ax button.button .icon{vertical-align:middle}#player .button .icon:not(:only-child):first-child,#player a.button .icon:not(:only-child):first-child,#player button.button .icon:not(:only-child):first-child,.ax .button .icon:not(:only-child):first-child,.ax a.button .icon:not(:only-child):first-child,.ax button.button .icon:not(:only-child):first-child{margin:0 .6em 0 .2em}#player .button .icon:not(:only-child):last-child,#player a.button .icon:not(:only-child):last-child,#player button.button .icon:not(:only-child):last-child,.ax .button .icon:not(:only-child):last-child,.ax a.button .icon:not(:only-child):last-child,.ax button.button .icon:not(:only-child):last-child{margin:0 .6em 0 .2em}#player .button:hover,#player a.button:hover,#player button.button:hover,.ax .button:hover,.ax a.button:hover,.ax button.button:hover{color:var(--button-hv-fg);background-color:var(--button-hv-bg);opacity:1!important}#player .button.active:not(:hover),#player a.button.active:not(:hover),#player button.button.active:not(:hover),.ax .button.active:not(:hover),.ax a.button.active:not(:hover),.ax button.button.active:not(:hover){color:var(--button-active-fg);background-color:var(--button-active-bg)}#player .button:not([disabled]),#player .button:not(.disabled),#player a.button:not([disabled]),#player a.button:not(.disabled),#player button.button:not([disabled]),#player button.button:not(.disabled),.ax .button:not([disabled]),.ax .button:not(.disabled),.ax a.button:not([disabled]),.ax a.button:not(.disabled),.ax button.button:not([disabled]),.ax button.button:not(.disabled){cursor:pointer}#player .button[disabled],#player .button.disabled,#player a.button[disabled],#player a.button.disabled,#player button.button[disabled],#player button.button.disabled,.ax .button[disabled],.ax .button.disabled,.ax a.button[disabled],.ax a.button.disabled,.ax button.button[disabled],.ax button.button.disabled{background-color:var(--text-color-light);color:var(--secondary-color);border-color:var(--secondary-color-light)}#player .button .dropdown-trigger,#player a.button .dropdown-trigger,#player button.button .dropdown-trigger,.ax .button .dropdown-trigger,.ax a.button .dropdown-trigger,.ax button.button .dropdown-trigger{border-radius:1.5em}#player .button-group .button,#player .nav .button,.ax .button-group .button,.ax .nav .button{border-radius:0;background-color:transparent;border-top:0px;border-bottom:0px;height:100%}#player .button-group .button:not(:first-child),#player .nav .button:not(:first-child),.ax .button-group .button:not(:first-child),.ax .nav .button:not(:first-child){border-left:0px}#player .button-group .button:last-child,#player .nav .button:last-child,.ax .button-group .button:last-child,.ax .nav .button:last-child{border-right:0px}.admin .navbar.has-shadow,.admin .navbar.is-fixed-bottom.has-shadow{box-shadow:0 0 1em #0000001a}.admin a.navbar-item.is-active{border-bottom:1px grey solid}.admin .navbar+.container{margin-top:1em}.admin .navbar .navbar-dropdown{z-index:2000}.admin .navbar .navbar-split{margin:.2em 1em .2em 0;padding-right:1em;border-right:1px #ddd solid;display:inline-block}.admin .navbar form{margin:0;padding:0}.admin .navbar.toolbar{margin:1em 0;background-color:transparent}.admin .navbar.toolbar .title{padding-right:2em;margin-right:1em;border-right:1px #ddd solid;font-size:1rem;font-weight:100}.admin .navbar .navbar-dropdown{max-height:40rem;overflow-y:auto}.admin .navbar .navbar-dropdown input{z-index:10000;position:sticky;top:0}.admin .navbar .navbar-brand{padding-right:1em}.admin .navbar .navbar-brand img{margin:.3em .4em 0;max-height:3em}.admin .breadcrumbs{margin-bottom:1em}.admin .results>#result_list{width:100%;margin:1em 0}.admin ul.menu-list li{list-style-type:none}.admin .submit-row a.deletelink{height:35px} diff --git a/aircox/static/aircox/admin.html b/aircox/static/aircox/admin.html deleted file mode 100644 index 00a3408..0000000 --- a/aircox/static/aircox/admin.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - Vue App - - -

- - diff --git a/aircox/static/aircox/admin.js b/aircox/static/aircox/admin.js new file mode 100644 index 0000000..b059f23 --- /dev/null +++ b/aircox/static/aircox/admin.js @@ -0,0 +1,30 @@ +import{_ as Qe,g as Ng,a as Pl,b as Fl,M as Dt,s as Wl,S as $g,c as Dl,d as Ml,e as qg,f as Gg,A as Xi}from"./index.js";import{openBlock as R,createElementBlock as B,renderSlot as G,createElementVNode as b,toDisplayString as re,createCommentVNode as fe,resolveComponent as Fe,createBlock as jt,withCtx as Q,normalizeProps as Je,guardReactiveProps as Rt,normalizeClass as tn,Fragment as Xe,renderList as We,isReactive as Hg,toRefs as kg,resolveDynamicComponent as Kg,createTextVNode as nn,createVNode as wt,createSlots as On,mergeProps as en,withModifiers as zg,withDirectives as Ji,vShow as Ul,vModelText as Zg}from"vue";const Yg={emit:["fileChange","load","abort","error"],props:{url:{type:String},fieldName:{type:String,default:"file"},label:{type:String,default:"Select a file"},submitLabel:{type:String,default:"Upload"}},data(){return{STATE:{DEFAULT:0,UPLOADING:1},state:0,upload:{},file:null,fileUrl:null,total:0,loaded:0,request:null}},methods:{abort(){this.request&&this.request.abort()},onFileChange(){const[o]=this.$refs.uploadFile.files;o&&(this._setUploadFile(o),this.$emit("fileChange",{upload:this,file:this.file,fileUrl:this.fileUrl}))},submit(){const o=new XMLHttpRequest;o.open("POST",this.url),o.upload.addEventListener("progress",s=>this.onUploadProgress(s)),o.addEventListener("load",s=>this.onUploadDone(s,"load")),o.addEventListener("abort",s=>this.onUploadDone(s,"abort")),o.addEventListener("error",s=>this.onUploadDone(s,"error"));const d=new FormData(this.$refs.form);d.append("csrfmiddlewaretoken",Ng()),o.send(d),this._resetUpload(this.STATE.UPLOADING,!1,o)},onUploadProgress(o){this.loaded=o.loaded,this.total=o.total},onUploadDone(o,d){this.$emit(d,o),this._resetUpload(this.STATE.DEFAULT,!0)},_setUploadFile(o){this.file=o,this.fileURL=o&&URL.createObjectURL(o)},_resetUpload(o,d=!1,s=null){this.state=o,this.loaded=0,this.total=0,this.request=s,d&&(this.file=null)}}},Xg={ref:"list",class:"a-select-file-list"},Jg={key:0,ref:"form",class:"flex-column"},Qg={class:"field is-horizontal"},Vg={class:"label"},jg=["name"],ep={key:0,class:"flex-row align-right"},tp={key:1,class:"flex-column"},np={class:"flex-row"},rp=["max","value"],ip=b("span",{class:"icon small"},[b("i",{class:"fa fa-close"})],-1),sp=[ip];function up(o,d,s,O,A,w){return R(),B("div",Xg,[A.state==A.STATE.DEFAULT?(R(),B("form",Jg,[G(o.$slots,"form"),b("div",Qg,[b("label",Vg,re(s.label),1),b("input",{type:"file",ref:"uploadFile",name:s.fieldName,onChange:d[0]||(d[0]=(...E)=>w.onFileChange&&w.onFileChange(...E))},null,40,jg)]),s.submitLabel?(R(),B("div",ep,[b("button",{type:"button",class:"button small",onClick:d[1]||(d[1]=(...E)=>w.submit&&w.submit(...E))},re(s.submitLabel),1)])):fe("",!0)],512)):(R(),B("div",tp,[G(o.$slots,"preview",{fileUrl:A.fileUrl,file:A.file,loaded:A.loaded,total:A.total}),b("div",np,[b("progress",{max:A.total,value:A.loaded},null,8,rp),b("button",{type:"button",class:"button small square ml-2",onClick:d[2]||(d[2]=(...E)=>w.abort&&w.abort(...E))},sp)])]))],512)}const Bl=Qe(Yg,[["render",up]]),lp={emit:["select"],components:{AActionButton:Pl,AFileUpload:Bl,AModal:Fl},props:{title:{type:String},labels:Object,listClass:{type:String,default:""},listUrl:{type:String},deleteUrl:{type:String},uploadUrl:{type:String},uploadFieldName:{type:String,default:"file"},uploadLabel:{type:String,default:"Upload a file"}},data(){return{LIST:0,UPLOAD:1,panel:0,item:null,items:[],nextUrl:"",prevUrl:"",lastUrl:""}},methods:{open(){this.$refs.modal.open()},close(){this.$refs.modal.close()},showPanel(o){this.panel=o},load(o){return fetch(o||this.listUrl).then(d=>d.ok?d.json():Promise.reject(d)).then(d=>(this.lastUrl=o,this.nextUrl=d.next,this.prevUrl=d.previous,this.items=d.results,this.showPanel(this.LIST),this.$forceUpdate(),this.$refs.list.scroll(0,0),this.items))},select(o){this.item=o},selected(){this.$emit("select",this.item),this.close()},uploadDone(o=!1){o&&this.load().then(d=>{this.item=d[0]})}},mounted(){this.load()}},op=b("span",{class:"icon"},[b("i",{class:"fa fa-upload"})],-1),fp=b("span",{class:"icon"},[b("i",{class:"fa fa-list"})],-1),ap={key:1,class:"a-select-file"},cp={key:0},hp=["onClick"],dp={key:1},_p={key:0,class:"mr-3"};function gp(o,d,s,O,A,w){const E=Fe("a-file-upload"),y=Fe("a-action-button"),D=Fe("a-modal");return R(),jt(D,{ref:"modal",title:s.title},{bar:Q(()=>[A.panel==A.LIST?(R(),B("button",{key:0,type:"button",class:"button small mr-3",onClick:d[0]||(d[0]=H=>w.showPanel(A.UPLOAD))},[op,b("span",null,re(s.labels.upload),1)])):(R(),B("button",{key:1,type:"button",class:"button small mr-3",onClick:d[1]||(d[1]=H=>w.showPanel(A.LIST))},[fp,b("span",null,re(s.labels.list),1)]))]),default:Q(()=>[A.panel==A.UPLOAD?(R(),jt(E,{key:0,ref:"upload",url:s.uploadUrl,label:s.uploadLabel,"field-name":s.uploadFieldName,onLoad:w.uploadDone},{form:Q(H=>[G(o.$slots,"upload-form",Je(Rt(H)))]),preview:Q(H=>[G(o.$slots,"upload-preview",Je(Rt(H)))]),_:3},8,["url","label","field-name","onLoad"])):(R(),B("div",ap,[b("div",{ref:"list",class:tn(["a-select-file-list",s.listClass])},[A.prevUrl?(R(),B("div",cp,[b("a",{href:"#",onClick:d[2]||(d[2]=H=>w.load(A.prevUrl))},re(s.labels.show_previous),1)])):fe("",!0),(R(!0),B(Xe,null,We(A.items,H=>(R(),B("div",{key:H.id,class:tn(["file-preview",this.item&&H.id==this.item.id&&"active"]),onClick:z=>w.select(H)},[G(o.$slots,"default",{item:H,load:w.load,lastUrl:A.lastUrl}),s.deleteUrl?(R(),jt(y,{key:0,class:"has-text-danger small float-right",icon:"fa fa-trash",confirm:s.labels.confirm_delete,method:"DELETE",url:s.deleteUrl.replace("123",H.id),onDone:d[3]||(d[3]=z=>w.load(A.lastUrl))},null,8,["confirm","url"])):fe("",!0)],10,hp))),128)),A.nextUrl?(R(),B("div",dp,[b("a",{href:"#",onClick:d[4]||(d[4]=H=>w.load(A.nextUrl))},re(s.labels.show_next),1)])):fe("",!0)],2)]))]),footer:Q(()=>[G(o.$slots,"footer",{item:A.item},()=>[A.item?(R(),B("span",_p,re(A.item.name),1)):fe("",!0)]),A.panel==A.LIST?(R(),B("button",{key:0,type:"button",class:"button align-right",onClick:d[5]||(d[5]=(...H)=>w.selected&&w.selected(...H))},re(s.labels.select_file),1)):fe("",!0)]),_:3},8,["title"])}const Nl=Qe(lp,[["render",gp]]),pp=new RegExp(",\\s*|\\s+","g"),mp={data(){return{counts:{}}},methods:{update(){const o=this.$el.querySelectorAll('input[name="data"]:checked'),d={};for(var s of o)if(s.value)for(var O of s.value.split(pp))O.trim()&&(d[O.trim()]=(d[O.trim()]||0)+1);this.counts=d},onclick(){}},mounted(){console.log(this.counts),this.$refs.form.addEventListener("change",()=>this.update()),this.update()}},vp={ref:"form"};function wp(o,d,s,O,A,w){return R(),B("form",vp,[G(o.$slots,"default",{counts:A.counts})],512)}const bp=Qe(mp,[["render",wp]]);class xp extends Dt{get playlists(){return this.data?this.data.playlists:[]}get queues(){return this.data?this.data.queues:[]}get sources(){return[...this.queues,...this.playlists]}get source(){return this.sources.find(d=>d.id==this.data.source)}commit(d){this.data||(this.data={id:d.id,playlists:[],queues:[]}),d.playlists=Ap.fromList(d.playlists,{streamer:this}),d.queues=Sp.fromList(d.queues,{streamer:this}),super.commit(d)}}class yp extends Dt{static getId(d){return d.rid}}class $l extends Dt{constructor(d,{streamer:s=null,...O}={}){super(d,O),this.streamer=s,Wl(()=>this.tick(),1e3)}get isQueue(){return!1}get isPlaylist(){return!1}get isPlaying(){return this.data.status=="playing"}get isPaused(){return this.data.status=="paused"}get remainingString(){if(!this.remaining)return"00:00";const d=Math.floor(this.remaining%60),s=Math.floor(this.remaining/60);return String(s).padStart(2,"0")+":"+String(d).padStart(2,"0")}sync(){return this.action("sync/",{method:"POST"},!0)}skip(){return this.action("skip/",{method:"POST"},!0)}restart(){return this.action("restart/",{method:"POST"},!0)}seek(d){return this.action("seek/",{method:"POST",body:JSON.stringify({count:d})},!0)}tick(){if(!this.data.remaining||!this.isPlaying)return;const d=(Date.now()-this.commitDate)/1e3;this.remaining=this.data.remaining-d}commit(d){d.air_time&&(d.air_time=new Date(d.air_time)),this.commitDate=Date.now(),super.commit(d),this.remaining=d.remaining}}class Ap extends $l{get isPlaylist(){return!0}}class Sp extends $l{get isQueue(){return!0}get queue(){return this.data&&this.data.queue}commit(d){d.queue=yp.fromList(d.queue),super.commit(d)}push(d){return this.action("push/",{method:"POST",body:JSON.stringify({sound_id:parseInt(d)})},!0)}}const Cp={props:{apiUrl:String},data(){return{streamer:null,streamers:[],fetchInterval:null,Sound:$g}},computed:{sources(){var o=this.streamer?this.streamer.sources:[];return o.filter(d=>d.data)}},methods:{fetchStreamers(){xp.fetch(this.apiUrl,{many:!0}).then(o=>{this.streamers=o,this.streamer=o?o[0]:null})}},mounted(){this.fetchStreamers(),this.fetchInterval=Wl(()=>this.streamer&&this.streamer.fetch(),5e3)},unmounted(){this.fetchInterval!==null&&clearInterval(this.fetchInterval)}};function Lp(o,d,s,O,A,w){return R(),B("div",null,[G(o.$slots,"default",{streamer:A.streamer,streamers:A.streamers,Sound:A.Sound,sources:w.sources,fetchStreamers:w.fetchStreamers})])}const Tp=Qe(Cp,[["render",Lp]]);var Tn=typeof globalThis<"u"?globalThis:typeof window<"u"?window:typeof global<"u"?global:typeof self<"u"?self:{},Lr={exports:{}};/** + * @license + * Lodash + * Copyright OpenJS Foundation and other contributors + * Released under MIT license + * Based on Underscore.js 1.8.3 + * Copyright Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors + */Lr.exports;(function(o,d){(function(){var s,O="4.17.21",A=200,w="Unsupported core-js use. Try https://npms.io/search?q=ponyfill.",E="Expected a function",y="Invalid `variable` option passed into `_.template`",D="__lodash_hash_undefined__",H=500,z="__lodash_placeholder__",he=1,Rn=2,Ut=4,Pt=1,Dn=2,Le=1,bt=2,ji=4,He=8,Ft=16,ke=32,Wt=64,Ve=128,rn=256,Tr=512,Gl=30,Hl="...",kl=800,Kl=16,es=1,zl=2,Zl=3,xt=1/0,ft=9007199254740991,Yl=17976931348623157e292,Un=NaN,Ke=4294967295,Xl=Ke-1,Jl=Ke>>>1,Ql=[["ary",Ve],["bind",Le],["bindKey",bt],["curry",He],["curryRight",Ft],["flip",Tr],["partial",ke],["partialRight",Wt],["rearg",rn]],Mt="[object Arguments]",Pn="[object Array]",Vl="[object AsyncFunction]",sn="[object Boolean]",un="[object Date]",jl="[object DOMException]",Fn="[object Error]",Wn="[object Function]",ts="[object GeneratorFunction]",Me="[object Map]",ln="[object Number]",eo="[object Null]",je="[object Object]",ns="[object Promise]",to="[object Proxy]",on="[object RegExp]",Be="[object Set]",fn="[object String]",Mn="[object Symbol]",no="[object Undefined]",an="[object WeakMap]",ro="[object WeakSet]",cn="[object ArrayBuffer]",Bt="[object DataView]",Ir="[object Float32Array]",Er="[object Float64Array]",Or="[object Int8Array]",Rr="[object Int16Array]",Dr="[object Int32Array]",Ur="[object Uint8Array]",Pr="[object Uint8ClampedArray]",Fr="[object Uint16Array]",Wr="[object Uint32Array]",io=/\b__p \+= '';/g,so=/\b(__p \+=) '' \+/g,uo=/(__e\(.*?\)|\b__t\)) \+\n'';/g,rs=/&(?:amp|lt|gt|quot|#39);/g,is=/[&<>"']/g,lo=RegExp(rs.source),oo=RegExp(is.source),fo=/<%-([\s\S]+?)%>/g,ao=/<%([\s\S]+?)%>/g,ss=/<%=([\s\S]+?)%>/g,co=/\.|\[(?:[^[\]]*|(["'])(?:(?!\1)[^\\]|\\.)*?\1)\]/,ho=/^\w*$/,_o=/[^.[\]]+|\[(?:(-?\d+(?:\.\d+)?)|(["'])((?:(?!\2)[^\\]|\\.)*?)\2)\]|(?=(?:\.|\[\])(?:\.|\[\]|$))/g,Mr=/[\\^$.*+?()[\]{}|]/g,go=RegExp(Mr.source),Br=/^\s+/,po=/\s/,mo=/\{(?:\n\/\* \[wrapped with .+\] \*\/)?\n?/,vo=/\{\n\/\* \[wrapped with (.+)\] \*/,wo=/,? & /,bo=/[^\x00-\x2f\x3a-\x40\x5b-\x60\x7b-\x7f]+/g,xo=/[()=,{}\[\]\/\s]/,yo=/\\(\\)?/g,Ao=/\$\{([^\\}]*(?:\\.[^\\}]*)*)\}/g,us=/\w*$/,So=/^[-+]0x[0-9a-f]+$/i,Co=/^0b[01]+$/i,Lo=/^\[object .+?Constructor\]$/,To=/^0o[0-7]+$/i,Io=/^(?:0|[1-9]\d*)$/,Eo=/[\xc0-\xd6\xd8-\xf6\xf8-\xff\u0100-\u017f]/g,Bn=/($^)/,Oo=/['\n\r\u2028\u2029\\]/g,Nn="\\ud800-\\udfff",Ro="\\u0300-\\u036f",Do="\\ufe20-\\ufe2f",Uo="\\u20d0-\\u20ff",ls=Ro+Do+Uo,os="\\u2700-\\u27bf",fs="a-z\\xdf-\\xf6\\xf8-\\xff",Po="\\xac\\xb1\\xd7\\xf7",Fo="\\x00-\\x2f\\x3a-\\x40\\x5b-\\x60\\x7b-\\xbf",Wo="\\u2000-\\u206f",Mo=" \\t\\x0b\\f\\xa0\\ufeff\\n\\r\\u2028\\u2029\\u1680\\u180e\\u2000\\u2001\\u2002\\u2003\\u2004\\u2005\\u2006\\u2007\\u2008\\u2009\\u200a\\u202f\\u205f\\u3000",as="A-Z\\xc0-\\xd6\\xd8-\\xde",cs="\\ufe0e\\ufe0f",hs=Po+Fo+Wo+Mo,Nr="['’]",Bo="["+Nn+"]",ds="["+hs+"]",$n="["+ls+"]",_s="\\d+",No="["+os+"]",gs="["+fs+"]",ps="[^"+Nn+hs+_s+os+fs+as+"]",$r="\\ud83c[\\udffb-\\udfff]",$o="(?:"+$n+"|"+$r+")",ms="[^"+Nn+"]",qr="(?:\\ud83c[\\udde6-\\uddff]){2}",Gr="[\\ud800-\\udbff][\\udc00-\\udfff]",Nt="["+as+"]",vs="\\u200d",ws="(?:"+gs+"|"+ps+")",qo="(?:"+Nt+"|"+ps+")",bs="(?:"+Nr+"(?:d|ll|m|re|s|t|ve))?",xs="(?:"+Nr+"(?:D|LL|M|RE|S|T|VE))?",ys=$o+"?",As="["+cs+"]?",Go="(?:"+vs+"(?:"+[ms,qr,Gr].join("|")+")"+As+ys+")*",Ho="\\d*(?:1st|2nd|3rd|(?![123])\\dth)(?=\\b|[A-Z_])",ko="\\d*(?:1ST|2ND|3RD|(?![123])\\dTH)(?=\\b|[a-z_])",Ss=As+ys+Go,Ko="(?:"+[No,qr,Gr].join("|")+")"+Ss,zo="(?:"+[ms+$n+"?",$n,qr,Gr,Bo].join("|")+")",Zo=RegExp(Nr,"g"),Yo=RegExp($n,"g"),Hr=RegExp($r+"(?="+$r+")|"+zo+Ss,"g"),Xo=RegExp([Nt+"?"+gs+"+"+bs+"(?="+[ds,Nt,"$"].join("|")+")",qo+"+"+xs+"(?="+[ds,Nt+ws,"$"].join("|")+")",Nt+"?"+ws+"+"+bs,Nt+"+"+xs,ko,Ho,_s,Ko].join("|"),"g"),Jo=RegExp("["+vs+Nn+ls+cs+"]"),Qo=/[a-z][A-Z]|[A-Z]{2}[a-z]|[0-9][a-zA-Z]|[a-zA-Z][0-9]|[^a-zA-Z0-9 ]/,Vo=["Array","Buffer","DataView","Date","Error","Float32Array","Float64Array","Function","Int8Array","Int16Array","Int32Array","Map","Math","Object","Promise","RegExp","Set","String","Symbol","TypeError","Uint8Array","Uint8ClampedArray","Uint16Array","Uint32Array","WeakMap","_","clearTimeout","isFinite","parseInt","setTimeout"],jo=-1,J={};J[Ir]=J[Er]=J[Or]=J[Rr]=J[Dr]=J[Ur]=J[Pr]=J[Fr]=J[Wr]=!0,J[Mt]=J[Pn]=J[cn]=J[sn]=J[Bt]=J[un]=J[Fn]=J[Wn]=J[Me]=J[ln]=J[je]=J[on]=J[Be]=J[fn]=J[an]=!1;var X={};X[Mt]=X[Pn]=X[cn]=X[Bt]=X[sn]=X[un]=X[Ir]=X[Er]=X[Or]=X[Rr]=X[Dr]=X[Me]=X[ln]=X[je]=X[on]=X[Be]=X[fn]=X[Mn]=X[Ur]=X[Pr]=X[Fr]=X[Wr]=!0,X[Fn]=X[Wn]=X[an]=!1;var ef={À:"A",Á:"A",Â:"A",Ã:"A",Ä:"A",Å:"A",à:"a",á:"a",â:"a",ã:"a",ä:"a",å:"a",Ç:"C",ç:"c",Ð:"D",ð:"d",È:"E",É:"E",Ê:"E",Ë:"E",è:"e",é:"e",ê:"e",ë:"e",Ì:"I",Í:"I",Î:"I",Ï:"I",ì:"i",í:"i",î:"i",ï:"i",Ñ:"N",ñ:"n",Ò:"O",Ó:"O",Ô:"O",Õ:"O",Ö:"O",Ø:"O",ò:"o",ó:"o",ô:"o",õ:"o",ö:"o",ø:"o",Ù:"U",Ú:"U",Û:"U",Ü:"U",ù:"u",ú:"u",û:"u",ü:"u",Ý:"Y",ý:"y",ÿ:"y",Æ:"Ae",æ:"ae",Þ:"Th",þ:"th",ß:"ss",Ā:"A",Ă:"A",Ą:"A",ā:"a",ă:"a",ą:"a",Ć:"C",Ĉ:"C",Ċ:"C",Č:"C",ć:"c",ĉ:"c",ċ:"c",č:"c",Ď:"D",Đ:"D",ď:"d",đ:"d",Ē:"E",Ĕ:"E",Ė:"E",Ę:"E",Ě:"E",ē:"e",ĕ:"e",ė:"e",ę:"e",ě:"e",Ĝ:"G",Ğ:"G",Ġ:"G",Ģ:"G",ĝ:"g",ğ:"g",ġ:"g",ģ:"g",Ĥ:"H",Ħ:"H",ĥ:"h",ħ:"h",Ĩ:"I",Ī:"I",Ĭ:"I",Į:"I",İ:"I",ĩ:"i",ī:"i",ĭ:"i",į:"i",ı:"i",Ĵ:"J",ĵ:"j",Ķ:"K",ķ:"k",ĸ:"k",Ĺ:"L",Ļ:"L",Ľ:"L",Ŀ:"L",Ł:"L",ĺ:"l",ļ:"l",ľ:"l",ŀ:"l",ł:"l",Ń:"N",Ņ:"N",Ň:"N",Ŋ:"N",ń:"n",ņ:"n",ň:"n",ŋ:"n",Ō:"O",Ŏ:"O",Ő:"O",ō:"o",ŏ:"o",ő:"o",Ŕ:"R",Ŗ:"R",Ř:"R",ŕ:"r",ŗ:"r",ř:"r",Ś:"S",Ŝ:"S",Ş:"S",Š:"S",ś:"s",ŝ:"s",ş:"s",š:"s",Ţ:"T",Ť:"T",Ŧ:"T",ţ:"t",ť:"t",ŧ:"t",Ũ:"U",Ū:"U",Ŭ:"U",Ů:"U",Ű:"U",Ų:"U",ũ:"u",ū:"u",ŭ:"u",ů:"u",ű:"u",ų:"u",Ŵ:"W",ŵ:"w",Ŷ:"Y",ŷ:"y",Ÿ:"Y",Ź:"Z",Ż:"Z",Ž:"Z",ź:"z",ż:"z",ž:"z",IJ:"IJ",ij:"ij",Œ:"Oe",œ:"oe",ʼn:"'n",ſ:"s"},tf={"&":"&","<":"<",">":">",'"':""","'":"'"},nf={"&":"&","<":"<",">":">",""":'"',"'":"'"},rf={"\\":"\\","'":"'","\n":"n","\r":"r","\u2028":"u2028","\u2029":"u2029"},sf=parseFloat,uf=parseInt,Cs=typeof Tn=="object"&&Tn&&Tn.Object===Object&&Tn,lf=typeof self=="object"&&self&&self.Object===Object&&self,le=Cs||lf||Function("return this")(),kr=d&&!d.nodeType&&d,yt=kr&&!0&&o&&!o.nodeType&&o,Ls=yt&&yt.exports===kr,Kr=Ls&&Cs.process,Te=function(){try{var c=yt&&yt.require&&yt.require("util").types;return c||Kr&&Kr.binding&&Kr.binding("util")}catch{}}(),Ts=Te&&Te.isArrayBuffer,Is=Te&&Te.isDate,Es=Te&&Te.isMap,Os=Te&&Te.isRegExp,Rs=Te&&Te.isSet,Ds=Te&&Te.isTypedArray;function be(c,g,_){switch(_.length){case 0:return c.call(g);case 1:return c.call(g,_[0]);case 2:return c.call(g,_[0],_[1]);case 3:return c.call(g,_[0],_[1],_[2])}return c.apply(g,_)}function of(c,g,_,S){for(var U=-1,k=c==null?0:c.length;++U-1}function zr(c,g,_){for(var S=-1,U=c==null?0:c.length;++S-1;);return _}function $s(c,g){for(var _=c.length;_--&&$t(g,c[_],0)>-1;);return _}function mf(c,g){for(var _=c.length,S=0;_--;)c[_]===g&&++S;return S}var vf=Jr(ef),wf=Jr(tf);function bf(c){return"\\"+rf[c]}function xf(c,g){return c==null?s:c[g]}function qt(c){return Jo.test(c)}function yf(c){return Qo.test(c)}function Af(c){for(var g,_=[];!(g=c.next()).done;)_.push(g.value);return _}function ei(c){var g=-1,_=Array(c.size);return c.forEach(function(S,U){_[++g]=[U,S]}),_}function qs(c,g){return function(_){return c(g(_))}}function ht(c,g){for(var _=-1,S=c.length,U=0,k=[];++_-1}function aa(e,t){var n=this.__data__,r=ir(n,e);return r<0?(++this.size,n.push([e,t])):n[r][1]=t,this}et.prototype.clear=ua,et.prototype.delete=la,et.prototype.get=oa,et.prototype.has=fa,et.prototype.set=aa;function tt(e){var t=-1,n=e==null?0:e.length;for(this.clear();++t=t?e:t)),e}function Re(e,t,n,r,i,l){var f,a=t&he,h=t&Rn,p=t&Ut;if(n&&(f=i?n(e,r,i,l):n(e)),f!==s)return f;if(!j(e))return e;var m=P(e);if(m){if(f=_c(e),!a)return pe(e,f)}else{var v=ce(e),x=v==Wn||v==ts;if(vt(e))return Au(e,a);if(v==je||v==Mt||x&&!i){if(f=h||x?{}:Gu(e),!a)return h?rc(e,La(f,e)):nc(e,js(f,e))}else{if(!X[v])return i?e:{};f=gc(e,v,a)}}l||(l=new $e);var C=l.get(e);if(C)return C;l.set(e,f),ml(e)?e.forEach(function(I){f.add(Re(I,t,n,I,e,l))}):gl(e)&&e.forEach(function(I,N){f.set(N,Re(I,t,n,N,e,l))});var T=p?h?Li:Ci:h?ve:ue,W=m?s:T(e);return Ie(W||e,function(I,N){W&&(N=I,I=e[N]),vn(f,N,Re(I,t,n,N,e,l))}),f}function Ta(e){var t=ue(e);return function(n){return eu(n,e,t)}}function eu(e,t,n){var r=n.length;if(e==null)return!r;for(e=Y(e);r--;){var i=n[r],l=t[i],f=e[i];if(f===s&&!(i in e)||!l(f))return!1}return!0}function tu(e,t,n){if(typeof e!="function")throw new Ee(E);return Cn(function(){e.apply(s,n)},t)}function wn(e,t,n,r){var i=-1,l=qn,f=!0,a=e.length,h=[],p=t.length;if(!a)return h;n&&(t=V(t,xe(n))),r?(l=zr,f=!1):t.length>=A&&(l=hn,f=!1,t=new Ct(t));e:for(;++ii?0:i+n),r=r===s||r>i?i:F(r),r<0&&(r+=i),r=n>r?0:wl(r);n0&&n(a)?t>1?oe(a,t-1,n,r,i):ct(i,a):r||(i[i.length]=a)}return i}var li=Eu(),iu=Eu(!0);function ze(e,t){return e&&li(e,t,ue)}function oi(e,t){return e&&iu(e,t,ue)}function ur(e,t){return at(t,function(n){return ut(e[n])})}function Tt(e,t){t=pt(t,e);for(var n=0,r=t.length;e!=null&&nt}function Oa(e,t){return e!=null&&Z.call(e,t)}function Ra(e,t){return e!=null&&t in Y(e)}function Da(e,t,n){return e>=ae(t,n)&&e=120&&m.length>=120)?new Ct(f&&m):s}m=e[0];var v=-1,x=a[0];e:for(;++v-1;)a!==e&&Qn.call(a,h,1),Qn.call(e,h,1);return e}function gu(e,t){for(var n=e?t.length:0,r=n-1;n--;){var i=t[n];if(n==r||i!==l){var l=i;st(i)?Qn.call(e,i,1):vi(e,i)}}return e}function gi(e,t){return e+er(Xs()*(t-e+1))}function Ka(e,t,n,r){for(var i=-1,l=se(jn((t-e)/(n||1)),0),f=_(l);l--;)f[r?l:++i]=e,e+=n;return f}function pi(e,t){var n="";if(!e||t<1||t>ft)return n;do t%2&&(n+=e),t=er(t/2),t&&(e+=e);while(t);return n}function M(e,t){return Ui(Ku(e,t,we),e+"")}function za(e){return Vs(Vt(e))}function Za(e,t){var n=Vt(e);return mr(n,Lt(t,0,n.length))}function yn(e,t,n,r){if(!j(e))return e;t=pt(t,e);for(var i=-1,l=t.length,f=l-1,a=e;a!=null&&++ii?0:i+t),n=n>i?i:n,n<0&&(n+=i),i=t>n?0:n-t>>>0,t>>>=0;for(var l=_(i);++r>>1,f=e[l];f!==null&&!Ae(f)&&(n?f<=t:f=A){var p=t?null:lc(e);if(p)return Hn(p);f=!1,i=hn,h=new Ct}else h=t?[]:a;e:for(;++r=r?e:De(e,t,n)}var yu=Bf||function(e){return le.clearTimeout(e)};function Au(e,t){if(t)return e.slice();var n=e.length,r=ks?ks(n):new e.constructor(n);return e.copy(r),r}function yi(e){var t=new e.constructor(e.byteLength);return new Xn(t).set(new Xn(e)),t}function Va(e,t){var n=t?yi(e.buffer):e.buffer;return new e.constructor(n,e.byteOffset,e.byteLength)}function ja(e){var t=new e.constructor(e.source,us.exec(e));return t.lastIndex=e.lastIndex,t}function ec(e){return mn?Y(mn.call(e)):{}}function Su(e,t){var n=t?yi(e.buffer):e.buffer;return new e.constructor(n,e.byteOffset,e.length)}function Cu(e,t){if(e!==t){var n=e!==s,r=e===null,i=e===e,l=Ae(e),f=t!==s,a=t===null,h=t===t,p=Ae(t);if(!a&&!p&&!l&&e>t||l&&f&&h&&!a&&!p||r&&f&&h||!n&&h||!i)return 1;if(!r&&!l&&!p&&e=a)return h;var p=n[r];return h*(p=="desc"?-1:1)}}return e.index-t.index}function Lu(e,t,n,r){for(var i=-1,l=e.length,f=n.length,a=-1,h=t.length,p=se(l-f,0),m=_(h+p),v=!r;++a1?n[i-1]:s,f=i>2?n[2]:s;for(l=e.length>3&&typeof l=="function"?(i--,l):s,f&&_e(n[0],n[1],f)&&(l=i<3?s:l,i=1),t=Y(t);++r-1?i[l?t[f]:f]:s}}function Du(e){return it(function(t){var n=t.length,r=n,i=Oe.prototype.thru;for(e&&t.reverse();r--;){var l=t[r];if(typeof l!="function")throw new Ee(E);if(i&&!f&&gr(l)=="wrapper")var f=new Oe([],!0)}for(r=f?r:n;++r1&&q.reverse(),m&&ha))return!1;var p=l.get(e),m=l.get(t);if(p&&m)return p==t&&m==e;var v=-1,x=!0,C=n&Dn?new Ct:s;for(l.set(e,t),l.set(t,e);++v1?"& ":"")+t[r],t=t.join(n>2?", ":" "),e.replace(mo,`{ +/* [wrapped with `+t+`] */ +`)}function mc(e){return P(e)||Ot(e)||!!(Zs&&e&&e[Zs])}function st(e,t){var n=typeof e;return t=t??ft,!!t&&(n=="number"||n!="symbol"&&Io.test(e))&&e>-1&&e%1==0&&e0){if(++t>=kl)return arguments[0]}else t=0;return e.apply(s,arguments)}}function mr(e,t){var n=-1,r=e.length,i=r-1;for(t=t===s?r:t;++n1?e[t-1]:s;return n=typeof n=="function"?(e.pop(),n):s,rl(e,n)});function il(e){var t=u(e);return t.__chain__=!0,t}function Ih(e,t){return t(e),e}function vr(e,t){return t(e)}var Eh=it(function(e){var t=e.length,n=t?e[0]:0,r=this.__wrapped__,i=function(l){return ui(l,e)};return t>1||this.__actions__.length||!(r instanceof $)||!st(n)?this.thru(i):(r=r.slice(n,+n+(t?1:0)),r.__actions__.push({func:vr,args:[i],thisArg:s}),new Oe(r,this.__chain__).thru(function(l){return t&&!l.length&&l.push(s),l}))});function Oh(){return il(this)}function Rh(){return new Oe(this.value(),this.__chain__)}function Dh(){this.__values__===s&&(this.__values__=vl(this.value()));var e=this.__index__>=this.__values__.length,t=e?s:this.__values__[this.__index__++];return{done:e,value:t}}function Uh(){return this}function Ph(e){for(var t,n=this;n instanceof rr;){var r=Qu(n);r.__index__=0,r.__values__=s,t?i.__wrapped__=r:t=r;var i=r;n=n.__wrapped__}return i.__wrapped__=e,t}function Fh(){var e=this.__wrapped__;if(e instanceof $){var t=e;return this.__actions__.length&&(t=new $(this)),t=t.reverse(),t.__actions__.push({func:vr,args:[Pi],thisArg:s}),new Oe(t,this.__chain__)}return this.thru(Pi)}function Wh(){return bu(this.__wrapped__,this.__actions__)}var Mh=ar(function(e,t,n){Z.call(e,n)?++e[n]:nt(e,n,1)});function Bh(e,t,n){var r=P(e)?Us:Ia;return n&&_e(e,t,n)&&(t=s),r(e,L(t,3))}function Nh(e,t){var n=P(e)?at:ru;return n(e,L(t,3))}var $h=Ru(Vu),qh=Ru(ju);function Gh(e,t){return oe(wr(e,t),1)}function Hh(e,t){return oe(wr(e,t),xt)}function kh(e,t,n){return n=n===s?1:F(n),oe(wr(e,t),n)}function sl(e,t){var n=P(e)?Ie:_t;return n(e,L(t,3))}function ul(e,t){var n=P(e)?ff:nu;return n(e,L(t,3))}var Kh=ar(function(e,t,n){Z.call(e,n)?e[n].push(t):nt(e,n,[t])});function zh(e,t,n,r){e=me(e)?e:Vt(e),n=n&&!r?F(n):0;var i=e.length;return n<0&&(n=se(i+n,0)),Sr(e)?n<=i&&e.indexOf(t,n)>-1:!!i&&$t(e,t,n)>-1}var Zh=M(function(e,t,n){var r=-1,i=typeof t=="function",l=me(e)?_(e.length):[];return _t(e,function(f){l[++r]=i?be(t,f,n):bn(f,t,n)}),l}),Yh=ar(function(e,t,n){nt(e,n,t)});function wr(e,t){var n=P(e)?V:fu;return n(e,L(t,3))}function Xh(e,t,n,r){return e==null?[]:(P(t)||(t=t==null?[]:[t]),n=r?s:n,P(n)||(n=n==null?[]:[n]),du(e,t,n))}var Jh=ar(function(e,t,n){e[n?0:1].push(t)},function(){return[[],[]]});function Qh(e,t,n){var r=P(e)?Zr:Ms,i=arguments.length<3;return r(e,L(t,4),n,i,_t)}function Vh(e,t,n){var r=P(e)?af:Ms,i=arguments.length<3;return r(e,L(t,4),n,i,nu)}function jh(e,t){var n=P(e)?at:ru;return n(e,yr(L(t,3)))}function ed(e){var t=P(e)?Vs:za;return t(e)}function td(e,t,n){(n?_e(e,t,n):t===s)?t=1:t=F(t);var r=P(e)?Aa:Za;return r(e,t)}function nd(e){var t=P(e)?Sa:Xa;return t(e)}function rd(e){if(e==null)return 0;if(me(e))return Sr(e)?Gt(e):e.length;var t=ce(e);return t==Me||t==Be?e.size:hi(e).length}function id(e,t,n){var r=P(e)?Yr:Ja;return n&&_e(e,t,n)&&(t=s),r(e,L(t,3))}var sd=M(function(e,t){if(e==null)return[];var n=t.length;return n>1&&_e(e,t[0],t[1])?t=[]:n>2&&_e(t[0],t[1],t[2])&&(t=[t[0]]),du(e,oe(t,1),[])}),br=Nf||function(){return le.Date.now()};function ud(e,t){if(typeof t!="function")throw new Ee(E);return e=F(e),function(){if(--e<1)return t.apply(this,arguments)}}function ll(e,t,n){return t=n?s:t,t=e&&t==null?e.length:t,rt(e,Ve,s,s,s,s,t)}function ol(e,t){var n;if(typeof t!="function")throw new Ee(E);return e=F(e),function(){return--e>0&&(n=t.apply(this,arguments)),e<=1&&(t=s),n}}var Wi=M(function(e,t,n){var r=Le;if(n.length){var i=ht(n,Jt(Wi));r|=ke}return rt(e,r,t,n,i)}),fl=M(function(e,t,n){var r=Le|bt;if(n.length){var i=ht(n,Jt(fl));r|=ke}return rt(t,r,e,n,i)});function al(e,t,n){t=n?s:t;var r=rt(e,He,s,s,s,s,s,t);return r.placeholder=al.placeholder,r}function cl(e,t,n){t=n?s:t;var r=rt(e,Ft,s,s,s,s,s,t);return r.placeholder=cl.placeholder,r}function hl(e,t,n){var r,i,l,f,a,h,p=0,m=!1,v=!1,x=!0;if(typeof e!="function")throw new Ee(E);t=Pe(t)||0,j(n)&&(m=!!n.leading,v="maxWait"in n,l=v?se(Pe(n.maxWait)||0,t):l,x="trailing"in n?!!n.trailing:x);function C(ne){var Ge=r,ot=i;return r=i=s,p=ne,f=e.apply(ot,Ge),f}function T(ne){return p=ne,a=Cn(N,t),m?C(ne):f}function W(ne){var Ge=ne-h,ot=ne-p,Rl=t-Ge;return v?ae(Rl,l-ot):Rl}function I(ne){var Ge=ne-h,ot=ne-p;return h===s||Ge>=t||Ge<0||v&&ot>=l}function N(){var ne=br();if(I(ne))return q(ne);a=Cn(N,W(ne))}function q(ne){return a=s,x&&r?C(ne):(r=i=s,f)}function Se(){a!==s&&yu(a),p=0,r=h=i=a=s}function ge(){return a===s?f:q(br())}function Ce(){var ne=br(),Ge=I(ne);if(r=arguments,i=this,h=ne,Ge){if(a===s)return T(h);if(v)return yu(a),a=Cn(N,t),C(h)}return a===s&&(a=Cn(N,t)),f}return Ce.cancel=Se,Ce.flush=ge,Ce}var ld=M(function(e,t){return tu(e,1,t)}),od=M(function(e,t,n){return tu(e,Pe(t)||0,n)});function fd(e){return rt(e,Tr)}function xr(e,t){if(typeof e!="function"||t!=null&&typeof t!="function")throw new Ee(E);var n=function(){var r=arguments,i=t?t.apply(this,r):r[0],l=n.cache;if(l.has(i))return l.get(i);var f=e.apply(this,r);return n.cache=l.set(i,f)||l,f};return n.cache=new(xr.Cache||tt),n}xr.Cache=tt;function yr(e){if(typeof e!="function")throw new Ee(E);return function(){var t=arguments;switch(t.length){case 0:return!e.call(this);case 1:return!e.call(this,t[0]);case 2:return!e.call(this,t[0],t[1]);case 3:return!e.call(this,t[0],t[1],t[2])}return!e.apply(this,t)}}function ad(e){return ol(2,e)}var cd=Qa(function(e,t){t=t.length==1&&P(t[0])?V(t[0],xe(L())):V(oe(t,1),xe(L()));var n=t.length;return M(function(r){for(var i=-1,l=ae(r.length,n);++i=t}),Ot=uu(function(){return arguments}())?uu:function(e){return ee(e)&&Z.call(e,"callee")&&!zs.call(e,"callee")},P=_.isArray,Ld=Ts?xe(Ts):Pa;function me(e){return e!=null&&Ar(e.length)&&!ut(e)}function te(e){return ee(e)&&me(e)}function Td(e){return e===!0||e===!1||ee(e)&&de(e)==sn}var vt=qf||Yi,Id=Is?xe(Is):Fa;function Ed(e){return ee(e)&&e.nodeType===1&&!Ln(e)}function Od(e){if(e==null)return!0;if(me(e)&&(P(e)||typeof e=="string"||typeof e.splice=="function"||vt(e)||Qt(e)||Ot(e)))return!e.length;var t=ce(e);if(t==Me||t==Be)return!e.size;if(Sn(e))return!hi(e).length;for(var n in e)if(Z.call(e,n))return!1;return!0}function Rd(e,t){return xn(e,t)}function Dd(e,t,n){n=typeof n=="function"?n:s;var r=n?n(e,t):s;return r===s?xn(e,t,s,n):!!r}function Bi(e){if(!ee(e))return!1;var t=de(e);return t==Fn||t==jl||typeof e.message=="string"&&typeof e.name=="string"&&!Ln(e)}function Ud(e){return typeof e=="number"&&Ys(e)}function ut(e){if(!j(e))return!1;var t=de(e);return t==Wn||t==ts||t==Vl||t==to}function _l(e){return typeof e=="number"&&e==F(e)}function Ar(e){return typeof e=="number"&&e>-1&&e%1==0&&e<=ft}function j(e){var t=typeof e;return e!=null&&(t=="object"||t=="function")}function ee(e){return e!=null&&typeof e=="object"}var gl=Es?xe(Es):Ma;function Pd(e,t){return e===t||ci(e,t,Ii(t))}function Fd(e,t,n){return n=typeof n=="function"?n:s,ci(e,t,Ii(t),n)}function Wd(e){return pl(e)&&e!=+e}function Md(e){if(bc(e))throw new U(w);return lu(e)}function Bd(e){return e===null}function Nd(e){return e==null}function pl(e){return typeof e=="number"||ee(e)&&de(e)==ln}function Ln(e){if(!ee(e)||de(e)!=je)return!1;var t=Jn(e);if(t===null)return!0;var n=Z.call(t,"constructor")&&t.constructor;return typeof n=="function"&&n instanceof n&&zn.call(n)==Ff}var Ni=Os?xe(Os):Ba;function $d(e){return _l(e)&&e>=-ft&&e<=ft}var ml=Rs?xe(Rs):Na;function Sr(e){return typeof e=="string"||!P(e)&&ee(e)&&de(e)==fn}function Ae(e){return typeof e=="symbol"||ee(e)&&de(e)==Mn}var Qt=Ds?xe(Ds):$a;function qd(e){return e===s}function Gd(e){return ee(e)&&ce(e)==an}function Hd(e){return ee(e)&&de(e)==ro}var kd=_r(di),Kd=_r(function(e,t){return e<=t});function vl(e){if(!e)return[];if(me(e))return Sr(e)?Ne(e):pe(e);if(dn&&e[dn])return Af(e[dn]());var t=ce(e),n=t==Me?ei:t==Be?Hn:Vt;return n(e)}function lt(e){if(!e)return e===0?e:0;if(e=Pe(e),e===xt||e===-xt){var t=e<0?-1:1;return t*Yl}return e===e?e:0}function F(e){var t=lt(e),n=t%1;return t===t?n?t-n:t:0}function wl(e){return e?Lt(F(e),0,Ke):0}function Pe(e){if(typeof e=="number")return e;if(Ae(e))return Un;if(j(e)){var t=typeof e.valueOf=="function"?e.valueOf():e;e=j(t)?t+"":t}if(typeof e!="string")return e===0?e:+e;e=Bs(e);var n=Co.test(e);return n||To.test(e)?uf(e.slice(2),n?2:8):So.test(e)?Un:+e}function bl(e){return Ze(e,ve(e))}function zd(e){return e?Lt(F(e),-ft,ft):e===0?e:0}function K(e){return e==null?"":ye(e)}var Zd=Yt(function(e,t){if(Sn(t)||me(t)){Ze(t,ue(t),e);return}for(var n in t)Z.call(t,n)&&vn(e,n,t[n])}),xl=Yt(function(e,t){Ze(t,ve(t),e)}),Cr=Yt(function(e,t,n,r){Ze(t,ve(t),e,r)}),Yd=Yt(function(e,t,n,r){Ze(t,ue(t),e,r)}),Xd=it(ui);function Jd(e,t){var n=Zt(e);return t==null?n:js(n,t)}var Qd=M(function(e,t){e=Y(e);var n=-1,r=t.length,i=r>2?t[2]:s;for(i&&_e(t[0],t[1],i)&&(r=1);++n1),l}),Ze(e,Li(e),n),r&&(n=Re(n,he|Rn|Ut,oc));for(var i=t.length;i--;)vi(n,t[i]);return n});function g_(e,t){return Al(e,yr(L(t)))}var p_=it(function(e,t){return e==null?{}:Ha(e,t)});function Al(e,t){if(e==null)return{};var n=V(Li(e),function(r){return[r]});return t=L(t),_u(e,n,function(r,i){return t(r,i[0])})}function m_(e,t,n){t=pt(t,e);var r=-1,i=t.length;for(i||(i=1,e=s);++rt){var r=e;e=t,t=r}if(n||e%1||t%1){var i=Xs();return ae(e+i*(t-e+sf("1e-"+((i+"").length-1))),t)}return gi(e,t)}var I_=Xt(function(e,t,n){return t=t.toLowerCase(),e+(n?Ll(t):t)});function Ll(e){return Gi(K(e).toLowerCase())}function Tl(e){return e=K(e),e&&e.replace(Eo,vf).replace(Yo,"")}function E_(e,t,n){e=K(e),t=ye(t);var r=e.length;n=n===s?r:Lt(F(n),0,r);var i=n;return n-=t.length,n>=0&&e.slice(n,i)==t}function O_(e){return e=K(e),e&&oo.test(e)?e.replace(is,wf):e}function R_(e){return e=K(e),e&&go.test(e)?e.replace(Mr,"\\$&"):e}var D_=Xt(function(e,t,n){return e+(n?"-":"")+t.toLowerCase()}),U_=Xt(function(e,t,n){return e+(n?" ":"")+t.toLowerCase()}),P_=Ou("toLowerCase");function F_(e,t,n){e=K(e),t=F(t);var r=t?Gt(e):0;if(!t||r>=t)return e;var i=(t-r)/2;return dr(er(i),n)+e+dr(jn(i),n)}function W_(e,t,n){e=K(e),t=F(t);var r=t?Gt(e):0;return t&&r>>0,n?(e=K(e),e&&(typeof t=="string"||t!=null&&!Ni(t))&&(t=ye(t),!t&&qt(e))?mt(Ne(e),0,n):e.split(t,n)):[]}var H_=Xt(function(e,t,n){return e+(n?" ":"")+Gi(t)});function k_(e,t,n){return e=K(e),n=n==null?0:Lt(F(n),0,e.length),t=ye(t),e.slice(n,n+t.length)==t}function K_(e,t,n){var r=u.templateSettings;n&&_e(e,t,n)&&(t=s),e=K(e),t=Cr({},t,r,Mu);var i=Cr({},t.imports,r.imports,Mu),l=ue(i),f=jr(i,l),a,h,p=0,m=t.interpolate||Bn,v="__p += '",x=ti((t.escape||Bn).source+"|"+m.source+"|"+(m===ss?Ao:Bn).source+"|"+(t.evaluate||Bn).source+"|$","g"),C="//# sourceURL="+(Z.call(t,"sourceURL")?(t.sourceURL+"").replace(/\s/g," "):"lodash.templateSources["+ ++jo+"]")+` +`;e.replace(x,function(I,N,q,Se,ge,Ce){return q||(q=Se),v+=e.slice(p,Ce).replace(Oo,bf),N&&(a=!0,v+=`' + +__e(`+N+`) + +'`),ge&&(h=!0,v+=`'; +`+ge+`; +__p += '`),q&&(v+=`' + +((__t = (`+q+`)) == null ? '' : __t) + +'`),p=Ce+I.length,I}),v+=`'; +`;var T=Z.call(t,"variable")&&t.variable;if(!T)v=`with (obj) { +`+v+` +} +`;else if(xo.test(T))throw new U(y);v=(h?v.replace(io,""):v).replace(so,"$1").replace(uo,"$1;"),v="function("+(T||"obj")+`) { +`+(T?"":`obj || (obj = {}); +`)+"var __t, __p = ''"+(a?", __e = _.escape":"")+(h?`, __j = Array.prototype.join; +function print() { __p += __j.call(arguments, '') } +`:`; +`)+v+`return __p +}`;var W=El(function(){return k(l,C+"return "+v).apply(s,f)});if(W.source=v,Bi(W))throw W;return W}function z_(e){return K(e).toLowerCase()}function Z_(e){return K(e).toUpperCase()}function Y_(e,t,n){if(e=K(e),e&&(n||t===s))return Bs(e);if(!e||!(t=ye(t)))return e;var r=Ne(e),i=Ne(t),l=Ns(r,i),f=$s(r,i)+1;return mt(r,l,f).join("")}function X_(e,t,n){if(e=K(e),e&&(n||t===s))return e.slice(0,Gs(e)+1);if(!e||!(t=ye(t)))return e;var r=Ne(e),i=$s(r,Ne(t))+1;return mt(r,0,i).join("")}function J_(e,t,n){if(e=K(e),e&&(n||t===s))return e.replace(Br,"");if(!e||!(t=ye(t)))return e;var r=Ne(e),i=Ns(r,Ne(t));return mt(r,i).join("")}function Q_(e,t){var n=Gl,r=Hl;if(j(t)){var i="separator"in t?t.separator:i;n="length"in t?F(t.length):n,r="omission"in t?ye(t.omission):r}e=K(e);var l=e.length;if(qt(e)){var f=Ne(e);l=f.length}if(n>=l)return e;var a=n-Gt(r);if(a<1)return r;var h=f?mt(f,0,a).join(""):e.slice(0,a);if(i===s)return h+r;if(f&&(a+=h.length-a),Ni(i)){if(e.slice(a).search(i)){var p,m=h;for(i.global||(i=ti(i.source,K(us.exec(i))+"g")),i.lastIndex=0;p=i.exec(m);)var v=p.index;h=h.slice(0,v===s?a:v)}}else if(e.indexOf(ye(i),a)!=a){var x=h.lastIndexOf(i);x>-1&&(h=h.slice(0,x))}return h+r}function V_(e){return e=K(e),e&&lo.test(e)?e.replace(rs,Tf):e}var j_=Xt(function(e,t,n){return e+(n?" ":"")+t.toUpperCase()}),Gi=Ou("toUpperCase");function Il(e,t,n){return e=K(e),t=n?s:t,t===s?yf(e)?Of(e):df(e):e.match(t)||[]}var El=M(function(e,t){try{return be(e,s,t)}catch(n){return Bi(n)?n:new U(n)}}),eg=it(function(e,t){return Ie(t,function(n){n=Ye(n),nt(e,n,Wi(e[n],e))}),e});function tg(e){var t=e==null?0:e.length,n=L();return e=t?V(e,function(r){if(typeof r[1]!="function")throw new Ee(E);return[n(r[0]),r[1]]}):[],M(function(r){for(var i=-1;++ift)return[];var n=Ke,r=ae(e,Ke);t=L(t),e-=Ke;for(var i=Vr(r,t);++n0||t<0)?new $(n):(e<0?n=n.takeRight(-e):e&&(n=n.drop(e)),t!==s&&(t=F(t),n=t<0?n.dropRight(-t):n.take(t-e)),n)},$.prototype.takeRightWhile=function(e){return this.reverse().takeWhile(e).reverse()},$.prototype.toArray=function(){return this.take(Ke)},ze($.prototype,function(e,t){var n=/^(?:filter|find|map|reject)|While$/.test(t),r=/^(?:head|last)$/.test(t),i=u[r?"take"+(t=="last"?"Right":""):t],l=r||/^find/.test(t);i&&(u.prototype[t]=function(){var f=this.__wrapped__,a=r?[1]:arguments,h=f instanceof $,p=a[0],m=h||P(f),v=function(N){var q=i.apply(u,ct([N],a));return r&&x?q[0]:q};m&&n&&typeof p=="function"&&p.length!=1&&(h=m=!1);var x=this.__chain__,C=!!this.__actions__.length,T=l&&!x,W=h&&!C;if(!l&&m){f=W?f:new $(this);var I=e.apply(f,a);return I.__actions__.push({func:vr,args:[v],thisArg:s}),new Oe(I,x)}return T&&W?e.apply(this,a):(I=this.thru(v),T?r?I.value()[0]:I.value():I)})}),Ie(["pop","push","shift","sort","splice","unshift"],function(e){var t=kn[e],n=/^(?:push|sort|unshift)$/.test(e)?"tap":"thru",r=/^(?:pop|shift)$/.test(e);u.prototype[e]=function(){var i=arguments;if(r&&!this.__chain__){var l=this.value();return t.apply(P(l)?l:[],i)}return this[n](function(f){return t.apply(P(f)?f:[],i)})}}),ze($.prototype,function(e,t){var n=u[t];if(n){var r=n.name+"";Z.call(zt,r)||(zt[r]=[]),zt[r].push({name:t,func:n})}}),zt[cr(s,bt).name]=[{name:"wrapper",func:s}],$.prototype.clone=Vf,$.prototype.reverse=jf,$.prototype.value=ea,u.prototype.at=Eh,u.prototype.chain=Oh,u.prototype.commit=Rh,u.prototype.next=Dh,u.prototype.plant=Ph,u.prototype.reverse=Fh,u.prototype.toJSON=u.prototype.valueOf=u.prototype.value=Wh,u.prototype.first=u.prototype.head,dn&&(u.prototype[dn]=Uh),u},Ht=Rf();yt?((yt.exports=Ht)._=Ht,kr._=Ht):le._=Ht}).call(Tn)})(Lr,Lr.exports);var En=Lr.exports;const Ip={emits:["move","cell"],props:{context:{type:Object,default:()=>({})},item:{type:Object,default:()=>({})},columns:Array,cell:{type:Object,default(){return{row:0}}},cellTag:{type:String,default:"td"},orderable:{type:Boolean,default:!1}},computed:{row(){return this.cell&&this.cell.row||0},itemData(){return this.item instanceof Dt?this.item.data:this.item},cells(){const o=Hg(this.cell)&&kg(this.cell)||this.cell||{},d=[];for(var s in this.columns)d.push({...o,col:Number(s)});return d}},methods:{cellEmit(o,d,s){this.$emit("cell",{name:o,cell:d,data:s,item:this.item})},onDragStart(o){const s=`cell:${o.target.dataset.col}`;o.dataTransfer.setData("text/cell",s),o.dataTransfer.dropEffect="move"},onDragOver(o){o.preventDefault(),o.dataTransfer.dropEffect="move"},onDrop(o){const d=o.dataTransfer.getData("text/cell");!d||!d.startsWith("cell:")||(o.preventDefault(),this.$emit("move",{from:Number(d.slice(5)),to:Number(o.target.dataset.col)}))},getCellEl(o){const d=this.$el.querySelectorAll(this.cellTag);for(var s of d)if(o==Number(s.dataset.col))return s;return null},focus(o,d){d&&(o+=d.col);const s=this.getCellEl(o);if(!s)return;const O=s.querySelector('input:not([type="hidden"])')||s.querySelector("button")||s.querySelector("select")||s.querySelector("a");O&&O.focus()}},mounted(){this.$el.__row=this}};function Ep(o,d,s,O,A,w){return R(),B("tr",null,[G(o.$slots,"head",{context:s.context,item:s.item,row:w.row}),(R(!0),B(Xe,null,We(s.columns,(E,y)=>(R(),B(Xe,{key:y},[G(o.$slots,"cell-before",{context:s.context,item:s.item,cell:w.cells[y],attr:E}),(R(),jt(Kg(s.cellTag),{class:tn(["cell","cell-"+E]),"data-col":y,draggable:s.orderable,onDragstart:w.onDragStart,onDragover:w.onDragOver,onDrop:w.onDrop},{default:Q(()=>[G(o.$slots,E,{context:s.context,item:s.item,cell:w.cells[y],data:w.itemData,attr:E,emit:w.cellEmit,value:w.itemData&&w.itemData[E]},()=>[nn(re(w.itemData&&w.itemData[E]),1)]),G(o.$slots,"cell",{context:s.context,item:s.item,cell:w.cells[y],data:w.itemData,attr:E,emit:w.cellEmit,value:w.itemData&&w.itemData[E]})]),_:2},1064,["class","data-col","draggable","onDragstart","onDragover","onDrop"])),G(o.$slots,"cell-after",{context:s.context,item:s.item,col:y,cell:w.cells[y],attr:E})],64))),128)),G(o.$slots,"tail",{context:s.context,item:s.item,row:w.row})])}const ql=Qe(Ip,[["render",Ep]]),Qi={extends:Dl,components:{ARow:ql},emits:["cell","colmove"],props:{...Dl.props,context:{type:Object,default:()=>({})},columns:Array,columnsOrderable:Boolean},data(){return{...super.data,columns_:[...this.columns],extraItem:new this.set.model}},computed:{columnNames(){return this.columns_.map(o=>o.name)},columnLabels(){return this.columns_.reduce((o,d)=>({...o,[d.name]:d.label}),{})},rowSlots(){return Object.keys(this.$slots).filter(o=>o.startsWith("row-")).map(o=>[o,o.slice(4)])}},methods:{sortColumns(o){const d=o.map(O=>this.columns_.find(A=>A.name==O)).filter(O=>!!O),s=this.columns_.filter(O=>o.indexOf(O.name)==-1);this.columns_=[...d,...s],this.$emit("colmove")},moveColumn(o){const{from:d,to:s}=o,O=this.columns_[d];this.columns_.splice(d,1),this.columns_.splice(s,0,O),this.$emit("colmove",o)},onCellEvent(o,d){d.name=="focus"&&this.focus(d.data,d.cell),this.$emit("cell",{...d,row:o,set:this.set})},getRow(o){const d=this.$el.querySelectorAll("tr");for(var s of d)if(s.__row&&o==Number(s.dataset.row))return s.__row},focus(o,d,s=null){s&&(o+=s.row),o=this.getRow(o),o&&o.focus(d,s)}}};Qi.props.itemTag.default="tr";Qi.props.listTag.default="tbody";const Op=Qi,Rp={class:"table is-stripped is-fullwidth"},Dp=["title"],Up=b("i",{class:"fa fa-circle-question"},null,-1),Pp=[Up];function Fp(o,d,s,O,A,w){const E=Fe("a-row");return R(),B("table",Rp,[b("thead",null,[wt(E,{context:o.context,columns:o.columnNames,orderable:o.columnsOrderable,cellTag:"th",onMove:o.moveColumn},On({_:2},[o.$slots["header-head"]?{name:"head",fn:Q(y=>[G(o.$slots,"header-head",Je(Rt(y)))]),key:"0"}:void 0,o.$slots["header-tail"]?{name:"tail",fn:Q(y=>[G(o.$slots,"header-tail",Je(Rt(y)))]),key:"1"}:void 0,We(o.columns,y=>({name:y.name,fn:Q(D=>[G(o.$slots,"header-"+y.name,Je(Rt(D)),()=>[nn(re(y.label)+" ",1),y.help?(R(),B("span",{key:0,class:"icon small",title:y.help},Pp,8,Dp)):fe("",!0)])])}))]),1032,["context","columns","orderable","onMove"])]),b("tbody",null,[G(o.$slots,"head"),(R(!0),B(Xe,null,We(o.items,(y,D)=>(R(),jt(E,{key:D,context:o.context,item:y,cell:{row:D},columns:o.columnNames,"data-index":D,"data-row":D,draggable:o.orderable,onDragstart:o.onDragStart,onDragover:o.onDragOver,onDrop:o.onDrop,onCell:H=>o.onCellEvent(D,H)},On({_:2},[We(o.rowSlots,([H,z])=>({name:z,fn:Q(he=>[G(o.$slots,H,Je(Rt(he)))])}))]),1032,["context","item","cell","columns","data-index","data-row","draggable","onDragstart","onDragover","onDrop","onCell"]))),128)),G(o.$slots,"tail")])])}const Wp=Qe(Op,[["render",Fp]]),Mp={emit:["cell","move","colmove","load"],components:{ARows:Wp},props:{labels:Object,actionAdd:Function,columnsOrderable:Boolean,orderBy:String,formData:Object,model:{type:Function,default:Dt}},data(){return{set:new Ml(Dt)}},computed:{_prefix(){return this.formData.prefix?this.formData.prefix+"-":""},fields(){return this.formData.fields},orderField(){return this.orderBy&&this.fields.find(o=>o.name==this.orderBy)},orderable(){return!!this.orderField},hiddenFields(){return this.fields.filter(o=>o.hidden&&!(this.orderable&&o==this.orderField))},visibleFields(){return this.fields.filter(o=>!o.hidden)},fieldSlots(){return this.visibleFields.reduce((o,d)=>({...o,["row-"+d.name]:d}),{})},items(){return this.set.items},rows(){return this.$refs.rows}},methods:{onCellEvent(o){this.$emit("cell",o)},onColumnMove(o){this.$emit("colmove",o)},onActionAdd(){if(this.actionAdd)return this.actionAdd(this);this.set.push()},moveItem(o){const{from:d,to:s}=o,O=o.set||this.set;O.move(d,s),this.$emit("move",{...o,seŧ:O})},removeItem(o){this.items[o].id||this.items.splice(o,1)},load(o=[],d=!1){d&&(this.set.items=[]);for(var s of o)this.set.push(En.cloneDeep(s));this.$emit("load",o)},reset(){var o;this.load(((o=this.formData)==null?void 0:o.initials)||[],!0)}},mounted(){this.reset()}},Bp=["name","value"],Np=["name","value"],$p=["title","aria-label","aria-description"],qp=b("span",{class:"icon"},[b("i",{class:"fa fa-arrow-down-1-9"})],-1),Gp=[qp],Hp=["name","value"],kp=["name","value"],Kp=["name","value"],zp={key:0},Zp={class:"field"},Yp={class:"control"},Xp={class:"align-right pr-0"},Jp=["onClick","title","aria-label"],Qp=b("span",{class:"icon"},[b("i",{class:"fa fa-trash"})],-1),Vp=[Qp],jp={class:"a-formset-footer flex-row"},em={class:"flex-grow-1 flex-row"},tm={class:"flex-grow-1 align-right"},nm=["title","aria-label"],rm=b("span",{class:"icon"},[b("i",{class:"fa fa-rotate"})],-1),im=[rm],sm=["title","aria-label"],um=b("span",{class:"icon"},[b("i",{class:"fa fa-plus"})],-1),lm=[um];function om(o,d,s,O,A,w){const E=Fe("a-rows");return R(),B("div",null,[b("input",{type:"hidden",name:w._prefix+"TOTAL_FORMS",value:w.items.length||0},null,8,Bp),(R(!0),B(Xe,null,We(s.formData.management,(y,D)=>(R(),B("input",{key:D,type:"hidden",name:w._prefix+D.toUpperCase(),value:y},null,8,Np))),128)),wt(E,{ref:"rows",set:A.set,context:this,columns:w.visibleFields,columnsOrderable:s.columnsOrderable,orderable:w.orderable,onMove:w.moveItem,onColmove:w.onColumnMove,onCell:d[0]||(d[0]=y=>o.$emit("cell",y))},On({"header-head":Q(()=>[w.orderable?(R(),B(Xe,{key:0},[b("th",{style:{"max-width":"2em"},title:w.orderField.label,"aria-label":w.orderField.label,"aria-description":w.orderField.help||""},Gp,8,$p),G(o.$slots,"rows-header-head")],64)):fe("",!0)]),"row-head":Q(y=>[w.orderable?(R(),B("input",{key:0,type:"hidden",name:w._prefix+y.row+"-"+s.orderBy,value:y.row},null,8,Hp)):fe("",!0),b("input",{type:"hidden",name:w._prefix+y.row+"-id",value:y.item?y.item.id:""},null,8,kp),(R(!0),B(Xe,null,We(w.hiddenFields,D=>(R(),B(Xe,{key:D.name},[D.name in["id",s.orderBy]?fe("",!0):(R(),B("input",{key:0,type:"hidden",name:w._prefix+y.row+"-"+D.name,value:D.value in[null,void 0]?y.item.data[o.name]:D.value},null,8,Kp))],64))),128)),G(o.$slots,"row-head",Je(Rt(y)),()=>[w.orderable?(R(),B("td",zp,re(y.row+1),1)):fe("",!0)])]),"row-tail":Q(y=>[o.$slots["row-tail"]?G(o.$slots,"row-tail",Je(en({key:0},y))):fe("",!0),b("td",Xp,[b("button",{type:"button",class:"button square",onClick:zg(D=>w.removeItem(y.row,y.item),["stop"]),title:s.labels.remove_item,"aria-label":s.labels.remove_item},Vp,8,Jp)])]),_:2},[We(w.fieldSlots,(y,D)=>({name:D,fn:Q(H=>[G(o.$slots,D,en(H,{field:y,inputName:w._prefix+H.cell.row+"-"+y.name}),()=>[b("div",Zp,[b("div",Yp,[G(o.$slots,"control-"+y.name,en(H,{field:y,inputName:w._prefix+H.cell.row+"-"+y.name}))]),(R(!0),B(Xe,null,We(H.item.error(y.name),([z,he])=>(R(),B("p",{class:"help is-danger",key:he},re(z),1))),128))])])])}))]),1032,["set","columns","columnsOrderable","orderable","onMove","onColmove"]),b("div",jp,[b("div",em,[G(o.$slots,"footer")]),b("div",tm,[b("button",{type:"button",class:"button square is-warning p-2",onClick:d[1]||(d[1]=y=>w.reset()),title:s.labels.discard_changes,"aria-label":s.labels.discard_changes},im,8,nm),b("button",{type:"button",class:"button square is-primary p-2",onClick:d[2]||(d[2]=(...y)=>w.onActionAdd&&w.onActionAdd(...y)),title:s.labels.add_item,"aria-label":s.labels.add_item},lm,8,sm)])])])}const Vi=Qe(Mp,[["render",om]]),In={Text:0,List:1,Settings:2},fm={components:{AActionButton:Pl,AFormSet:Vi,ARow:ql,AModal:Fl},props:{formData:Object,labels:Object,initData:Object,dataPrefix:String,settingsUrl:String,defaultColumns:{type:Array,default:()=>["artist","title","tags","album","year","timestamp"]}},data(){const o={tracklist_editor_sep:" -- "};return{Page:In,page:In.Text,extraData:{},settings:o,savedSettings:En.cloneDeep(o)}},computed:{rows(){return this.$refs.formset&&this.$refs.formset.rows},columns(){return this.rows&&this.rows.columns_||[]},settingsChanged(){var o=Object.keys(this.savedSettings).findIndex(d=>!En.isEqual(this.settings[d],this.savedSettings[d]));return o!=-1},separator:{set(o){this.settings.tracklist_editor_sep=o,this.page==In.List&&this.updateInput()},get(){return this.settings.tracklist_editor_sep}},rowsSlots(){return Object.keys(this.$slots).filter(o=>o.startsWith("row-")||o.startsWith("rows-")||o.startsWith("control-")).map(o=>[o,o.startsWith("rows-")?o.slice(5):o])}},methods:{onCellEvent(o){switch(o.name){case"change":this.updateInput();break}},onColumnMove(){this.settings.tracklist_editor_columns=this.$refs.formset.rows.columnNames,this.page==this.Page.List?this.updateInput():this.updateList()},updateList(){const o=this.toList(this.$refs.textarea.value);this.$refs.formset.set.reset(o)},updateInput(){const o=this.toText(this.$refs.formset.items);this.$refs.textarea.value=o},toList(o){const d=this.$refs.formset.rows.columns_;var s=o.split(` +`),O=[];for(let y of s)if(y=y.trimLeft(),!!y){var A=y.split(this.separator),w={};for(var E in d){if(E>=A.length)break;const D=d[E];w[D.name]=A[E].trim()}w&&O.push(w)}return O},toText(o){const d=this.$refs.formset.rows.columns_,s=` ${this.separator.trim()} `,O=[];for(let E of o)if(E){var A=[];for(var w of d)A.push(E.data[w.name]||"");A=En.dropRightWhile(A,y=>!y||!(""+y).trim()),A=A.join(s).trimRight(),O.push(A)}return O.join(` +`)},_data_key(o){o=o.slice(this.dataPrefix.length);try{var[d,s]=o.split("-",1);return[Number(d),s]}catch{return[null,o]}},settingsSaved(o=null){o!==null&&(this.settings=o),this.$refs.settings&&this.$refs.settings.close(),this.savedSettings=En.cloneDeep(this.settings)}},mounted(){const o=this.initData&&this.initData.settings;o&&(this.settingsSaved(o),this.rows.sortColumns(o.tracklist_editor_columns)),this.page=this.initData.items.length?In.List:In.Text}},am={class:"a-tracklist-editor"},cm={class:"flex-row"},hm={class:"flex-grow-1"},dm={class:"flex-row align-right"},_m={class:"field has-addons"},gm={class:"control"},pm=b("span",{class:"icon is-small"},[b("i",{class:"fa fa-pencil"})],-1),mm={class:"control"},vm=b("span",{class:"icon is-small"},[b("i",{class:"fa fa-list"})],-1),wm={class:"control ml-3"},bm=["title"],xm=b("span",{class:"icon is-small"},[b("i",{class:"fa fa-cog"})],-1),ym=[xm],Am={class:"panel"},Sm={class:"panel"},Cm={class:"field"},Lm={class:"label",style:{"vertical-align":"middle"}},Tm={class:"table is-bordered",style:{"vertical-align":"middle"}},Im={key:0},Em={key:0,style:{cursor:"pointer"}},Om=["onClick"],Rm=b("i",{class:"fa fa-left-right"},null,-1),Dm=[Rm],Um={class:"flex-row"},Pm={class:"field is-inline-block is-vcentered flex-grow-1"},Fm=b("label",{class:"label is-inline mr-2",style:{"vertical-align":"middle"}}," Séparateur",-1),Wm={class:"control is-inline-block",style:{"vertical-align":"middle"}},Mm={class:"flex-row align-right"};function Bm(o,d,s,O,A,w){const E=Fe("a-form-set"),y=Fe("a-row"),D=Fe("a-action-button"),H=Fe("a-modal");return R(),B("div",am,[b("div",cm,[b("div",hm,[G(o.$slots,"title")]),b("div",dm,[b("div",_m,[b("p",gm,[b("button",{type:"button",class:tn(["button","p-2",A.page==A.Page.Text?"is-primary":"is-light"]),onClick:d[0]||(d[0]=z=>A.page=A.Page.Text)},[pm,b("span",null,re(s.labels.text),1)],2)]),b("p",mm,[b("button",{type:"button",class:tn(["button","p-2",A.page==A.Page.List?"is-primary":"is-light"]),onClick:d[1]||(d[1]=z=>A.page=A.Page.List)},[vm,b("span",null,re(s.labels.list),1)],2)]),b("p",wm,[b("button",{type:"button",class:"button is-info square",title:s.labels.settings,onClick:d[2]||(d[2]=z=>o.$refs.settings.open())},ym,8,bm)])])])]),Ji(b("section",Am,[b("textarea",{ref:"textarea",class:"is-fullwidth is-size-6",rows:"20",onChange:d[3]||(d[3]=(...z)=>w.updateList&&w.updateList(...z))},null,544)],512),[[Ul,A.page==A.Page.Text]]),Ji(b("section",Sm,[wt(E,{ref:"formset","form-data":s.formData,initials:s.initData.items,columnsOrderable:!0,labels:s.labels,"order-by":"position",onLoad:w.updateInput,onColmove:w.onColumnMove,onMove:w.updateInput,onCell:w.onCellEvent},On({_:2},[We(w.rowsSlots,([z,he])=>({name:he,fn:Q(Rn=>[z!="row-tail"?G(o.$slots,z,Je(en({key:0},Rn))):fe("",!0)])}))]),1032,["form-data","initials","labels","onLoad","onColmove","onMove","onCell"])],512),[[Ul,A.page==A.Page.List]]),wt(H,{ref:"settings",title:s.labels.settings},{default:Q(()=>[b("div",Cm,[b("label",Lm,re(s.labels.columns),1),b("table",Tm,[o.$refs.formset?(R(),B("tr",Im,[wt(y,{columns:o.$refs.formset.rows.columnNames,item:o.$refs.formset.rows.columnLabels,onMove:o.$refs.formset.rows.moveColumn},{"cell-after":Q(({cell:z})=>[z.colo.$refs.formset.rows.moveColumn({from:z.col,to:z.col+1})},Dm,8,Om)])):fe("",!0)]),_:1},8,["columns","item","onMove"])])):fe("",!0)])]),b("div",Um,[b("div",Pm,[Fm,b("div",Wm,[Ji(b("input",{type:"text",ref:"sep",class:"input is-inline is-text-centered is-small",style:{"max-width":"5em"},"onUpdate:modelValue":d[4]||(d[4]=z=>w.separator=z),onChange:d[5]||(d[5]=z=>w.updateList())},null,544),[[Zg,w.separator]])])])])]),footer:Q(()=>[b("div",Mm,[w.settingsChanged?(R(),jt(D,{key:0,icon:"fa fa-floppy-disk",class:"button control p-2 mr-3 is-secondary","run-class":"blink",url:s.settingsUrl,method:"POST",data:A.settings,"aria-label":s.labels.save_settings,onDone:d[6]||(d[6]=z=>w.settingsSaved())},{default:Q(()=>[nn(re(s.labels.save_settings),1)]),_:1},8,["url","data","aria-label"])):fe("",!0),b("button",{class:"button",type:"button",onClick:d[7]||(d[7]=z=>o.$refs.settings.close())}," Fermer ")])]),_:1},8,["title"])])}const Nm=Qe(fm,[["render",Bm]]),$m={components:{AFormSet:Vi,ASelectFile:Nl},props:{formData:Object,labels:Object,initData:Object,soundListUrl:String,soundUploadUrl:String,soundDeleteUrl:String},computed:{rowsSlots(){return Object.keys(this.$slots).filter(o=>o.startsWith("row-")||o.startsWith("rows-")||o.startsWith("control-")).map(o=>[o,o.startsWith("rows-")?o.slice(5):o])}},methods:{actionAdd(){this.$refs["select-file"].open()},selected(o){const d={sound:o.id,name:o.name,url:o.url,broadcast:o.broadcast};this.$refs.formset.set.push(d)}}},qm={class:"a-playlist-editor"},Gm=["src"],Hm={class:"label small flex-grow-1"},km=b("br",null,null,-1),Km=["src"],zm=["name","value"];function Zm(o,d,s,O,A,w){const E=Fe("a-select-file"),y=Fe("a-form-set");return R(),B("div",qm,[wt(E,{ref:"select-file",title:s.labels&&s.labels.add_sound,labels:s.labels,"list-url":s.soundListUrl,deleteUrl:s.soundDeleteUrl,uploadUrl:s.soundUploadUrl,uploadLabel:s.labels.select_file,onSelect:w.selected},{"upload-preview":Q(({upload:D})=>[G(o.$slots,"upload-preview",{upload:D})]),"upload-form":Q(()=>[G(o.$slots,"upload-form")]),default:Q(({item:D})=>[b("audio",{controls:"",src:D.url},null,8,Gm),b("label",Hm,re(D.name),1)]),_:3},8,["title","labels","list-url","deleteUrl","uploadUrl","uploadLabel","onSelect"]),wt(y,{ref:"formset","form-data":s.formData,labels:s.labels,initials:s.initData.items,"order-by":"position","action-add":w.actionAdd},On({"row-sound":Q(({item:D,inputName:H})=>[b("label",null,re(D.data.name),1),km,b("audio",{controls:"",src:D.data.url},null,8,Km),b("input",{type:"hidden",name:H,value:D.data.sound},null,8,zm)]),_:2},[We(w.rowsSlots,([D,H])=>({name:H,fn:Q(z=>[D!="row-tail"?G(o.$slots,D,Je(en({key:0},z))):fe("",!0)])}))]),1032,["form-data","labels","initials","action-add"])])}const Ym=Qe($m,[["render",Zm]]),Xm={components:{AAutocomplete:qg},props:{model:{type:Function,default:Dt},url:String,commitUrl:String,autocomplete:{type:Object},source_id:Number,source_field:String,target_field:String},data(){return{set:new Ml(this.model,{url:this.url,unique:!0})}},computed:{items(){var o;return((o=this.set)==null?void 0:o.items)||[]},initials(){let o={};return o[this.source_id_attr]=this.source_id,o},source_id_attr(){return this.source_field+"_id"},target_id_attr(){return this.target_field+"_id"},target_ids(){var o;return(o=this.set)==null?void 0:o.items.map(d=>d.data[this.target_id_attr])}},methods:{onSelect(o,d,s){if(this.target_ids.indexOf(d.id)!=-1)return;let O={...this.initials};O[this.target_field]={...d},O[this.target_id_attr]=d.id,this.set.push(O),this.$refs.autocomplete.reset()},save(){this.set.commit(this.commitUrl,{fields:[...Object.keys(this.initials),this.target_id_attr]})}},mounted(){this.set.fetch()}},Jm={class:"a-m2m-edit"},Qm={class:"table is-fullwidth"},Vm=b("th",{style:{width:"1rem"}},[b("span",{class:"icon"},[b("i",{class:"fa fa-trash"})])],-1),jm={class:"align-center"},e0=["onChange"],t0=b("label",null,[b("span",{class:"icon"},[b("i",{class:"fa fa-plus"})]),nn(" Add ")],-1);function n0(o,d,s,O,A,w){const E=Fe("a-autocomplete");return R(),B("div",Jm,[b("table",Qm,[b("thead",null,[b("tr",null,[b("th",null,[G(o.$slots,"items-title")]),Vm])]),b("tbody",null,[(R(!0),B(Xe,null,We(w.items,y=>(R(),B("tr",{key:y.id,class:tn([y.created&&"has-text-info",y.deleted&&"has-text-danger"])},[b("td",null,[G(o.$slots,"item",{item:y},()=>[nn(re(y.data),1)])]),b("td",jm,[b("input",{type:"checkbox",class:"checkbox",onChange:D=>y.deleted=D.target.checked},null,40,e0)])],2))),128))])]),b("div",null,[t0,wt(E,en({ref:"autocomplete"},s.autocomplete,{onSelect:w.onSelect}),{item:Q(({item:y})=>[G(o.$slots,"autocomplete-item",{item:y},()=>[nn(re(y),1)])]),_:3},16,["onSelect"])])])}const r0=Qe(Xm,[["render",n0]]),i0={...Gg,AManyToManyEdit:r0,AFileUpload:Bl,ASelectFile:Nl,AFormSet:Vi,ATrackListEditor:Nm,ASoundListEditor:Ym,AStatistics:bp,AStreamer:Tp},s0={...Xi,components:{...Xi.components,...i0},data(){return{...super.data,modalItem:null}},methods:{...Xi.methods,fileSelected(o,d,s){const O=this.$refs[o].item;O&&(this.$refs[d].value=O.id,s&&(s.src=O.file))}}};window.App=s0; +//# sourceMappingURL=admin.js.map diff --git a/aircox/static/aircox/admin.js.map b/aircox/static/aircox/admin.js.map new file mode 100644 index 0000000..de60e13 --- /dev/null +++ b/aircox/static/aircox/admin.js.map @@ -0,0 +1 @@ +{"version":3,"file":"admin.js","sources":["../../../assets/src/components/AFileUpload.vue","../../../assets/src/components/ASelectFile.vue","../../../assets/src/components/AStatistics.vue","../../../assets/src/streamer.js","../../../assets/src/components/AStreamer.vue","../../../assets/node_modules/lodash/lodash.js","../../../assets/src/components/ARow.vue","../../../assets/src/components/ARows.vue","../../../assets/src/components/AFormSet.vue","../../../assets/src/components/ATrackListEditor.vue","../../../assets/src/components/ASoundListEditor.vue","../../../assets/src/components/AManyToManyEdit.vue","../../../assets/src/components/admin.js","../../../assets/src/admin.js"],"sourcesContent":["\n\n","\n\n","\n\n\n","import Model from './model';\nimport {setEcoInterval} from './utils';\n\n\nexport class Streamer extends Model {\n get playlists() { return this.data ? this.data.playlists : []; }\n get queues() { return this.data ? this.data.queues : []; }\n get sources() { return [...this.queues, ...this.playlists]; }\n get source() { return this.sources.find(o => o.id == this.data.source) }\n\n commit(data) {\n if(!this.data)\n this.data = { id: data.id, playlists: [], queues: [] }\n\n data.playlists = Playlist.fromList(data.playlists, {streamer: this});\n data.queues = Queue.fromList(data.queues, {streamer: this});\n super.commit(data)\n }\n}\n\nexport default Streamer;\n\nexport class Request extends Model {\n static getId(data) { return data.rid; }\n}\n\nexport class Source extends Model {\n constructor(data, {streamer=null, ...options}={}) {\n super(data, options);\n this.streamer = streamer;\n setEcoInterval(() => this.tick(), 1000)\n }\n\n get isQueue() { return false; }\n get isPlaylist() { return false; }\n get isPlaying() { return this.data.status == 'playing' }\n get isPaused() { return this.data.status == 'paused' }\n\n get remainingString() {\n if(!this.remaining)\n return '00:00';\n\n const seconds = Math.floor(this.remaining % 60);\n const minutes = Math.floor(this.remaining / 60);\n return String(minutes).padStart(2, '0') + ':' +\n String(seconds).padStart(2, '0');\n }\n\n sync() { return this.action('sync/', {method: 'POST'}, true); }\n skip() { return this.action('skip/', {method: 'POST'}, true); }\n restart() { return this.action('restart/', {method: 'POST'}, true); }\n\n seek(count) {\n return this.action('seek/', {\n method: 'POST',\n body: JSON.stringify({count: count})\n }, true)\n }\n\n tick() {\n if(!this.data.remaining || !this.isPlaying)\n return;\n const delta = (Date.now() - this.commitDate) / 1000;\n this.remaining = this.data.remaining - delta\n }\n\n commit(data) {\n if(data.air_time)\n data.air_time = new Date(data.air_time);\n\n this.commitDate = Date.now()\n super.commit(data)\n this.remaining = data.remaining\n }\n}\n\n\nexport class Playlist extends Source {\n get isPlaylist() { return true; }\n}\n\n\nexport class Queue extends Source {\n get isQueue() { return true; }\n get queue() { return this.data && this.data.queue; }\n\n commit(data) {\n data.queue = Request.fromList(data.queue);\n super.commit(data)\n }\n\n push(soundId) {\n return this.action('push/', {\n method: 'POST',\n body: JSON.stringify({'sound_id': parseInt(soundId)})\n }, true);\n }\n}\n","\n\n","/**\n * @license\n * Lodash \n * Copyright OpenJS Foundation and other contributors \n * Released under MIT license \n * Based on Underscore.js 1.8.3 \n * Copyright Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors\n */\n;(function() {\n\n /** Used as a safe reference for `undefined` in pre-ES5 environments. */\n var undefined;\n\n /** Used as the semantic version number. */\n var VERSION = '4.17.21';\n\n /** Used as the size to enable large array optimizations. */\n var LARGE_ARRAY_SIZE = 200;\n\n /** Error message constants. */\n var CORE_ERROR_TEXT = 'Unsupported core-js use. Try https://npms.io/search?q=ponyfill.',\n FUNC_ERROR_TEXT = 'Expected a function',\n INVALID_TEMPL_VAR_ERROR_TEXT = 'Invalid `variable` option passed into `_.template`';\n\n /** Used to stand-in for `undefined` hash values. */\n var HASH_UNDEFINED = '__lodash_hash_undefined__';\n\n /** Used as the maximum memoize cache size. */\n var MAX_MEMOIZE_SIZE = 500;\n\n /** Used as the internal argument placeholder. */\n var PLACEHOLDER = '__lodash_placeholder__';\n\n /** Used to compose bitmasks for cloning. */\n var CLONE_DEEP_FLAG = 1,\n CLONE_FLAT_FLAG = 2,\n CLONE_SYMBOLS_FLAG = 4;\n\n /** Used to compose bitmasks for value comparisons. */\n var COMPARE_PARTIAL_FLAG = 1,\n COMPARE_UNORDERED_FLAG = 2;\n\n /** Used to compose bitmasks for function metadata. */\n var WRAP_BIND_FLAG = 1,\n WRAP_BIND_KEY_FLAG = 2,\n WRAP_CURRY_BOUND_FLAG = 4,\n WRAP_CURRY_FLAG = 8,\n WRAP_CURRY_RIGHT_FLAG = 16,\n WRAP_PARTIAL_FLAG = 32,\n WRAP_PARTIAL_RIGHT_FLAG = 64,\n WRAP_ARY_FLAG = 128,\n WRAP_REARG_FLAG = 256,\n WRAP_FLIP_FLAG = 512;\n\n /** Used as default options for `_.truncate`. */\n var DEFAULT_TRUNC_LENGTH = 30,\n DEFAULT_TRUNC_OMISSION = '...';\n\n /** Used to detect hot functions by number of calls within a span of milliseconds. */\n var HOT_COUNT = 800,\n HOT_SPAN = 16;\n\n /** Used to indicate the type of lazy iteratees. */\n var LAZY_FILTER_FLAG = 1,\n LAZY_MAP_FLAG = 2,\n LAZY_WHILE_FLAG = 3;\n\n /** Used as references for various `Number` constants. */\n var INFINITY = 1 / 0,\n MAX_SAFE_INTEGER = 9007199254740991,\n MAX_INTEGER = 1.7976931348623157e+308,\n NAN = 0 / 0;\n\n /** Used as references for the maximum length and index of an array. */\n var MAX_ARRAY_LENGTH = 4294967295,\n MAX_ARRAY_INDEX = MAX_ARRAY_LENGTH - 1,\n HALF_MAX_ARRAY_LENGTH = MAX_ARRAY_LENGTH >>> 1;\n\n /** Used to associate wrap methods with their bit flags. */\n var wrapFlags = [\n ['ary', WRAP_ARY_FLAG],\n ['bind', WRAP_BIND_FLAG],\n ['bindKey', WRAP_BIND_KEY_FLAG],\n ['curry', WRAP_CURRY_FLAG],\n ['curryRight', WRAP_CURRY_RIGHT_FLAG],\n ['flip', WRAP_FLIP_FLAG],\n ['partial', WRAP_PARTIAL_FLAG],\n ['partialRight', WRAP_PARTIAL_RIGHT_FLAG],\n ['rearg', WRAP_REARG_FLAG]\n ];\n\n /** `Object#toString` result references. */\n var argsTag = '[object Arguments]',\n arrayTag = '[object Array]',\n asyncTag = '[object AsyncFunction]',\n boolTag = '[object Boolean]',\n dateTag = '[object Date]',\n domExcTag = '[object DOMException]',\n errorTag = '[object Error]',\n funcTag = '[object Function]',\n genTag = '[object GeneratorFunction]',\n mapTag = '[object Map]',\n numberTag = '[object Number]',\n nullTag = '[object Null]',\n objectTag = '[object Object]',\n promiseTag = '[object Promise]',\n proxyTag = '[object Proxy]',\n regexpTag = '[object RegExp]',\n setTag = '[object Set]',\n stringTag = '[object String]',\n symbolTag = '[object Symbol]',\n undefinedTag = '[object Undefined]',\n weakMapTag = '[object WeakMap]',\n weakSetTag = '[object WeakSet]';\n\n var arrayBufferTag = '[object ArrayBuffer]',\n dataViewTag = '[object DataView]',\n float32Tag = '[object Float32Array]',\n float64Tag = '[object Float64Array]',\n int8Tag = '[object Int8Array]',\n int16Tag = '[object Int16Array]',\n int32Tag = '[object Int32Array]',\n uint8Tag = '[object Uint8Array]',\n uint8ClampedTag = '[object Uint8ClampedArray]',\n uint16Tag = '[object Uint16Array]',\n uint32Tag = '[object Uint32Array]';\n\n /** Used to match empty string literals in compiled template source. */\n var reEmptyStringLeading = /\\b__p \\+= '';/g,\n reEmptyStringMiddle = /\\b(__p \\+=) '' \\+/g,\n reEmptyStringTrailing = /(__e\\(.*?\\)|\\b__t\\)) \\+\\n'';/g;\n\n /** Used to match HTML entities and HTML characters. */\n var reEscapedHtml = /&(?:amp|lt|gt|quot|#39);/g,\n reUnescapedHtml = /[&<>\"']/g,\n reHasEscapedHtml = RegExp(reEscapedHtml.source),\n reHasUnescapedHtml = RegExp(reUnescapedHtml.source);\n\n /** Used to match template delimiters. */\n var reEscape = /<%-([\\s\\S]+?)%>/g,\n reEvaluate = /<%([\\s\\S]+?)%>/g,\n reInterpolate = /<%=([\\s\\S]+?)%>/g;\n\n /** Used to match property names within property paths. */\n var reIsDeepProp = /\\.|\\[(?:[^[\\]]*|([\"'])(?:(?!\\1)[^\\\\]|\\\\.)*?\\1)\\]/,\n reIsPlainProp = /^\\w*$/,\n rePropName = /[^.[\\]]+|\\[(?:(-?\\d+(?:\\.\\d+)?)|([\"'])((?:(?!\\2)[^\\\\]|\\\\.)*?)\\2)\\]|(?=(?:\\.|\\[\\])(?:\\.|\\[\\]|$))/g;\n\n /**\n * Used to match `RegExp`\n * [syntax characters](http://ecma-international.org/ecma-262/7.0/#sec-patterns).\n */\n var reRegExpChar = /[\\\\^$.*+?()[\\]{}|]/g,\n reHasRegExpChar = RegExp(reRegExpChar.source);\n\n /** Used to match leading whitespace. */\n var reTrimStart = /^\\s+/;\n\n /** Used to match a single whitespace character. */\n var reWhitespace = /\\s/;\n\n /** Used to match wrap detail comments. */\n var reWrapComment = /\\{(?:\\n\\/\\* \\[wrapped with .+\\] \\*\\/)?\\n?/,\n reWrapDetails = /\\{\\n\\/\\* \\[wrapped with (.+)\\] \\*/,\n reSplitDetails = /,? & /;\n\n /** Used to match words composed of alphanumeric characters. */\n var reAsciiWord = /[^\\x00-\\x2f\\x3a-\\x40\\x5b-\\x60\\x7b-\\x7f]+/g;\n\n /**\n * Used to validate the `validate` option in `_.template` variable.\n *\n * Forbids characters which could potentially change the meaning of the function argument definition:\n * - \"(),\" (modification of function parameters)\n * - \"=\" (default value)\n * - \"[]{}\" (destructuring of function parameters)\n * - \"/\" (beginning of a comment)\n * - whitespace\n */\n var reForbiddenIdentifierChars = /[()=,{}\\[\\]\\/\\s]/;\n\n /** Used to match backslashes in property paths. */\n var reEscapeChar = /\\\\(\\\\)?/g;\n\n /**\n * Used to match\n * [ES template delimiters](http://ecma-international.org/ecma-262/7.0/#sec-template-literal-lexical-components).\n */\n var reEsTemplate = /\\$\\{([^\\\\}]*(?:\\\\.[^\\\\}]*)*)\\}/g;\n\n /** Used to match `RegExp` flags from their coerced string values. */\n var reFlags = /\\w*$/;\n\n /** Used to detect bad signed hexadecimal string values. */\n var reIsBadHex = /^[-+]0x[0-9a-f]+$/i;\n\n /** Used to detect binary string values. */\n var reIsBinary = /^0b[01]+$/i;\n\n /** Used to detect host constructors (Safari). */\n var reIsHostCtor = /^\\[object .+?Constructor\\]$/;\n\n /** Used to detect octal string values. */\n var reIsOctal = /^0o[0-7]+$/i;\n\n /** Used to detect unsigned integer values. */\n var reIsUint = /^(?:0|[1-9]\\d*)$/;\n\n /** Used to match Latin Unicode letters (excluding mathematical operators). */\n var reLatin = /[\\xc0-\\xd6\\xd8-\\xf6\\xf8-\\xff\\u0100-\\u017f]/g;\n\n /** Used to ensure capturing order of template delimiters. */\n var reNoMatch = /($^)/;\n\n /** Used to match unescaped characters in compiled string literals. */\n var reUnescapedString = /['\\n\\r\\u2028\\u2029\\\\]/g;\n\n /** Used to compose unicode character classes. */\n var rsAstralRange = '\\\\ud800-\\\\udfff',\n rsComboMarksRange = '\\\\u0300-\\\\u036f',\n reComboHalfMarksRange = '\\\\ufe20-\\\\ufe2f',\n rsComboSymbolsRange = '\\\\u20d0-\\\\u20ff',\n rsComboRange = rsComboMarksRange + reComboHalfMarksRange + rsComboSymbolsRange,\n rsDingbatRange = '\\\\u2700-\\\\u27bf',\n rsLowerRange = 'a-z\\\\xdf-\\\\xf6\\\\xf8-\\\\xff',\n rsMathOpRange = '\\\\xac\\\\xb1\\\\xd7\\\\xf7',\n rsNonCharRange = '\\\\x00-\\\\x2f\\\\x3a-\\\\x40\\\\x5b-\\\\x60\\\\x7b-\\\\xbf',\n rsPunctuationRange = '\\\\u2000-\\\\u206f',\n rsSpaceRange = ' \\\\t\\\\x0b\\\\f\\\\xa0\\\\ufeff\\\\n\\\\r\\\\u2028\\\\u2029\\\\u1680\\\\u180e\\\\u2000\\\\u2001\\\\u2002\\\\u2003\\\\u2004\\\\u2005\\\\u2006\\\\u2007\\\\u2008\\\\u2009\\\\u200a\\\\u202f\\\\u205f\\\\u3000',\n rsUpperRange = 'A-Z\\\\xc0-\\\\xd6\\\\xd8-\\\\xde',\n rsVarRange = '\\\\ufe0e\\\\ufe0f',\n rsBreakRange = rsMathOpRange + rsNonCharRange + rsPunctuationRange + rsSpaceRange;\n\n /** Used to compose unicode capture groups. */\n var rsApos = \"['\\u2019]\",\n rsAstral = '[' + rsAstralRange + ']',\n rsBreak = '[' + rsBreakRange + ']',\n rsCombo = '[' + rsComboRange + ']',\n rsDigits = '\\\\d+',\n rsDingbat = '[' + rsDingbatRange + ']',\n rsLower = '[' + rsLowerRange + ']',\n rsMisc = '[^' + rsAstralRange + rsBreakRange + rsDigits + rsDingbatRange + rsLowerRange + rsUpperRange + ']',\n rsFitz = '\\\\ud83c[\\\\udffb-\\\\udfff]',\n rsModifier = '(?:' + rsCombo + '|' + rsFitz + ')',\n rsNonAstral = '[^' + rsAstralRange + ']',\n rsRegional = '(?:\\\\ud83c[\\\\udde6-\\\\uddff]){2}',\n rsSurrPair = '[\\\\ud800-\\\\udbff][\\\\udc00-\\\\udfff]',\n rsUpper = '[' + rsUpperRange + ']',\n rsZWJ = '\\\\u200d';\n\n /** Used to compose unicode regexes. */\n var rsMiscLower = '(?:' + rsLower + '|' + rsMisc + ')',\n rsMiscUpper = '(?:' + rsUpper + '|' + rsMisc + ')',\n rsOptContrLower = '(?:' + rsApos + '(?:d|ll|m|re|s|t|ve))?',\n rsOptContrUpper = '(?:' + rsApos + '(?:D|LL|M|RE|S|T|VE))?',\n reOptMod = rsModifier + '?',\n rsOptVar = '[' + rsVarRange + ']?',\n rsOptJoin = '(?:' + rsZWJ + '(?:' + [rsNonAstral, rsRegional, rsSurrPair].join('|') + ')' + rsOptVar + reOptMod + ')*',\n rsOrdLower = '\\\\d*(?:1st|2nd|3rd|(?![123])\\\\dth)(?=\\\\b|[A-Z_])',\n rsOrdUpper = '\\\\d*(?:1ST|2ND|3RD|(?![123])\\\\dTH)(?=\\\\b|[a-z_])',\n rsSeq = rsOptVar + reOptMod + rsOptJoin,\n rsEmoji = '(?:' + [rsDingbat, rsRegional, rsSurrPair].join('|') + ')' + rsSeq,\n rsSymbol = '(?:' + [rsNonAstral + rsCombo + '?', rsCombo, rsRegional, rsSurrPair, rsAstral].join('|') + ')';\n\n /** Used to match apostrophes. */\n var reApos = RegExp(rsApos, 'g');\n\n /**\n * Used to match [combining diacritical marks](https://en.wikipedia.org/wiki/Combining_Diacritical_Marks) and\n * [combining diacritical marks for symbols](https://en.wikipedia.org/wiki/Combining_Diacritical_Marks_for_Symbols).\n */\n var reComboMark = RegExp(rsCombo, 'g');\n\n /** Used to match [string symbols](https://mathiasbynens.be/notes/javascript-unicode). */\n var reUnicode = RegExp(rsFitz + '(?=' + rsFitz + ')|' + rsSymbol + rsSeq, 'g');\n\n /** Used to match complex or compound words. */\n var reUnicodeWord = RegExp([\n rsUpper + '?' + rsLower + '+' + rsOptContrLower + '(?=' + [rsBreak, rsUpper, '$'].join('|') + ')',\n rsMiscUpper + '+' + rsOptContrUpper + '(?=' + [rsBreak, rsUpper + rsMiscLower, '$'].join('|') + ')',\n rsUpper + '?' + rsMiscLower + '+' + rsOptContrLower,\n rsUpper + '+' + rsOptContrUpper,\n rsOrdUpper,\n rsOrdLower,\n rsDigits,\n rsEmoji\n ].join('|'), 'g');\n\n /** Used to detect strings with [zero-width joiners or code points from the astral planes](http://eev.ee/blog/2015/09/12/dark-corners-of-unicode/). */\n var reHasUnicode = RegExp('[' + rsZWJ + rsAstralRange + rsComboRange + rsVarRange + ']');\n\n /** Used to detect strings that need a more robust regexp to match words. */\n var reHasUnicodeWord = /[a-z][A-Z]|[A-Z]{2}[a-z]|[0-9][a-zA-Z]|[a-zA-Z][0-9]|[^a-zA-Z0-9 ]/;\n\n /** Used to assign default `context` object properties. */\n var contextProps = [\n 'Array', 'Buffer', 'DataView', 'Date', 'Error', 'Float32Array', 'Float64Array',\n 'Function', 'Int8Array', 'Int16Array', 'Int32Array', 'Map', 'Math', 'Object',\n 'Promise', 'RegExp', 'Set', 'String', 'Symbol', 'TypeError', 'Uint8Array',\n 'Uint8ClampedArray', 'Uint16Array', 'Uint32Array', 'WeakMap',\n '_', 'clearTimeout', 'isFinite', 'parseInt', 'setTimeout'\n ];\n\n /** Used to make template sourceURLs easier to identify. */\n var templateCounter = -1;\n\n /** Used to identify `toStringTag` values of typed arrays. */\n var typedArrayTags = {};\n typedArrayTags[float32Tag] = typedArrayTags[float64Tag] =\n typedArrayTags[int8Tag] = typedArrayTags[int16Tag] =\n typedArrayTags[int32Tag] = typedArrayTags[uint8Tag] =\n typedArrayTags[uint8ClampedTag] = typedArrayTags[uint16Tag] =\n typedArrayTags[uint32Tag] = true;\n typedArrayTags[argsTag] = typedArrayTags[arrayTag] =\n typedArrayTags[arrayBufferTag] = typedArrayTags[boolTag] =\n typedArrayTags[dataViewTag] = typedArrayTags[dateTag] =\n typedArrayTags[errorTag] = typedArrayTags[funcTag] =\n typedArrayTags[mapTag] = typedArrayTags[numberTag] =\n typedArrayTags[objectTag] = typedArrayTags[regexpTag] =\n typedArrayTags[setTag] = typedArrayTags[stringTag] =\n typedArrayTags[weakMapTag] = false;\n\n /** Used to identify `toStringTag` values supported by `_.clone`. */\n var cloneableTags = {};\n cloneableTags[argsTag] = cloneableTags[arrayTag] =\n cloneableTags[arrayBufferTag] = cloneableTags[dataViewTag] =\n cloneableTags[boolTag] = cloneableTags[dateTag] =\n cloneableTags[float32Tag] = cloneableTags[float64Tag] =\n cloneableTags[int8Tag] = cloneableTags[int16Tag] =\n cloneableTags[int32Tag] = cloneableTags[mapTag] =\n cloneableTags[numberTag] = cloneableTags[objectTag] =\n cloneableTags[regexpTag] = cloneableTags[setTag] =\n cloneableTags[stringTag] = cloneableTags[symbolTag] =\n cloneableTags[uint8Tag] = cloneableTags[uint8ClampedTag] =\n cloneableTags[uint16Tag] = cloneableTags[uint32Tag] = true;\n cloneableTags[errorTag] = cloneableTags[funcTag] =\n cloneableTags[weakMapTag] = false;\n\n /** Used to map Latin Unicode letters to basic Latin letters. */\n var deburredLetters = {\n // Latin-1 Supplement block.\n '\\xc0': 'A', '\\xc1': 'A', '\\xc2': 'A', '\\xc3': 'A', '\\xc4': 'A', '\\xc5': 'A',\n '\\xe0': 'a', '\\xe1': 'a', '\\xe2': 'a', '\\xe3': 'a', '\\xe4': 'a', '\\xe5': 'a',\n '\\xc7': 'C', '\\xe7': 'c',\n '\\xd0': 'D', '\\xf0': 'd',\n '\\xc8': 'E', '\\xc9': 'E', '\\xca': 'E', '\\xcb': 'E',\n '\\xe8': 'e', '\\xe9': 'e', '\\xea': 'e', '\\xeb': 'e',\n '\\xcc': 'I', '\\xcd': 'I', '\\xce': 'I', '\\xcf': 'I',\n '\\xec': 'i', '\\xed': 'i', '\\xee': 'i', '\\xef': 'i',\n '\\xd1': 'N', '\\xf1': 'n',\n '\\xd2': 'O', '\\xd3': 'O', '\\xd4': 'O', '\\xd5': 'O', '\\xd6': 'O', '\\xd8': 'O',\n '\\xf2': 'o', '\\xf3': 'o', '\\xf4': 'o', '\\xf5': 'o', '\\xf6': 'o', '\\xf8': 'o',\n '\\xd9': 'U', '\\xda': 'U', '\\xdb': 'U', '\\xdc': 'U',\n '\\xf9': 'u', '\\xfa': 'u', '\\xfb': 'u', '\\xfc': 'u',\n '\\xdd': 'Y', '\\xfd': 'y', '\\xff': 'y',\n '\\xc6': 'Ae', '\\xe6': 'ae',\n '\\xde': 'Th', '\\xfe': 'th',\n '\\xdf': 'ss',\n // Latin Extended-A block.\n '\\u0100': 'A', '\\u0102': 'A', '\\u0104': 'A',\n '\\u0101': 'a', '\\u0103': 'a', '\\u0105': 'a',\n '\\u0106': 'C', '\\u0108': 'C', '\\u010a': 'C', '\\u010c': 'C',\n '\\u0107': 'c', '\\u0109': 'c', '\\u010b': 'c', '\\u010d': 'c',\n '\\u010e': 'D', '\\u0110': 'D', '\\u010f': 'd', '\\u0111': 'd',\n '\\u0112': 'E', '\\u0114': 'E', '\\u0116': 'E', '\\u0118': 'E', '\\u011a': 'E',\n '\\u0113': 'e', '\\u0115': 'e', '\\u0117': 'e', '\\u0119': 'e', '\\u011b': 'e',\n '\\u011c': 'G', '\\u011e': 'G', '\\u0120': 'G', '\\u0122': 'G',\n '\\u011d': 'g', '\\u011f': 'g', '\\u0121': 'g', '\\u0123': 'g',\n '\\u0124': 'H', '\\u0126': 'H', '\\u0125': 'h', '\\u0127': 'h',\n '\\u0128': 'I', '\\u012a': 'I', '\\u012c': 'I', '\\u012e': 'I', '\\u0130': 'I',\n '\\u0129': 'i', '\\u012b': 'i', '\\u012d': 'i', '\\u012f': 'i', '\\u0131': 'i',\n '\\u0134': 'J', '\\u0135': 'j',\n '\\u0136': 'K', '\\u0137': 'k', '\\u0138': 'k',\n '\\u0139': 'L', '\\u013b': 'L', '\\u013d': 'L', '\\u013f': 'L', '\\u0141': 'L',\n '\\u013a': 'l', '\\u013c': 'l', '\\u013e': 'l', '\\u0140': 'l', '\\u0142': 'l',\n '\\u0143': 'N', '\\u0145': 'N', '\\u0147': 'N', '\\u014a': 'N',\n '\\u0144': 'n', '\\u0146': 'n', '\\u0148': 'n', '\\u014b': 'n',\n '\\u014c': 'O', '\\u014e': 'O', '\\u0150': 'O',\n '\\u014d': 'o', '\\u014f': 'o', '\\u0151': 'o',\n '\\u0154': 'R', '\\u0156': 'R', '\\u0158': 'R',\n '\\u0155': 'r', '\\u0157': 'r', '\\u0159': 'r',\n '\\u015a': 'S', '\\u015c': 'S', '\\u015e': 'S', '\\u0160': 'S',\n '\\u015b': 's', '\\u015d': 's', '\\u015f': 's', '\\u0161': 's',\n '\\u0162': 'T', '\\u0164': 'T', '\\u0166': 'T',\n '\\u0163': 't', '\\u0165': 't', '\\u0167': 't',\n '\\u0168': 'U', '\\u016a': 'U', '\\u016c': 'U', '\\u016e': 'U', '\\u0170': 'U', '\\u0172': 'U',\n '\\u0169': 'u', '\\u016b': 'u', '\\u016d': 'u', '\\u016f': 'u', '\\u0171': 'u', '\\u0173': 'u',\n '\\u0174': 'W', '\\u0175': 'w',\n '\\u0176': 'Y', '\\u0177': 'y', '\\u0178': 'Y',\n '\\u0179': 'Z', '\\u017b': 'Z', '\\u017d': 'Z',\n '\\u017a': 'z', '\\u017c': 'z', '\\u017e': 'z',\n '\\u0132': 'IJ', '\\u0133': 'ij',\n '\\u0152': 'Oe', '\\u0153': 'oe',\n '\\u0149': \"'n\", '\\u017f': 's'\n };\n\n /** Used to map characters to HTML entities. */\n var htmlEscapes = {\n '&': '&',\n '<': '<',\n '>': '>',\n '\"': '"',\n \"'\": '''\n };\n\n /** Used to map HTML entities to characters. */\n var htmlUnescapes = {\n '&': '&',\n '<': '<',\n '>': '>',\n '"': '\"',\n ''': \"'\"\n };\n\n /** Used to escape characters for inclusion in compiled string literals. */\n var stringEscapes = {\n '\\\\': '\\\\',\n \"'\": \"'\",\n '\\n': 'n',\n '\\r': 'r',\n '\\u2028': 'u2028',\n '\\u2029': 'u2029'\n };\n\n /** Built-in method references without a dependency on `root`. */\n var freeParseFloat = parseFloat,\n freeParseInt = parseInt;\n\n /** Detect free variable `global` from Node.js. */\n var freeGlobal = typeof global == 'object' && global && global.Object === Object && global;\n\n /** Detect free variable `self`. */\n var freeSelf = typeof self == 'object' && self && self.Object === Object && self;\n\n /** Used as a reference to the global object. */\n var root = freeGlobal || freeSelf || Function('return this')();\n\n /** Detect free variable `exports`. */\n var freeExports = typeof exports == 'object' && exports && !exports.nodeType && exports;\n\n /** Detect free variable `module`. */\n var freeModule = freeExports && typeof module == 'object' && module && !module.nodeType && module;\n\n /** Detect the popular CommonJS extension `module.exports`. */\n var moduleExports = freeModule && freeModule.exports === freeExports;\n\n /** Detect free variable `process` from Node.js. */\n var freeProcess = moduleExports && freeGlobal.process;\n\n /** Used to access faster Node.js helpers. */\n var nodeUtil = (function() {\n try {\n // Use `util.types` for Node.js 10+.\n var types = freeModule && freeModule.require && freeModule.require('util').types;\n\n if (types) {\n return types;\n }\n\n // Legacy `process.binding('util')` for Node.js < 10.\n return freeProcess && freeProcess.binding && freeProcess.binding('util');\n } catch (e) {}\n }());\n\n /* Node.js helper references. */\n var nodeIsArrayBuffer = nodeUtil && nodeUtil.isArrayBuffer,\n nodeIsDate = nodeUtil && nodeUtil.isDate,\n nodeIsMap = nodeUtil && nodeUtil.isMap,\n nodeIsRegExp = nodeUtil && nodeUtil.isRegExp,\n nodeIsSet = nodeUtil && nodeUtil.isSet,\n nodeIsTypedArray = nodeUtil && nodeUtil.isTypedArray;\n\n /*--------------------------------------------------------------------------*/\n\n /**\n * A faster alternative to `Function#apply`, this function invokes `func`\n * with the `this` binding of `thisArg` and the arguments of `args`.\n *\n * @private\n * @param {Function} func The function to invoke.\n * @param {*} thisArg The `this` binding of `func`.\n * @param {Array} args The arguments to invoke `func` with.\n * @returns {*} Returns the result of `func`.\n */\n function apply(func, thisArg, args) {\n switch (args.length) {\n case 0: return func.call(thisArg);\n case 1: return func.call(thisArg, args[0]);\n case 2: return func.call(thisArg, args[0], args[1]);\n case 3: return func.call(thisArg, args[0], args[1], args[2]);\n }\n return func.apply(thisArg, args);\n }\n\n /**\n * A specialized version of `baseAggregator` for arrays.\n *\n * @private\n * @param {Array} [array] The array to iterate over.\n * @param {Function} setter The function to set `accumulator` values.\n * @param {Function} iteratee The iteratee to transform keys.\n * @param {Object} accumulator The initial aggregated object.\n * @returns {Function} Returns `accumulator`.\n */\n function arrayAggregator(array, setter, iteratee, accumulator) {\n var index = -1,\n length = array == null ? 0 : array.length;\n\n while (++index < length) {\n var value = array[index];\n setter(accumulator, value, iteratee(value), array);\n }\n return accumulator;\n }\n\n /**\n * A specialized version of `_.forEach` for arrays without support for\n * iteratee shorthands.\n *\n * @private\n * @param {Array} [array] The array to iterate over.\n * @param {Function} iteratee The function invoked per iteration.\n * @returns {Array} Returns `array`.\n */\n function arrayEach(array, iteratee) {\n var index = -1,\n length = array == null ? 0 : array.length;\n\n while (++index < length) {\n if (iteratee(array[index], index, array) === false) {\n break;\n }\n }\n return array;\n }\n\n /**\n * A specialized version of `_.forEachRight` for arrays without support for\n * iteratee shorthands.\n *\n * @private\n * @param {Array} [array] The array to iterate over.\n * @param {Function} iteratee The function invoked per iteration.\n * @returns {Array} Returns `array`.\n */\n function arrayEachRight(array, iteratee) {\n var length = array == null ? 0 : array.length;\n\n while (length--) {\n if (iteratee(array[length], length, array) === false) {\n break;\n }\n }\n return array;\n }\n\n /**\n * A specialized version of `_.every` for arrays without support for\n * iteratee shorthands.\n *\n * @private\n * @param {Array} [array] The array to iterate over.\n * @param {Function} predicate The function invoked per iteration.\n * @returns {boolean} Returns `true` if all elements pass the predicate check,\n * else `false`.\n */\n function arrayEvery(array, predicate) {\n var index = -1,\n length = array == null ? 0 : array.length;\n\n while (++index < length) {\n if (!predicate(array[index], index, array)) {\n return false;\n }\n }\n return true;\n }\n\n /**\n * A specialized version of `_.filter` for arrays without support for\n * iteratee shorthands.\n *\n * @private\n * @param {Array} [array] The array to iterate over.\n * @param {Function} predicate The function invoked per iteration.\n * @returns {Array} Returns the new filtered array.\n */\n function arrayFilter(array, predicate) {\n var index = -1,\n length = array == null ? 0 : array.length,\n resIndex = 0,\n result = [];\n\n while (++index < length) {\n var value = array[index];\n if (predicate(value, index, array)) {\n result[resIndex++] = value;\n }\n }\n return result;\n }\n\n /**\n * A specialized version of `_.includes` for arrays without support for\n * specifying an index to search from.\n *\n * @private\n * @param {Array} [array] The array to inspect.\n * @param {*} target The value to search for.\n * @returns {boolean} Returns `true` if `target` is found, else `false`.\n */\n function arrayIncludes(array, value) {\n var length = array == null ? 0 : array.length;\n return !!length && baseIndexOf(array, value, 0) > -1;\n }\n\n /**\n * This function is like `arrayIncludes` except that it accepts a comparator.\n *\n * @private\n * @param {Array} [array] The array to inspect.\n * @param {*} target The value to search for.\n * @param {Function} comparator The comparator invoked per element.\n * @returns {boolean} Returns `true` if `target` is found, else `false`.\n */\n function arrayIncludesWith(array, value, comparator) {\n var index = -1,\n length = array == null ? 0 : array.length;\n\n while (++index < length) {\n if (comparator(value, array[index])) {\n return true;\n }\n }\n return false;\n }\n\n /**\n * A specialized version of `_.map` for arrays without support for iteratee\n * shorthands.\n *\n * @private\n * @param {Array} [array] The array to iterate over.\n * @param {Function} iteratee The function invoked per iteration.\n * @returns {Array} Returns the new mapped array.\n */\n function arrayMap(array, iteratee) {\n var index = -1,\n length = array == null ? 0 : array.length,\n result = Array(length);\n\n while (++index < length) {\n result[index] = iteratee(array[index], index, array);\n }\n return result;\n }\n\n /**\n * Appends the elements of `values` to `array`.\n *\n * @private\n * @param {Array} array The array to modify.\n * @param {Array} values The values to append.\n * @returns {Array} Returns `array`.\n */\n function arrayPush(array, values) {\n var index = -1,\n length = values.length,\n offset = array.length;\n\n while (++index < length) {\n array[offset + index] = values[index];\n }\n return array;\n }\n\n /**\n * A specialized version of `_.reduce` for arrays without support for\n * iteratee shorthands.\n *\n * @private\n * @param {Array} [array] The array to iterate over.\n * @param {Function} iteratee The function invoked per iteration.\n * @param {*} [accumulator] The initial value.\n * @param {boolean} [initAccum] Specify using the first element of `array` as\n * the initial value.\n * @returns {*} Returns the accumulated value.\n */\n function arrayReduce(array, iteratee, accumulator, initAccum) {\n var index = -1,\n length = array == null ? 0 : array.length;\n\n if (initAccum && length) {\n accumulator = array[++index];\n }\n while (++index < length) {\n accumulator = iteratee(accumulator, array[index], index, array);\n }\n return accumulator;\n }\n\n /**\n * A specialized version of `_.reduceRight` for arrays without support for\n * iteratee shorthands.\n *\n * @private\n * @param {Array} [array] The array to iterate over.\n * @param {Function} iteratee The function invoked per iteration.\n * @param {*} [accumulator] The initial value.\n * @param {boolean} [initAccum] Specify using the last element of `array` as\n * the initial value.\n * @returns {*} Returns the accumulated value.\n */\n function arrayReduceRight(array, iteratee, accumulator, initAccum) {\n var length = array == null ? 0 : array.length;\n if (initAccum && length) {\n accumulator = array[--length];\n }\n while (length--) {\n accumulator = iteratee(accumulator, array[length], length, array);\n }\n return accumulator;\n }\n\n /**\n * A specialized version of `_.some` for arrays without support for iteratee\n * shorthands.\n *\n * @private\n * @param {Array} [array] The array to iterate over.\n * @param {Function} predicate The function invoked per iteration.\n * @returns {boolean} Returns `true` if any element passes the predicate check,\n * else `false`.\n */\n function arraySome(array, predicate) {\n var index = -1,\n length = array == null ? 0 : array.length;\n\n while (++index < length) {\n if (predicate(array[index], index, array)) {\n return true;\n }\n }\n return false;\n }\n\n /**\n * Gets the size of an ASCII `string`.\n *\n * @private\n * @param {string} string The string inspect.\n * @returns {number} Returns the string size.\n */\n var asciiSize = baseProperty('length');\n\n /**\n * Converts an ASCII `string` to an array.\n *\n * @private\n * @param {string} string The string to convert.\n * @returns {Array} Returns the converted array.\n */\n function asciiToArray(string) {\n return string.split('');\n }\n\n /**\n * Splits an ASCII `string` into an array of its words.\n *\n * @private\n * @param {string} The string to inspect.\n * @returns {Array} Returns the words of `string`.\n */\n function asciiWords(string) {\n return string.match(reAsciiWord) || [];\n }\n\n /**\n * The base implementation of methods like `_.findKey` and `_.findLastKey`,\n * without support for iteratee shorthands, which iterates over `collection`\n * using `eachFunc`.\n *\n * @private\n * @param {Array|Object} collection The collection to inspect.\n * @param {Function} predicate The function invoked per iteration.\n * @param {Function} eachFunc The function to iterate over `collection`.\n * @returns {*} Returns the found element or its key, else `undefined`.\n */\n function baseFindKey(collection, predicate, eachFunc) {\n var result;\n eachFunc(collection, function(value, key, collection) {\n if (predicate(value, key, collection)) {\n result = key;\n return false;\n }\n });\n return result;\n }\n\n /**\n * The base implementation of `_.findIndex` and `_.findLastIndex` without\n * support for iteratee shorthands.\n *\n * @private\n * @param {Array} array The array to inspect.\n * @param {Function} predicate The function invoked per iteration.\n * @param {number} fromIndex The index to search from.\n * @param {boolean} [fromRight] Specify iterating from right to left.\n * @returns {number} Returns the index of the matched value, else `-1`.\n */\n function baseFindIndex(array, predicate, fromIndex, fromRight) {\n var length = array.length,\n index = fromIndex + (fromRight ? 1 : -1);\n\n while ((fromRight ? index-- : ++index < length)) {\n if (predicate(array[index], index, array)) {\n return index;\n }\n }\n return -1;\n }\n\n /**\n * The base implementation of `_.indexOf` without `fromIndex` bounds checks.\n *\n * @private\n * @param {Array} array The array to inspect.\n * @param {*} value The value to search for.\n * @param {number} fromIndex The index to search from.\n * @returns {number} Returns the index of the matched value, else `-1`.\n */\n function baseIndexOf(array, value, fromIndex) {\n return value === value\n ? strictIndexOf(array, value, fromIndex)\n : baseFindIndex(array, baseIsNaN, fromIndex);\n }\n\n /**\n * This function is like `baseIndexOf` except that it accepts a comparator.\n *\n * @private\n * @param {Array} array The array to inspect.\n * @param {*} value The value to search for.\n * @param {number} fromIndex The index to search from.\n * @param {Function} comparator The comparator invoked per element.\n * @returns {number} Returns the index of the matched value, else `-1`.\n */\n function baseIndexOfWith(array, value, fromIndex, comparator) {\n var index = fromIndex - 1,\n length = array.length;\n\n while (++index < length) {\n if (comparator(array[index], value)) {\n return index;\n }\n }\n return -1;\n }\n\n /**\n * The base implementation of `_.isNaN` without support for number objects.\n *\n * @private\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is `NaN`, else `false`.\n */\n function baseIsNaN(value) {\n return value !== value;\n }\n\n /**\n * The base implementation of `_.mean` and `_.meanBy` without support for\n * iteratee shorthands.\n *\n * @private\n * @param {Array} array The array to iterate over.\n * @param {Function} iteratee The function invoked per iteration.\n * @returns {number} Returns the mean.\n */\n function baseMean(array, iteratee) {\n var length = array == null ? 0 : array.length;\n return length ? (baseSum(array, iteratee) / length) : NAN;\n }\n\n /**\n * The base implementation of `_.property` without support for deep paths.\n *\n * @private\n * @param {string} key The key of the property to get.\n * @returns {Function} Returns the new accessor function.\n */\n function baseProperty(key) {\n return function(object) {\n return object == null ? undefined : object[key];\n };\n }\n\n /**\n * The base implementation of `_.propertyOf` without support for deep paths.\n *\n * @private\n * @param {Object} object The object to query.\n * @returns {Function} Returns the new accessor function.\n */\n function basePropertyOf(object) {\n return function(key) {\n return object == null ? undefined : object[key];\n };\n }\n\n /**\n * The base implementation of `_.reduce` and `_.reduceRight`, without support\n * for iteratee shorthands, which iterates over `collection` using `eachFunc`.\n *\n * @private\n * @param {Array|Object} collection The collection to iterate over.\n * @param {Function} iteratee The function invoked per iteration.\n * @param {*} accumulator The initial value.\n * @param {boolean} initAccum Specify using the first or last element of\n * `collection` as the initial value.\n * @param {Function} eachFunc The function to iterate over `collection`.\n * @returns {*} Returns the accumulated value.\n */\n function baseReduce(collection, iteratee, accumulator, initAccum, eachFunc) {\n eachFunc(collection, function(value, index, collection) {\n accumulator = initAccum\n ? (initAccum = false, value)\n : iteratee(accumulator, value, index, collection);\n });\n return accumulator;\n }\n\n /**\n * The base implementation of `_.sortBy` which uses `comparer` to define the\n * sort order of `array` and replaces criteria objects with their corresponding\n * values.\n *\n * @private\n * @param {Array} array The array to sort.\n * @param {Function} comparer The function to define sort order.\n * @returns {Array} Returns `array`.\n */\n function baseSortBy(array, comparer) {\n var length = array.length;\n\n array.sort(comparer);\n while (length--) {\n array[length] = array[length].value;\n }\n return array;\n }\n\n /**\n * The base implementation of `_.sum` and `_.sumBy` without support for\n * iteratee shorthands.\n *\n * @private\n * @param {Array} array The array to iterate over.\n * @param {Function} iteratee The function invoked per iteration.\n * @returns {number} Returns the sum.\n */\n function baseSum(array, iteratee) {\n var result,\n index = -1,\n length = array.length;\n\n while (++index < length) {\n var current = iteratee(array[index]);\n if (current !== undefined) {\n result = result === undefined ? current : (result + current);\n }\n }\n return result;\n }\n\n /**\n * The base implementation of `_.times` without support for iteratee shorthands\n * or max array length checks.\n *\n * @private\n * @param {number} n The number of times to invoke `iteratee`.\n * @param {Function} iteratee The function invoked per iteration.\n * @returns {Array} Returns the array of results.\n */\n function baseTimes(n, iteratee) {\n var index = -1,\n result = Array(n);\n\n while (++index < n) {\n result[index] = iteratee(index);\n }\n return result;\n }\n\n /**\n * The base implementation of `_.toPairs` and `_.toPairsIn` which creates an array\n * of key-value pairs for `object` corresponding to the property names of `props`.\n *\n * @private\n * @param {Object} object The object to query.\n * @param {Array} props The property names to get values for.\n * @returns {Object} Returns the key-value pairs.\n */\n function baseToPairs(object, props) {\n return arrayMap(props, function(key) {\n return [key, object[key]];\n });\n }\n\n /**\n * The base implementation of `_.trim`.\n *\n * @private\n * @param {string} string The string to trim.\n * @returns {string} Returns the trimmed string.\n */\n function baseTrim(string) {\n return string\n ? string.slice(0, trimmedEndIndex(string) + 1).replace(reTrimStart, '')\n : string;\n }\n\n /**\n * The base implementation of `_.unary` without support for storing metadata.\n *\n * @private\n * @param {Function} func The function to cap arguments for.\n * @returns {Function} Returns the new capped function.\n */\n function baseUnary(func) {\n return function(value) {\n return func(value);\n };\n }\n\n /**\n * The base implementation of `_.values` and `_.valuesIn` which creates an\n * array of `object` property values corresponding to the property names\n * of `props`.\n *\n * @private\n * @param {Object} object The object to query.\n * @param {Array} props The property names to get values for.\n * @returns {Object} Returns the array of property values.\n */\n function baseValues(object, props) {\n return arrayMap(props, function(key) {\n return object[key];\n });\n }\n\n /**\n * Checks if a `cache` value for `key` exists.\n *\n * @private\n * @param {Object} cache The cache to query.\n * @param {string} key The key of the entry to check.\n * @returns {boolean} Returns `true` if an entry for `key` exists, else `false`.\n */\n function cacheHas(cache, key) {\n return cache.has(key);\n }\n\n /**\n * Used by `_.trim` and `_.trimStart` to get the index of the first string symbol\n * that is not found in the character symbols.\n *\n * @private\n * @param {Array} strSymbols The string symbols to inspect.\n * @param {Array} chrSymbols The character symbols to find.\n * @returns {number} Returns the index of the first unmatched string symbol.\n */\n function charsStartIndex(strSymbols, chrSymbols) {\n var index = -1,\n length = strSymbols.length;\n\n while (++index < length && baseIndexOf(chrSymbols, strSymbols[index], 0) > -1) {}\n return index;\n }\n\n /**\n * Used by `_.trim` and `_.trimEnd` to get the index of the last string symbol\n * that is not found in the character symbols.\n *\n * @private\n * @param {Array} strSymbols The string symbols to inspect.\n * @param {Array} chrSymbols The character symbols to find.\n * @returns {number} Returns the index of the last unmatched string symbol.\n */\n function charsEndIndex(strSymbols, chrSymbols) {\n var index = strSymbols.length;\n\n while (index-- && baseIndexOf(chrSymbols, strSymbols[index], 0) > -1) {}\n return index;\n }\n\n /**\n * Gets the number of `placeholder` occurrences in `array`.\n *\n * @private\n * @param {Array} array The array to inspect.\n * @param {*} placeholder The placeholder to search for.\n * @returns {number} Returns the placeholder count.\n */\n function countHolders(array, placeholder) {\n var length = array.length,\n result = 0;\n\n while (length--) {\n if (array[length] === placeholder) {\n ++result;\n }\n }\n return result;\n }\n\n /**\n * Used by `_.deburr` to convert Latin-1 Supplement and Latin Extended-A\n * letters to basic Latin letters.\n *\n * @private\n * @param {string} letter The matched letter to deburr.\n * @returns {string} Returns the deburred letter.\n */\n var deburrLetter = basePropertyOf(deburredLetters);\n\n /**\n * Used by `_.escape` to convert characters to HTML entities.\n *\n * @private\n * @param {string} chr The matched character to escape.\n * @returns {string} Returns the escaped character.\n */\n var escapeHtmlChar = basePropertyOf(htmlEscapes);\n\n /**\n * Used by `_.template` to escape characters for inclusion in compiled string literals.\n *\n * @private\n * @param {string} chr The matched character to escape.\n * @returns {string} Returns the escaped character.\n */\n function escapeStringChar(chr) {\n return '\\\\' + stringEscapes[chr];\n }\n\n /**\n * Gets the value at `key` of `object`.\n *\n * @private\n * @param {Object} [object] The object to query.\n * @param {string} key The key of the property to get.\n * @returns {*} Returns the property value.\n */\n function getValue(object, key) {\n return object == null ? undefined : object[key];\n }\n\n /**\n * Checks if `string` contains Unicode symbols.\n *\n * @private\n * @param {string} string The string to inspect.\n * @returns {boolean} Returns `true` if a symbol is found, else `false`.\n */\n function hasUnicode(string) {\n return reHasUnicode.test(string);\n }\n\n /**\n * Checks if `string` contains a word composed of Unicode symbols.\n *\n * @private\n * @param {string} string The string to inspect.\n * @returns {boolean} Returns `true` if a word is found, else `false`.\n */\n function hasUnicodeWord(string) {\n return reHasUnicodeWord.test(string);\n }\n\n /**\n * Converts `iterator` to an array.\n *\n * @private\n * @param {Object} iterator The iterator to convert.\n * @returns {Array} Returns the converted array.\n */\n function iteratorToArray(iterator) {\n var data,\n result = [];\n\n while (!(data = iterator.next()).done) {\n result.push(data.value);\n }\n return result;\n }\n\n /**\n * Converts `map` to its key-value pairs.\n *\n * @private\n * @param {Object} map The map to convert.\n * @returns {Array} Returns the key-value pairs.\n */\n function mapToArray(map) {\n var index = -1,\n result = Array(map.size);\n\n map.forEach(function(value, key) {\n result[++index] = [key, value];\n });\n return result;\n }\n\n /**\n * Creates a unary function that invokes `func` with its argument transformed.\n *\n * @private\n * @param {Function} func The function to wrap.\n * @param {Function} transform The argument transform.\n * @returns {Function} Returns the new function.\n */\n function overArg(func, transform) {\n return function(arg) {\n return func(transform(arg));\n };\n }\n\n /**\n * Replaces all `placeholder` elements in `array` with an internal placeholder\n * and returns an array of their indexes.\n *\n * @private\n * @param {Array} array The array to modify.\n * @param {*} placeholder The placeholder to replace.\n * @returns {Array} Returns the new array of placeholder indexes.\n */\n function replaceHolders(array, placeholder) {\n var index = -1,\n length = array.length,\n resIndex = 0,\n result = [];\n\n while (++index < length) {\n var value = array[index];\n if (value === placeholder || value === PLACEHOLDER) {\n array[index] = PLACEHOLDER;\n result[resIndex++] = index;\n }\n }\n return result;\n }\n\n /**\n * Converts `set` to an array of its values.\n *\n * @private\n * @param {Object} set The set to convert.\n * @returns {Array} Returns the values.\n */\n function setToArray(set) {\n var index = -1,\n result = Array(set.size);\n\n set.forEach(function(value) {\n result[++index] = value;\n });\n return result;\n }\n\n /**\n * Converts `set` to its value-value pairs.\n *\n * @private\n * @param {Object} set The set to convert.\n * @returns {Array} Returns the value-value pairs.\n */\n function setToPairs(set) {\n var index = -1,\n result = Array(set.size);\n\n set.forEach(function(value) {\n result[++index] = [value, value];\n });\n return result;\n }\n\n /**\n * A specialized version of `_.indexOf` which performs strict equality\n * comparisons of values, i.e. `===`.\n *\n * @private\n * @param {Array} array The array to inspect.\n * @param {*} value The value to search for.\n * @param {number} fromIndex The index to search from.\n * @returns {number} Returns the index of the matched value, else `-1`.\n */\n function strictIndexOf(array, value, fromIndex) {\n var index = fromIndex - 1,\n length = array.length;\n\n while (++index < length) {\n if (array[index] === value) {\n return index;\n }\n }\n return -1;\n }\n\n /**\n * A specialized version of `_.lastIndexOf` which performs strict equality\n * comparisons of values, i.e. `===`.\n *\n * @private\n * @param {Array} array The array to inspect.\n * @param {*} value The value to search for.\n * @param {number} fromIndex The index to search from.\n * @returns {number} Returns the index of the matched value, else `-1`.\n */\n function strictLastIndexOf(array, value, fromIndex) {\n var index = fromIndex + 1;\n while (index--) {\n if (array[index] === value) {\n return index;\n }\n }\n return index;\n }\n\n /**\n * Gets the number of symbols in `string`.\n *\n * @private\n * @param {string} string The string to inspect.\n * @returns {number} Returns the string size.\n */\n function stringSize(string) {\n return hasUnicode(string)\n ? unicodeSize(string)\n : asciiSize(string);\n }\n\n /**\n * Converts `string` to an array.\n *\n * @private\n * @param {string} string The string to convert.\n * @returns {Array} Returns the converted array.\n */\n function stringToArray(string) {\n return hasUnicode(string)\n ? unicodeToArray(string)\n : asciiToArray(string);\n }\n\n /**\n * Used by `_.trim` and `_.trimEnd` to get the index of the last non-whitespace\n * character of `string`.\n *\n * @private\n * @param {string} string The string to inspect.\n * @returns {number} Returns the index of the last non-whitespace character.\n */\n function trimmedEndIndex(string) {\n var index = string.length;\n\n while (index-- && reWhitespace.test(string.charAt(index))) {}\n return index;\n }\n\n /**\n * Used by `_.unescape` to convert HTML entities to characters.\n *\n * @private\n * @param {string} chr The matched character to unescape.\n * @returns {string} Returns the unescaped character.\n */\n var unescapeHtmlChar = basePropertyOf(htmlUnescapes);\n\n /**\n * Gets the size of a Unicode `string`.\n *\n * @private\n * @param {string} string The string inspect.\n * @returns {number} Returns the string size.\n */\n function unicodeSize(string) {\n var result = reUnicode.lastIndex = 0;\n while (reUnicode.test(string)) {\n ++result;\n }\n return result;\n }\n\n /**\n * Converts a Unicode `string` to an array.\n *\n * @private\n * @param {string} string The string to convert.\n * @returns {Array} Returns the converted array.\n */\n function unicodeToArray(string) {\n return string.match(reUnicode) || [];\n }\n\n /**\n * Splits a Unicode `string` into an array of its words.\n *\n * @private\n * @param {string} The string to inspect.\n * @returns {Array} Returns the words of `string`.\n */\n function unicodeWords(string) {\n return string.match(reUnicodeWord) || [];\n }\n\n /*--------------------------------------------------------------------------*/\n\n /**\n * Create a new pristine `lodash` function using the `context` object.\n *\n * @static\n * @memberOf _\n * @since 1.1.0\n * @category Util\n * @param {Object} [context=root] The context object.\n * @returns {Function} Returns a new `lodash` function.\n * @example\n *\n * _.mixin({ 'foo': _.constant('foo') });\n *\n * var lodash = _.runInContext();\n * lodash.mixin({ 'bar': lodash.constant('bar') });\n *\n * _.isFunction(_.foo);\n * // => true\n * _.isFunction(_.bar);\n * // => false\n *\n * lodash.isFunction(lodash.foo);\n * // => false\n * lodash.isFunction(lodash.bar);\n * // => true\n *\n * // Create a suped-up `defer` in Node.js.\n * var defer = _.runInContext({ 'setTimeout': setImmediate }).defer;\n */\n var runInContext = (function runInContext(context) {\n context = context == null ? root : _.defaults(root.Object(), context, _.pick(root, contextProps));\n\n /** Built-in constructor references. */\n var Array = context.Array,\n Date = context.Date,\n Error = context.Error,\n Function = context.Function,\n Math = context.Math,\n Object = context.Object,\n RegExp = context.RegExp,\n String = context.String,\n TypeError = context.TypeError;\n\n /** Used for built-in method references. */\n var arrayProto = Array.prototype,\n funcProto = Function.prototype,\n objectProto = Object.prototype;\n\n /** Used to detect overreaching core-js shims. */\n var coreJsData = context['__core-js_shared__'];\n\n /** Used to resolve the decompiled source of functions. */\n var funcToString = funcProto.toString;\n\n /** Used to check objects for own properties. */\n var hasOwnProperty = objectProto.hasOwnProperty;\n\n /** Used to generate unique IDs. */\n var idCounter = 0;\n\n /** Used to detect methods masquerading as native. */\n var maskSrcKey = (function() {\n var uid = /[^.]+$/.exec(coreJsData && coreJsData.keys && coreJsData.keys.IE_PROTO || '');\n return uid ? ('Symbol(src)_1.' + uid) : '';\n }());\n\n /**\n * Used to resolve the\n * [`toStringTag`](http://ecma-international.org/ecma-262/7.0/#sec-object.prototype.tostring)\n * of values.\n */\n var nativeObjectToString = objectProto.toString;\n\n /** Used to infer the `Object` constructor. */\n var objectCtorString = funcToString.call(Object);\n\n /** Used to restore the original `_` reference in `_.noConflict`. */\n var oldDash = root._;\n\n /** Used to detect if a method is native. */\n var reIsNative = RegExp('^' +\n funcToString.call(hasOwnProperty).replace(reRegExpChar, '\\\\$&')\n .replace(/hasOwnProperty|(function).*?(?=\\\\\\()| for .+?(?=\\\\\\])/g, '$1.*?') + '$'\n );\n\n /** Built-in value references. */\n var Buffer = moduleExports ? context.Buffer : undefined,\n Symbol = context.Symbol,\n Uint8Array = context.Uint8Array,\n allocUnsafe = Buffer ? Buffer.allocUnsafe : undefined,\n getPrototype = overArg(Object.getPrototypeOf, Object),\n objectCreate = Object.create,\n propertyIsEnumerable = objectProto.propertyIsEnumerable,\n splice = arrayProto.splice,\n spreadableSymbol = Symbol ? Symbol.isConcatSpreadable : undefined,\n symIterator = Symbol ? Symbol.iterator : undefined,\n symToStringTag = Symbol ? Symbol.toStringTag : undefined;\n\n var defineProperty = (function() {\n try {\n var func = getNative(Object, 'defineProperty');\n func({}, '', {});\n return func;\n } catch (e) {}\n }());\n\n /** Mocked built-ins. */\n var ctxClearTimeout = context.clearTimeout !== root.clearTimeout && context.clearTimeout,\n ctxNow = Date && Date.now !== root.Date.now && Date.now,\n ctxSetTimeout = context.setTimeout !== root.setTimeout && context.setTimeout;\n\n /* Built-in method references for those with the same name as other `lodash` methods. */\n var nativeCeil = Math.ceil,\n nativeFloor = Math.floor,\n nativeGetSymbols = Object.getOwnPropertySymbols,\n nativeIsBuffer = Buffer ? Buffer.isBuffer : undefined,\n nativeIsFinite = context.isFinite,\n nativeJoin = arrayProto.join,\n nativeKeys = overArg(Object.keys, Object),\n nativeMax = Math.max,\n nativeMin = Math.min,\n nativeNow = Date.now,\n nativeParseInt = context.parseInt,\n nativeRandom = Math.random,\n nativeReverse = arrayProto.reverse;\n\n /* Built-in method references that are verified to be native. */\n var DataView = getNative(context, 'DataView'),\n Map = getNative(context, 'Map'),\n Promise = getNative(context, 'Promise'),\n Set = getNative(context, 'Set'),\n WeakMap = getNative(context, 'WeakMap'),\n nativeCreate = getNative(Object, 'create');\n\n /** Used to store function metadata. */\n var metaMap = WeakMap && new WeakMap;\n\n /** Used to lookup unminified function names. */\n var realNames = {};\n\n /** Used to detect maps, sets, and weakmaps. */\n var dataViewCtorString = toSource(DataView),\n mapCtorString = toSource(Map),\n promiseCtorString = toSource(Promise),\n setCtorString = toSource(Set),\n weakMapCtorString = toSource(WeakMap);\n\n /** Used to convert symbols to primitives and strings. */\n var symbolProto = Symbol ? Symbol.prototype : undefined,\n symbolValueOf = symbolProto ? symbolProto.valueOf : undefined,\n symbolToString = symbolProto ? symbolProto.toString : undefined;\n\n /*------------------------------------------------------------------------*/\n\n /**\n * Creates a `lodash` object which wraps `value` to enable implicit method\n * chain sequences. Methods that operate on and return arrays, collections,\n * and functions can be chained together. Methods that retrieve a single value\n * or may return a primitive value will automatically end the chain sequence\n * and return the unwrapped value. Otherwise, the value must be unwrapped\n * with `_#value`.\n *\n * Explicit chain sequences, which must be unwrapped with `_#value`, may be\n * enabled using `_.chain`.\n *\n * The execution of chained methods is lazy, that is, it's deferred until\n * `_#value` is implicitly or explicitly called.\n *\n * Lazy evaluation allows several methods to support shortcut fusion.\n * Shortcut fusion is an optimization to merge iteratee calls; this avoids\n * the creation of intermediate arrays and can greatly reduce the number of\n * iteratee executions. Sections of a chain sequence qualify for shortcut\n * fusion if the section is applied to an array and iteratees accept only\n * one argument. The heuristic for whether a section qualifies for shortcut\n * fusion is subject to change.\n *\n * Chaining is supported in custom builds as long as the `_#value` method is\n * directly or indirectly included in the build.\n *\n * In addition to lodash methods, wrappers have `Array` and `String` methods.\n *\n * The wrapper `Array` methods are:\n * `concat`, `join`, `pop`, `push`, `shift`, `sort`, `splice`, and `unshift`\n *\n * The wrapper `String` methods are:\n * `replace` and `split`\n *\n * The wrapper methods that support shortcut fusion are:\n * `at`, `compact`, `drop`, `dropRight`, `dropWhile`, `filter`, `find`,\n * `findLast`, `head`, `initial`, `last`, `map`, `reject`, `reverse`, `slice`,\n * `tail`, `take`, `takeRight`, `takeRightWhile`, `takeWhile`, and `toArray`\n *\n * The chainable wrapper methods are:\n * `after`, `ary`, `assign`, `assignIn`, `assignInWith`, `assignWith`, `at`,\n * `before`, `bind`, `bindAll`, `bindKey`, `castArray`, `chain`, `chunk`,\n * `commit`, `compact`, `concat`, `conforms`, `constant`, `countBy`, `create`,\n * `curry`, `debounce`, `defaults`, `defaultsDeep`, `defer`, `delay`,\n * `difference`, `differenceBy`, `differenceWith`, `drop`, `dropRight`,\n * `dropRightWhile`, `dropWhile`, `extend`, `extendWith`, `fill`, `filter`,\n * `flatMap`, `flatMapDeep`, `flatMapDepth`, `flatten`, `flattenDeep`,\n * `flattenDepth`, `flip`, `flow`, `flowRight`, `fromPairs`, `functions`,\n * `functionsIn`, `groupBy`, `initial`, `intersection`, `intersectionBy`,\n * `intersectionWith`, `invert`, `invertBy`, `invokeMap`, `iteratee`, `keyBy`,\n * `keys`, `keysIn`, `map`, `mapKeys`, `mapValues`, `matches`, `matchesProperty`,\n * `memoize`, `merge`, `mergeWith`, `method`, `methodOf`, `mixin`, `negate`,\n * `nthArg`, `omit`, `omitBy`, `once`, `orderBy`, `over`, `overArgs`,\n * `overEvery`, `overSome`, `partial`, `partialRight`, `partition`, `pick`,\n * `pickBy`, `plant`, `property`, `propertyOf`, `pull`, `pullAll`, `pullAllBy`,\n * `pullAllWith`, `pullAt`, `push`, `range`, `rangeRight`, `rearg`, `reject`,\n * `remove`, `rest`, `reverse`, `sampleSize`, `set`, `setWith`, `shuffle`,\n * `slice`, `sort`, `sortBy`, `splice`, `spread`, `tail`, `take`, `takeRight`,\n * `takeRightWhile`, `takeWhile`, `tap`, `throttle`, `thru`, `toArray`,\n * `toPairs`, `toPairsIn`, `toPath`, `toPlainObject`, `transform`, `unary`,\n * `union`, `unionBy`, `unionWith`, `uniq`, `uniqBy`, `uniqWith`, `unset`,\n * `unshift`, `unzip`, `unzipWith`, `update`, `updateWith`, `values`,\n * `valuesIn`, `without`, `wrap`, `xor`, `xorBy`, `xorWith`, `zip`,\n * `zipObject`, `zipObjectDeep`, and `zipWith`\n *\n * The wrapper methods that are **not** chainable by default are:\n * `add`, `attempt`, `camelCase`, `capitalize`, `ceil`, `clamp`, `clone`,\n * `cloneDeep`, `cloneDeepWith`, `cloneWith`, `conformsTo`, `deburr`,\n * `defaultTo`, `divide`, `each`, `eachRight`, `endsWith`, `eq`, `escape`,\n * `escapeRegExp`, `every`, `find`, `findIndex`, `findKey`, `findLast`,\n * `findLastIndex`, `findLastKey`, `first`, `floor`, `forEach`, `forEachRight`,\n * `forIn`, `forInRight`, `forOwn`, `forOwnRight`, `get`, `gt`, `gte`, `has`,\n * `hasIn`, `head`, `identity`, `includes`, `indexOf`, `inRange`, `invoke`,\n * `isArguments`, `isArray`, `isArrayBuffer`, `isArrayLike`, `isArrayLikeObject`,\n * `isBoolean`, `isBuffer`, `isDate`, `isElement`, `isEmpty`, `isEqual`,\n * `isEqualWith`, `isError`, `isFinite`, `isFunction`, `isInteger`, `isLength`,\n * `isMap`, `isMatch`, `isMatchWith`, `isNaN`, `isNative`, `isNil`, `isNull`,\n * `isNumber`, `isObject`, `isObjectLike`, `isPlainObject`, `isRegExp`,\n * `isSafeInteger`, `isSet`, `isString`, `isUndefined`, `isTypedArray`,\n * `isWeakMap`, `isWeakSet`, `join`, `kebabCase`, `last`, `lastIndexOf`,\n * `lowerCase`, `lowerFirst`, `lt`, `lte`, `max`, `maxBy`, `mean`, `meanBy`,\n * `min`, `minBy`, `multiply`, `noConflict`, `noop`, `now`, `nth`, `pad`,\n * `padEnd`, `padStart`, `parseInt`, `pop`, `random`, `reduce`, `reduceRight`,\n * `repeat`, `result`, `round`, `runInContext`, `sample`, `shift`, `size`,\n * `snakeCase`, `some`, `sortedIndex`, `sortedIndexBy`, `sortedLastIndex`,\n * `sortedLastIndexBy`, `startCase`, `startsWith`, `stubArray`, `stubFalse`,\n * `stubObject`, `stubString`, `stubTrue`, `subtract`, `sum`, `sumBy`,\n * `template`, `times`, `toFinite`, `toInteger`, `toJSON`, `toLength`,\n * `toLower`, `toNumber`, `toSafeInteger`, `toString`, `toUpper`, `trim`,\n * `trimEnd`, `trimStart`, `truncate`, `unescape`, `uniqueId`, `upperCase`,\n * `upperFirst`, `value`, and `words`\n *\n * @name _\n * @constructor\n * @category Seq\n * @param {*} value The value to wrap in a `lodash` instance.\n * @returns {Object} Returns the new `lodash` wrapper instance.\n * @example\n *\n * function square(n) {\n * return n * n;\n * }\n *\n * var wrapped = _([1, 2, 3]);\n *\n * // Returns an unwrapped value.\n * wrapped.reduce(_.add);\n * // => 6\n *\n * // Returns a wrapped value.\n * var squares = wrapped.map(square);\n *\n * _.isArray(squares);\n * // => false\n *\n * _.isArray(squares.value());\n * // => true\n */\n function lodash(value) {\n if (isObjectLike(value) && !isArray(value) && !(value instanceof LazyWrapper)) {\n if (value instanceof LodashWrapper) {\n return value;\n }\n if (hasOwnProperty.call(value, '__wrapped__')) {\n return wrapperClone(value);\n }\n }\n return new LodashWrapper(value);\n }\n\n /**\n * The base implementation of `_.create` without support for assigning\n * properties to the created object.\n *\n * @private\n * @param {Object} proto The object to inherit from.\n * @returns {Object} Returns the new object.\n */\n var baseCreate = (function() {\n function object() {}\n return function(proto) {\n if (!isObject(proto)) {\n return {};\n }\n if (objectCreate) {\n return objectCreate(proto);\n }\n object.prototype = proto;\n var result = new object;\n object.prototype = undefined;\n return result;\n };\n }());\n\n /**\n * The function whose prototype chain sequence wrappers inherit from.\n *\n * @private\n */\n function baseLodash() {\n // No operation performed.\n }\n\n /**\n * The base constructor for creating `lodash` wrapper objects.\n *\n * @private\n * @param {*} value The value to wrap.\n * @param {boolean} [chainAll] Enable explicit method chain sequences.\n */\n function LodashWrapper(value, chainAll) {\n this.__wrapped__ = value;\n this.__actions__ = [];\n this.__chain__ = !!chainAll;\n this.__index__ = 0;\n this.__values__ = undefined;\n }\n\n /**\n * By default, the template delimiters used by lodash are like those in\n * embedded Ruby (ERB) as well as ES2015 template strings. Change the\n * following template settings to use alternative delimiters.\n *\n * @static\n * @memberOf _\n * @type {Object}\n */\n lodash.templateSettings = {\n\n /**\n * Used to detect `data` property values to be HTML-escaped.\n *\n * @memberOf _.templateSettings\n * @type {RegExp}\n */\n 'escape': reEscape,\n\n /**\n * Used to detect code to be evaluated.\n *\n * @memberOf _.templateSettings\n * @type {RegExp}\n */\n 'evaluate': reEvaluate,\n\n /**\n * Used to detect `data` property values to inject.\n *\n * @memberOf _.templateSettings\n * @type {RegExp}\n */\n 'interpolate': reInterpolate,\n\n /**\n * Used to reference the data object in the template text.\n *\n * @memberOf _.templateSettings\n * @type {string}\n */\n 'variable': '',\n\n /**\n * Used to import variables into the compiled template.\n *\n * @memberOf _.templateSettings\n * @type {Object}\n */\n 'imports': {\n\n /**\n * A reference to the `lodash` function.\n *\n * @memberOf _.templateSettings.imports\n * @type {Function}\n */\n '_': lodash\n }\n };\n\n // Ensure wrappers are instances of `baseLodash`.\n lodash.prototype = baseLodash.prototype;\n lodash.prototype.constructor = lodash;\n\n LodashWrapper.prototype = baseCreate(baseLodash.prototype);\n LodashWrapper.prototype.constructor = LodashWrapper;\n\n /*------------------------------------------------------------------------*/\n\n /**\n * Creates a lazy wrapper object which wraps `value` to enable lazy evaluation.\n *\n * @private\n * @constructor\n * @param {*} value The value to wrap.\n */\n function LazyWrapper(value) {\n this.__wrapped__ = value;\n this.__actions__ = [];\n this.__dir__ = 1;\n this.__filtered__ = false;\n this.__iteratees__ = [];\n this.__takeCount__ = MAX_ARRAY_LENGTH;\n this.__views__ = [];\n }\n\n /**\n * Creates a clone of the lazy wrapper object.\n *\n * @private\n * @name clone\n * @memberOf LazyWrapper\n * @returns {Object} Returns the cloned `LazyWrapper` object.\n */\n function lazyClone() {\n var result = new LazyWrapper(this.__wrapped__);\n result.__actions__ = copyArray(this.__actions__);\n result.__dir__ = this.__dir__;\n result.__filtered__ = this.__filtered__;\n result.__iteratees__ = copyArray(this.__iteratees__);\n result.__takeCount__ = this.__takeCount__;\n result.__views__ = copyArray(this.__views__);\n return result;\n }\n\n /**\n * Reverses the direction of lazy iteration.\n *\n * @private\n * @name reverse\n * @memberOf LazyWrapper\n * @returns {Object} Returns the new reversed `LazyWrapper` object.\n */\n function lazyReverse() {\n if (this.__filtered__) {\n var result = new LazyWrapper(this);\n result.__dir__ = -1;\n result.__filtered__ = true;\n } else {\n result = this.clone();\n result.__dir__ *= -1;\n }\n return result;\n }\n\n /**\n * Extracts the unwrapped value from its lazy wrapper.\n *\n * @private\n * @name value\n * @memberOf LazyWrapper\n * @returns {*} Returns the unwrapped value.\n */\n function lazyValue() {\n var array = this.__wrapped__.value(),\n dir = this.__dir__,\n isArr = isArray(array),\n isRight = dir < 0,\n arrLength = isArr ? array.length : 0,\n view = getView(0, arrLength, this.__views__),\n start = view.start,\n end = view.end,\n length = end - start,\n index = isRight ? end : (start - 1),\n iteratees = this.__iteratees__,\n iterLength = iteratees.length,\n resIndex = 0,\n takeCount = nativeMin(length, this.__takeCount__);\n\n if (!isArr || (!isRight && arrLength == length && takeCount == length)) {\n return baseWrapperValue(array, this.__actions__);\n }\n var result = [];\n\n outer:\n while (length-- && resIndex < takeCount) {\n index += dir;\n\n var iterIndex = -1,\n value = array[index];\n\n while (++iterIndex < iterLength) {\n var data = iteratees[iterIndex],\n iteratee = data.iteratee,\n type = data.type,\n computed = iteratee(value);\n\n if (type == LAZY_MAP_FLAG) {\n value = computed;\n } else if (!computed) {\n if (type == LAZY_FILTER_FLAG) {\n continue outer;\n } else {\n break outer;\n }\n }\n }\n result[resIndex++] = value;\n }\n return result;\n }\n\n // Ensure `LazyWrapper` is an instance of `baseLodash`.\n LazyWrapper.prototype = baseCreate(baseLodash.prototype);\n LazyWrapper.prototype.constructor = LazyWrapper;\n\n /*------------------------------------------------------------------------*/\n\n /**\n * Creates a hash object.\n *\n * @private\n * @constructor\n * @param {Array} [entries] The key-value pairs to cache.\n */\n function Hash(entries) {\n var index = -1,\n length = entries == null ? 0 : entries.length;\n\n this.clear();\n while (++index < length) {\n var entry = entries[index];\n this.set(entry[0], entry[1]);\n }\n }\n\n /**\n * Removes all key-value entries from the hash.\n *\n * @private\n * @name clear\n * @memberOf Hash\n */\n function hashClear() {\n this.__data__ = nativeCreate ? nativeCreate(null) : {};\n this.size = 0;\n }\n\n /**\n * Removes `key` and its value from the hash.\n *\n * @private\n * @name delete\n * @memberOf Hash\n * @param {Object} hash The hash to modify.\n * @param {string} key The key of the value to remove.\n * @returns {boolean} Returns `true` if the entry was removed, else `false`.\n */\n function hashDelete(key) {\n var result = this.has(key) && delete this.__data__[key];\n this.size -= result ? 1 : 0;\n return result;\n }\n\n /**\n * Gets the hash value for `key`.\n *\n * @private\n * @name get\n * @memberOf Hash\n * @param {string} key The key of the value to get.\n * @returns {*} Returns the entry value.\n */\n function hashGet(key) {\n var data = this.__data__;\n if (nativeCreate) {\n var result = data[key];\n return result === HASH_UNDEFINED ? undefined : result;\n }\n return hasOwnProperty.call(data, key) ? data[key] : undefined;\n }\n\n /**\n * Checks if a hash value for `key` exists.\n *\n * @private\n * @name has\n * @memberOf Hash\n * @param {string} key The key of the entry to check.\n * @returns {boolean} Returns `true` if an entry for `key` exists, else `false`.\n */\n function hashHas(key) {\n var data = this.__data__;\n return nativeCreate ? (data[key] !== undefined) : hasOwnProperty.call(data, key);\n }\n\n /**\n * Sets the hash `key` to `value`.\n *\n * @private\n * @name set\n * @memberOf Hash\n * @param {string} key The key of the value to set.\n * @param {*} value The value to set.\n * @returns {Object} Returns the hash instance.\n */\n function hashSet(key, value) {\n var data = this.__data__;\n this.size += this.has(key) ? 0 : 1;\n data[key] = (nativeCreate && value === undefined) ? HASH_UNDEFINED : value;\n return this;\n }\n\n // Add methods to `Hash`.\n Hash.prototype.clear = hashClear;\n Hash.prototype['delete'] = hashDelete;\n Hash.prototype.get = hashGet;\n Hash.prototype.has = hashHas;\n Hash.prototype.set = hashSet;\n\n /*------------------------------------------------------------------------*/\n\n /**\n * Creates an list cache object.\n *\n * @private\n * @constructor\n * @param {Array} [entries] The key-value pairs to cache.\n */\n function ListCache(entries) {\n var index = -1,\n length = entries == null ? 0 : entries.length;\n\n this.clear();\n while (++index < length) {\n var entry = entries[index];\n this.set(entry[0], entry[1]);\n }\n }\n\n /**\n * Removes all key-value entries from the list cache.\n *\n * @private\n * @name clear\n * @memberOf ListCache\n */\n function listCacheClear() {\n this.__data__ = [];\n this.size = 0;\n }\n\n /**\n * Removes `key` and its value from the list cache.\n *\n * @private\n * @name delete\n * @memberOf ListCache\n * @param {string} key The key of the value to remove.\n * @returns {boolean} Returns `true` if the entry was removed, else `false`.\n */\n function listCacheDelete(key) {\n var data = this.__data__,\n index = assocIndexOf(data, key);\n\n if (index < 0) {\n return false;\n }\n var lastIndex = data.length - 1;\n if (index == lastIndex) {\n data.pop();\n } else {\n splice.call(data, index, 1);\n }\n --this.size;\n return true;\n }\n\n /**\n * Gets the list cache value for `key`.\n *\n * @private\n * @name get\n * @memberOf ListCache\n * @param {string} key The key of the value to get.\n * @returns {*} Returns the entry value.\n */\n function listCacheGet(key) {\n var data = this.__data__,\n index = assocIndexOf(data, key);\n\n return index < 0 ? undefined : data[index][1];\n }\n\n /**\n * Checks if a list cache value for `key` exists.\n *\n * @private\n * @name has\n * @memberOf ListCache\n * @param {string} key The key of the entry to check.\n * @returns {boolean} Returns `true` if an entry for `key` exists, else `false`.\n */\n function listCacheHas(key) {\n return assocIndexOf(this.__data__, key) > -1;\n }\n\n /**\n * Sets the list cache `key` to `value`.\n *\n * @private\n * @name set\n * @memberOf ListCache\n * @param {string} key The key of the value to set.\n * @param {*} value The value to set.\n * @returns {Object} Returns the list cache instance.\n */\n function listCacheSet(key, value) {\n var data = this.__data__,\n index = assocIndexOf(data, key);\n\n if (index < 0) {\n ++this.size;\n data.push([key, value]);\n } else {\n data[index][1] = value;\n }\n return this;\n }\n\n // Add methods to `ListCache`.\n ListCache.prototype.clear = listCacheClear;\n ListCache.prototype['delete'] = listCacheDelete;\n ListCache.prototype.get = listCacheGet;\n ListCache.prototype.has = listCacheHas;\n ListCache.prototype.set = listCacheSet;\n\n /*------------------------------------------------------------------------*/\n\n /**\n * Creates a map cache object to store key-value pairs.\n *\n * @private\n * @constructor\n * @param {Array} [entries] The key-value pairs to cache.\n */\n function MapCache(entries) {\n var index = -1,\n length = entries == null ? 0 : entries.length;\n\n this.clear();\n while (++index < length) {\n var entry = entries[index];\n this.set(entry[0], entry[1]);\n }\n }\n\n /**\n * Removes all key-value entries from the map.\n *\n * @private\n * @name clear\n * @memberOf MapCache\n */\n function mapCacheClear() {\n this.size = 0;\n this.__data__ = {\n 'hash': new Hash,\n 'map': new (Map || ListCache),\n 'string': new Hash\n };\n }\n\n /**\n * Removes `key` and its value from the map.\n *\n * @private\n * @name delete\n * @memberOf MapCache\n * @param {string} key The key of the value to remove.\n * @returns {boolean} Returns `true` if the entry was removed, else `false`.\n */\n function mapCacheDelete(key) {\n var result = getMapData(this, key)['delete'](key);\n this.size -= result ? 1 : 0;\n return result;\n }\n\n /**\n * Gets the map value for `key`.\n *\n * @private\n * @name get\n * @memberOf MapCache\n * @param {string} key The key of the value to get.\n * @returns {*} Returns the entry value.\n */\n function mapCacheGet(key) {\n return getMapData(this, key).get(key);\n }\n\n /**\n * Checks if a map value for `key` exists.\n *\n * @private\n * @name has\n * @memberOf MapCache\n * @param {string} key The key of the entry to check.\n * @returns {boolean} Returns `true` if an entry for `key` exists, else `false`.\n */\n function mapCacheHas(key) {\n return getMapData(this, key).has(key);\n }\n\n /**\n * Sets the map `key` to `value`.\n *\n * @private\n * @name set\n * @memberOf MapCache\n * @param {string} key The key of the value to set.\n * @param {*} value The value to set.\n * @returns {Object} Returns the map cache instance.\n */\n function mapCacheSet(key, value) {\n var data = getMapData(this, key),\n size = data.size;\n\n data.set(key, value);\n this.size += data.size == size ? 0 : 1;\n return this;\n }\n\n // Add methods to `MapCache`.\n MapCache.prototype.clear = mapCacheClear;\n MapCache.prototype['delete'] = mapCacheDelete;\n MapCache.prototype.get = mapCacheGet;\n MapCache.prototype.has = mapCacheHas;\n MapCache.prototype.set = mapCacheSet;\n\n /*------------------------------------------------------------------------*/\n\n /**\n *\n * Creates an array cache object to store unique values.\n *\n * @private\n * @constructor\n * @param {Array} [values] The values to cache.\n */\n function SetCache(values) {\n var index = -1,\n length = values == null ? 0 : values.length;\n\n this.__data__ = new MapCache;\n while (++index < length) {\n this.add(values[index]);\n }\n }\n\n /**\n * Adds `value` to the array cache.\n *\n * @private\n * @name add\n * @memberOf SetCache\n * @alias push\n * @param {*} value The value to cache.\n * @returns {Object} Returns the cache instance.\n */\n function setCacheAdd(value) {\n this.__data__.set(value, HASH_UNDEFINED);\n return this;\n }\n\n /**\n * Checks if `value` is in the array cache.\n *\n * @private\n * @name has\n * @memberOf SetCache\n * @param {*} value The value to search for.\n * @returns {number} Returns `true` if `value` is found, else `false`.\n */\n function setCacheHas(value) {\n return this.__data__.has(value);\n }\n\n // Add methods to `SetCache`.\n SetCache.prototype.add = SetCache.prototype.push = setCacheAdd;\n SetCache.prototype.has = setCacheHas;\n\n /*------------------------------------------------------------------------*/\n\n /**\n * Creates a stack cache object to store key-value pairs.\n *\n * @private\n * @constructor\n * @param {Array} [entries] The key-value pairs to cache.\n */\n function Stack(entries) {\n var data = this.__data__ = new ListCache(entries);\n this.size = data.size;\n }\n\n /**\n * Removes all key-value entries from the stack.\n *\n * @private\n * @name clear\n * @memberOf Stack\n */\n function stackClear() {\n this.__data__ = new ListCache;\n this.size = 0;\n }\n\n /**\n * Removes `key` and its value from the stack.\n *\n * @private\n * @name delete\n * @memberOf Stack\n * @param {string} key The key of the value to remove.\n * @returns {boolean} Returns `true` if the entry was removed, else `false`.\n */\n function stackDelete(key) {\n var data = this.__data__,\n result = data['delete'](key);\n\n this.size = data.size;\n return result;\n }\n\n /**\n * Gets the stack value for `key`.\n *\n * @private\n * @name get\n * @memberOf Stack\n * @param {string} key The key of the value to get.\n * @returns {*} Returns the entry value.\n */\n function stackGet(key) {\n return this.__data__.get(key);\n }\n\n /**\n * Checks if a stack value for `key` exists.\n *\n * @private\n * @name has\n * @memberOf Stack\n * @param {string} key The key of the entry to check.\n * @returns {boolean} Returns `true` if an entry for `key` exists, else `false`.\n */\n function stackHas(key) {\n return this.__data__.has(key);\n }\n\n /**\n * Sets the stack `key` to `value`.\n *\n * @private\n * @name set\n * @memberOf Stack\n * @param {string} key The key of the value to set.\n * @param {*} value The value to set.\n * @returns {Object} Returns the stack cache instance.\n */\n function stackSet(key, value) {\n var data = this.__data__;\n if (data instanceof ListCache) {\n var pairs = data.__data__;\n if (!Map || (pairs.length < LARGE_ARRAY_SIZE - 1)) {\n pairs.push([key, value]);\n this.size = ++data.size;\n return this;\n }\n data = this.__data__ = new MapCache(pairs);\n }\n data.set(key, value);\n this.size = data.size;\n return this;\n }\n\n // Add methods to `Stack`.\n Stack.prototype.clear = stackClear;\n Stack.prototype['delete'] = stackDelete;\n Stack.prototype.get = stackGet;\n Stack.prototype.has = stackHas;\n Stack.prototype.set = stackSet;\n\n /*------------------------------------------------------------------------*/\n\n /**\n * Creates an array of the enumerable property names of the array-like `value`.\n *\n * @private\n * @param {*} value The value to query.\n * @param {boolean} inherited Specify returning inherited property names.\n * @returns {Array} Returns the array of property names.\n */\n function arrayLikeKeys(value, inherited) {\n var isArr = isArray(value),\n isArg = !isArr && isArguments(value),\n isBuff = !isArr && !isArg && isBuffer(value),\n isType = !isArr && !isArg && !isBuff && isTypedArray(value),\n skipIndexes = isArr || isArg || isBuff || isType,\n result = skipIndexes ? baseTimes(value.length, String) : [],\n length = result.length;\n\n for (var key in value) {\n if ((inherited || hasOwnProperty.call(value, key)) &&\n !(skipIndexes && (\n // Safari 9 has enumerable `arguments.length` in strict mode.\n key == 'length' ||\n // Node.js 0.10 has enumerable non-index properties on buffers.\n (isBuff && (key == 'offset' || key == 'parent')) ||\n // PhantomJS 2 has enumerable non-index properties on typed arrays.\n (isType && (key == 'buffer' || key == 'byteLength' || key == 'byteOffset')) ||\n // Skip index properties.\n isIndex(key, length)\n ))) {\n result.push(key);\n }\n }\n return result;\n }\n\n /**\n * A specialized version of `_.sample` for arrays.\n *\n * @private\n * @param {Array} array The array to sample.\n * @returns {*} Returns the random element.\n */\n function arraySample(array) {\n var length = array.length;\n return length ? array[baseRandom(0, length - 1)] : undefined;\n }\n\n /**\n * A specialized version of `_.sampleSize` for arrays.\n *\n * @private\n * @param {Array} array The array to sample.\n * @param {number} n The number of elements to sample.\n * @returns {Array} Returns the random elements.\n */\n function arraySampleSize(array, n) {\n return shuffleSelf(copyArray(array), baseClamp(n, 0, array.length));\n }\n\n /**\n * A specialized version of `_.shuffle` for arrays.\n *\n * @private\n * @param {Array} array The array to shuffle.\n * @returns {Array} Returns the new shuffled array.\n */\n function arrayShuffle(array) {\n return shuffleSelf(copyArray(array));\n }\n\n /**\n * This function is like `assignValue` except that it doesn't assign\n * `undefined` values.\n *\n * @private\n * @param {Object} object The object to modify.\n * @param {string} key The key of the property to assign.\n * @param {*} value The value to assign.\n */\n function assignMergeValue(object, key, value) {\n if ((value !== undefined && !eq(object[key], value)) ||\n (value === undefined && !(key in object))) {\n baseAssignValue(object, key, value);\n }\n }\n\n /**\n * Assigns `value` to `key` of `object` if the existing value is not equivalent\n * using [`SameValueZero`](http://ecma-international.org/ecma-262/7.0/#sec-samevaluezero)\n * for equality comparisons.\n *\n * @private\n * @param {Object} object The object to modify.\n * @param {string} key The key of the property to assign.\n * @param {*} value The value to assign.\n */\n function assignValue(object, key, value) {\n var objValue = object[key];\n if (!(hasOwnProperty.call(object, key) && eq(objValue, value)) ||\n (value === undefined && !(key in object))) {\n baseAssignValue(object, key, value);\n }\n }\n\n /**\n * Gets the index at which the `key` is found in `array` of key-value pairs.\n *\n * @private\n * @param {Array} array The array to inspect.\n * @param {*} key The key to search for.\n * @returns {number} Returns the index of the matched value, else `-1`.\n */\n function assocIndexOf(array, key) {\n var length = array.length;\n while (length--) {\n if (eq(array[length][0], key)) {\n return length;\n }\n }\n return -1;\n }\n\n /**\n * Aggregates elements of `collection` on `accumulator` with keys transformed\n * by `iteratee` and values set by `setter`.\n *\n * @private\n * @param {Array|Object} collection The collection to iterate over.\n * @param {Function} setter The function to set `accumulator` values.\n * @param {Function} iteratee The iteratee to transform keys.\n * @param {Object} accumulator The initial aggregated object.\n * @returns {Function} Returns `accumulator`.\n */\n function baseAggregator(collection, setter, iteratee, accumulator) {\n baseEach(collection, function(value, key, collection) {\n setter(accumulator, value, iteratee(value), collection);\n });\n return accumulator;\n }\n\n /**\n * The base implementation of `_.assign` without support for multiple sources\n * or `customizer` functions.\n *\n * @private\n * @param {Object} object The destination object.\n * @param {Object} source The source object.\n * @returns {Object} Returns `object`.\n */\n function baseAssign(object, source) {\n return object && copyObject(source, keys(source), object);\n }\n\n /**\n * The base implementation of `_.assignIn` without support for multiple sources\n * or `customizer` functions.\n *\n * @private\n * @param {Object} object The destination object.\n * @param {Object} source The source object.\n * @returns {Object} Returns `object`.\n */\n function baseAssignIn(object, source) {\n return object && copyObject(source, keysIn(source), object);\n }\n\n /**\n * The base implementation of `assignValue` and `assignMergeValue` without\n * value checks.\n *\n * @private\n * @param {Object} object The object to modify.\n * @param {string} key The key of the property to assign.\n * @param {*} value The value to assign.\n */\n function baseAssignValue(object, key, value) {\n if (key == '__proto__' && defineProperty) {\n defineProperty(object, key, {\n 'configurable': true,\n 'enumerable': true,\n 'value': value,\n 'writable': true\n });\n } else {\n object[key] = value;\n }\n }\n\n /**\n * The base implementation of `_.at` without support for individual paths.\n *\n * @private\n * @param {Object} object The object to iterate over.\n * @param {string[]} paths The property paths to pick.\n * @returns {Array} Returns the picked elements.\n */\n function baseAt(object, paths) {\n var index = -1,\n length = paths.length,\n result = Array(length),\n skip = object == null;\n\n while (++index < length) {\n result[index] = skip ? undefined : get(object, paths[index]);\n }\n return result;\n }\n\n /**\n * The base implementation of `_.clamp` which doesn't coerce arguments.\n *\n * @private\n * @param {number} number The number to clamp.\n * @param {number} [lower] The lower bound.\n * @param {number} upper The upper bound.\n * @returns {number} Returns the clamped number.\n */\n function baseClamp(number, lower, upper) {\n if (number === number) {\n if (upper !== undefined) {\n number = number <= upper ? number : upper;\n }\n if (lower !== undefined) {\n number = number >= lower ? number : lower;\n }\n }\n return number;\n }\n\n /**\n * The base implementation of `_.clone` and `_.cloneDeep` which tracks\n * traversed objects.\n *\n * @private\n * @param {*} value The value to clone.\n * @param {boolean} bitmask The bitmask flags.\n * 1 - Deep clone\n * 2 - Flatten inherited properties\n * 4 - Clone symbols\n * @param {Function} [customizer] The function to customize cloning.\n * @param {string} [key] The key of `value`.\n * @param {Object} [object] The parent object of `value`.\n * @param {Object} [stack] Tracks traversed objects and their clone counterparts.\n * @returns {*} Returns the cloned value.\n */\n function baseClone(value, bitmask, customizer, key, object, stack) {\n var result,\n isDeep = bitmask & CLONE_DEEP_FLAG,\n isFlat = bitmask & CLONE_FLAT_FLAG,\n isFull = bitmask & CLONE_SYMBOLS_FLAG;\n\n if (customizer) {\n result = object ? customizer(value, key, object, stack) : customizer(value);\n }\n if (result !== undefined) {\n return result;\n }\n if (!isObject(value)) {\n return value;\n }\n var isArr = isArray(value);\n if (isArr) {\n result = initCloneArray(value);\n if (!isDeep) {\n return copyArray(value, result);\n }\n } else {\n var tag = getTag(value),\n isFunc = tag == funcTag || tag == genTag;\n\n if (isBuffer(value)) {\n return cloneBuffer(value, isDeep);\n }\n if (tag == objectTag || tag == argsTag || (isFunc && !object)) {\n result = (isFlat || isFunc) ? {} : initCloneObject(value);\n if (!isDeep) {\n return isFlat\n ? copySymbolsIn(value, baseAssignIn(result, value))\n : copySymbols(value, baseAssign(result, value));\n }\n } else {\n if (!cloneableTags[tag]) {\n return object ? value : {};\n }\n result = initCloneByTag(value, tag, isDeep);\n }\n }\n // Check for circular references and return its corresponding clone.\n stack || (stack = new Stack);\n var stacked = stack.get(value);\n if (stacked) {\n return stacked;\n }\n stack.set(value, result);\n\n if (isSet(value)) {\n value.forEach(function(subValue) {\n result.add(baseClone(subValue, bitmask, customizer, subValue, value, stack));\n });\n } else if (isMap(value)) {\n value.forEach(function(subValue, key) {\n result.set(key, baseClone(subValue, bitmask, customizer, key, value, stack));\n });\n }\n\n var keysFunc = isFull\n ? (isFlat ? getAllKeysIn : getAllKeys)\n : (isFlat ? keysIn : keys);\n\n var props = isArr ? undefined : keysFunc(value);\n arrayEach(props || value, function(subValue, key) {\n if (props) {\n key = subValue;\n subValue = value[key];\n }\n // Recursively populate clone (susceptible to call stack limits).\n assignValue(result, key, baseClone(subValue, bitmask, customizer, key, value, stack));\n });\n return result;\n }\n\n /**\n * The base implementation of `_.conforms` which doesn't clone `source`.\n *\n * @private\n * @param {Object} source The object of property predicates to conform to.\n * @returns {Function} Returns the new spec function.\n */\n function baseConforms(source) {\n var props = keys(source);\n return function(object) {\n return baseConformsTo(object, source, props);\n };\n }\n\n /**\n * The base implementation of `_.conformsTo` which accepts `props` to check.\n *\n * @private\n * @param {Object} object The object to inspect.\n * @param {Object} source The object of property predicates to conform to.\n * @returns {boolean} Returns `true` if `object` conforms, else `false`.\n */\n function baseConformsTo(object, source, props) {\n var length = props.length;\n if (object == null) {\n return !length;\n }\n object = Object(object);\n while (length--) {\n var key = props[length],\n predicate = source[key],\n value = object[key];\n\n if ((value === undefined && !(key in object)) || !predicate(value)) {\n return false;\n }\n }\n return true;\n }\n\n /**\n * The base implementation of `_.delay` and `_.defer` which accepts `args`\n * to provide to `func`.\n *\n * @private\n * @param {Function} func The function to delay.\n * @param {number} wait The number of milliseconds to delay invocation.\n * @param {Array} args The arguments to provide to `func`.\n * @returns {number|Object} Returns the timer id or timeout object.\n */\n function baseDelay(func, wait, args) {\n if (typeof func != 'function') {\n throw new TypeError(FUNC_ERROR_TEXT);\n }\n return setTimeout(function() { func.apply(undefined, args); }, wait);\n }\n\n /**\n * The base implementation of methods like `_.difference` without support\n * for excluding multiple arrays or iteratee shorthands.\n *\n * @private\n * @param {Array} array The array to inspect.\n * @param {Array} values The values to exclude.\n * @param {Function} [iteratee] The iteratee invoked per element.\n * @param {Function} [comparator] The comparator invoked per element.\n * @returns {Array} Returns the new array of filtered values.\n */\n function baseDifference(array, values, iteratee, comparator) {\n var index = -1,\n includes = arrayIncludes,\n isCommon = true,\n length = array.length,\n result = [],\n valuesLength = values.length;\n\n if (!length) {\n return result;\n }\n if (iteratee) {\n values = arrayMap(values, baseUnary(iteratee));\n }\n if (comparator) {\n includes = arrayIncludesWith;\n isCommon = false;\n }\n else if (values.length >= LARGE_ARRAY_SIZE) {\n includes = cacheHas;\n isCommon = false;\n values = new SetCache(values);\n }\n outer:\n while (++index < length) {\n var value = array[index],\n computed = iteratee == null ? value : iteratee(value);\n\n value = (comparator || value !== 0) ? value : 0;\n if (isCommon && computed === computed) {\n var valuesIndex = valuesLength;\n while (valuesIndex--) {\n if (values[valuesIndex] === computed) {\n continue outer;\n }\n }\n result.push(value);\n }\n else if (!includes(values, computed, comparator)) {\n result.push(value);\n }\n }\n return result;\n }\n\n /**\n * The base implementation of `_.forEach` without support for iteratee shorthands.\n *\n * @private\n * @param {Array|Object} collection The collection to iterate over.\n * @param {Function} iteratee The function invoked per iteration.\n * @returns {Array|Object} Returns `collection`.\n */\n var baseEach = createBaseEach(baseForOwn);\n\n /**\n * The base implementation of `_.forEachRight` without support for iteratee shorthands.\n *\n * @private\n * @param {Array|Object} collection The collection to iterate over.\n * @param {Function} iteratee The function invoked per iteration.\n * @returns {Array|Object} Returns `collection`.\n */\n var baseEachRight = createBaseEach(baseForOwnRight, true);\n\n /**\n * The base implementation of `_.every` without support for iteratee shorthands.\n *\n * @private\n * @param {Array|Object} collection The collection to iterate over.\n * @param {Function} predicate The function invoked per iteration.\n * @returns {boolean} Returns `true` if all elements pass the predicate check,\n * else `false`\n */\n function baseEvery(collection, predicate) {\n var result = true;\n baseEach(collection, function(value, index, collection) {\n result = !!predicate(value, index, collection);\n return result;\n });\n return result;\n }\n\n /**\n * The base implementation of methods like `_.max` and `_.min` which accepts a\n * `comparator` to determine the extremum value.\n *\n * @private\n * @param {Array} array The array to iterate over.\n * @param {Function} iteratee The iteratee invoked per iteration.\n * @param {Function} comparator The comparator used to compare values.\n * @returns {*} Returns the extremum value.\n */\n function baseExtremum(array, iteratee, comparator) {\n var index = -1,\n length = array.length;\n\n while (++index < length) {\n var value = array[index],\n current = iteratee(value);\n\n if (current != null && (computed === undefined\n ? (current === current && !isSymbol(current))\n : comparator(current, computed)\n )) {\n var computed = current,\n result = value;\n }\n }\n return result;\n }\n\n /**\n * The base implementation of `_.fill` without an iteratee call guard.\n *\n * @private\n * @param {Array} array The array to fill.\n * @param {*} value The value to fill `array` with.\n * @param {number} [start=0] The start position.\n * @param {number} [end=array.length] The end position.\n * @returns {Array} Returns `array`.\n */\n function baseFill(array, value, start, end) {\n var length = array.length;\n\n start = toInteger(start);\n if (start < 0) {\n start = -start > length ? 0 : (length + start);\n }\n end = (end === undefined || end > length) ? length : toInteger(end);\n if (end < 0) {\n end += length;\n }\n end = start > end ? 0 : toLength(end);\n while (start < end) {\n array[start++] = value;\n }\n return array;\n }\n\n /**\n * The base implementation of `_.filter` without support for iteratee shorthands.\n *\n * @private\n * @param {Array|Object} collection The collection to iterate over.\n * @param {Function} predicate The function invoked per iteration.\n * @returns {Array} Returns the new filtered array.\n */\n function baseFilter(collection, predicate) {\n var result = [];\n baseEach(collection, function(value, index, collection) {\n if (predicate(value, index, collection)) {\n result.push(value);\n }\n });\n return result;\n }\n\n /**\n * The base implementation of `_.flatten` with support for restricting flattening.\n *\n * @private\n * @param {Array} array The array to flatten.\n * @param {number} depth The maximum recursion depth.\n * @param {boolean} [predicate=isFlattenable] The function invoked per iteration.\n * @param {boolean} [isStrict] Restrict to values that pass `predicate` checks.\n * @param {Array} [result=[]] The initial result value.\n * @returns {Array} Returns the new flattened array.\n */\n function baseFlatten(array, depth, predicate, isStrict, result) {\n var index = -1,\n length = array.length;\n\n predicate || (predicate = isFlattenable);\n result || (result = []);\n\n while (++index < length) {\n var value = array[index];\n if (depth > 0 && predicate(value)) {\n if (depth > 1) {\n // Recursively flatten arrays (susceptible to call stack limits).\n baseFlatten(value, depth - 1, predicate, isStrict, result);\n } else {\n arrayPush(result, value);\n }\n } else if (!isStrict) {\n result[result.length] = value;\n }\n }\n return result;\n }\n\n /**\n * The base implementation of `baseForOwn` which iterates over `object`\n * properties returned by `keysFunc` and invokes `iteratee` for each property.\n * Iteratee functions may exit iteration early by explicitly returning `false`.\n *\n * @private\n * @param {Object} object The object to iterate over.\n * @param {Function} iteratee The function invoked per iteration.\n * @param {Function} keysFunc The function to get the keys of `object`.\n * @returns {Object} Returns `object`.\n */\n var baseFor = createBaseFor();\n\n /**\n * This function is like `baseFor` except that it iterates over properties\n * in the opposite order.\n *\n * @private\n * @param {Object} object The object to iterate over.\n * @param {Function} iteratee The function invoked per iteration.\n * @param {Function} keysFunc The function to get the keys of `object`.\n * @returns {Object} Returns `object`.\n */\n var baseForRight = createBaseFor(true);\n\n /**\n * The base implementation of `_.forOwn` without support for iteratee shorthands.\n *\n * @private\n * @param {Object} object The object to iterate over.\n * @param {Function} iteratee The function invoked per iteration.\n * @returns {Object} Returns `object`.\n */\n function baseForOwn(object, iteratee) {\n return object && baseFor(object, iteratee, keys);\n }\n\n /**\n * The base implementation of `_.forOwnRight` without support for iteratee shorthands.\n *\n * @private\n * @param {Object} object The object to iterate over.\n * @param {Function} iteratee The function invoked per iteration.\n * @returns {Object} Returns `object`.\n */\n function baseForOwnRight(object, iteratee) {\n return object && baseForRight(object, iteratee, keys);\n }\n\n /**\n * The base implementation of `_.functions` which creates an array of\n * `object` function property names filtered from `props`.\n *\n * @private\n * @param {Object} object The object to inspect.\n * @param {Array} props The property names to filter.\n * @returns {Array} Returns the function names.\n */\n function baseFunctions(object, props) {\n return arrayFilter(props, function(key) {\n return isFunction(object[key]);\n });\n }\n\n /**\n * The base implementation of `_.get` without support for default values.\n *\n * @private\n * @param {Object} object The object to query.\n * @param {Array|string} path The path of the property to get.\n * @returns {*} Returns the resolved value.\n */\n function baseGet(object, path) {\n path = castPath(path, object);\n\n var index = 0,\n length = path.length;\n\n while (object != null && index < length) {\n object = object[toKey(path[index++])];\n }\n return (index && index == length) ? object : undefined;\n }\n\n /**\n * The base implementation of `getAllKeys` and `getAllKeysIn` which uses\n * `keysFunc` and `symbolsFunc` to get the enumerable property names and\n * symbols of `object`.\n *\n * @private\n * @param {Object} object The object to query.\n * @param {Function} keysFunc The function to get the keys of `object`.\n * @param {Function} symbolsFunc The function to get the symbols of `object`.\n * @returns {Array} Returns the array of property names and symbols.\n */\n function baseGetAllKeys(object, keysFunc, symbolsFunc) {\n var result = keysFunc(object);\n return isArray(object) ? result : arrayPush(result, symbolsFunc(object));\n }\n\n /**\n * The base implementation of `getTag` without fallbacks for buggy environments.\n *\n * @private\n * @param {*} value The value to query.\n * @returns {string} Returns the `toStringTag`.\n */\n function baseGetTag(value) {\n if (value == null) {\n return value === undefined ? undefinedTag : nullTag;\n }\n return (symToStringTag && symToStringTag in Object(value))\n ? getRawTag(value)\n : objectToString(value);\n }\n\n /**\n * The base implementation of `_.gt` which doesn't coerce arguments.\n *\n * @private\n * @param {*} value The value to compare.\n * @param {*} other The other value to compare.\n * @returns {boolean} Returns `true` if `value` is greater than `other`,\n * else `false`.\n */\n function baseGt(value, other) {\n return value > other;\n }\n\n /**\n * The base implementation of `_.has` without support for deep paths.\n *\n * @private\n * @param {Object} [object] The object to query.\n * @param {Array|string} key The key to check.\n * @returns {boolean} Returns `true` if `key` exists, else `false`.\n */\n function baseHas(object, key) {\n return object != null && hasOwnProperty.call(object, key);\n }\n\n /**\n * The base implementation of `_.hasIn` without support for deep paths.\n *\n * @private\n * @param {Object} [object] The object to query.\n * @param {Array|string} key The key to check.\n * @returns {boolean} Returns `true` if `key` exists, else `false`.\n */\n function baseHasIn(object, key) {\n return object != null && key in Object(object);\n }\n\n /**\n * The base implementation of `_.inRange` which doesn't coerce arguments.\n *\n * @private\n * @param {number} number The number to check.\n * @param {number} start The start of the range.\n * @param {number} end The end of the range.\n * @returns {boolean} Returns `true` if `number` is in the range, else `false`.\n */\n function baseInRange(number, start, end) {\n return number >= nativeMin(start, end) && number < nativeMax(start, end);\n }\n\n /**\n * The base implementation of methods like `_.intersection`, without support\n * for iteratee shorthands, that accepts an array of arrays to inspect.\n *\n * @private\n * @param {Array} arrays The arrays to inspect.\n * @param {Function} [iteratee] The iteratee invoked per element.\n * @param {Function} [comparator] The comparator invoked per element.\n * @returns {Array} Returns the new array of shared values.\n */\n function baseIntersection(arrays, iteratee, comparator) {\n var includes = comparator ? arrayIncludesWith : arrayIncludes,\n length = arrays[0].length,\n othLength = arrays.length,\n othIndex = othLength,\n caches = Array(othLength),\n maxLength = Infinity,\n result = [];\n\n while (othIndex--) {\n var array = arrays[othIndex];\n if (othIndex && iteratee) {\n array = arrayMap(array, baseUnary(iteratee));\n }\n maxLength = nativeMin(array.length, maxLength);\n caches[othIndex] = !comparator && (iteratee || (length >= 120 && array.length >= 120))\n ? new SetCache(othIndex && array)\n : undefined;\n }\n array = arrays[0];\n\n var index = -1,\n seen = caches[0];\n\n outer:\n while (++index < length && result.length < maxLength) {\n var value = array[index],\n computed = iteratee ? iteratee(value) : value;\n\n value = (comparator || value !== 0) ? value : 0;\n if (!(seen\n ? cacheHas(seen, computed)\n : includes(result, computed, comparator)\n )) {\n othIndex = othLength;\n while (--othIndex) {\n var cache = caches[othIndex];\n if (!(cache\n ? cacheHas(cache, computed)\n : includes(arrays[othIndex], computed, comparator))\n ) {\n continue outer;\n }\n }\n if (seen) {\n seen.push(computed);\n }\n result.push(value);\n }\n }\n return result;\n }\n\n /**\n * The base implementation of `_.invert` and `_.invertBy` which inverts\n * `object` with values transformed by `iteratee` and set by `setter`.\n *\n * @private\n * @param {Object} object The object to iterate over.\n * @param {Function} setter The function to set `accumulator` values.\n * @param {Function} iteratee The iteratee to transform values.\n * @param {Object} accumulator The initial inverted object.\n * @returns {Function} Returns `accumulator`.\n */\n function baseInverter(object, setter, iteratee, accumulator) {\n baseForOwn(object, function(value, key, object) {\n setter(accumulator, iteratee(value), key, object);\n });\n return accumulator;\n }\n\n /**\n * The base implementation of `_.invoke` without support for individual\n * method arguments.\n *\n * @private\n * @param {Object} object The object to query.\n * @param {Array|string} path The path of the method to invoke.\n * @param {Array} args The arguments to invoke the method with.\n * @returns {*} Returns the result of the invoked method.\n */\n function baseInvoke(object, path, args) {\n path = castPath(path, object);\n object = parent(object, path);\n var func = object == null ? object : object[toKey(last(path))];\n return func == null ? undefined : apply(func, object, args);\n }\n\n /**\n * The base implementation of `_.isArguments`.\n *\n * @private\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is an `arguments` object,\n */\n function baseIsArguments(value) {\n return isObjectLike(value) && baseGetTag(value) == argsTag;\n }\n\n /**\n * The base implementation of `_.isArrayBuffer` without Node.js optimizations.\n *\n * @private\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is an array buffer, else `false`.\n */\n function baseIsArrayBuffer(value) {\n return isObjectLike(value) && baseGetTag(value) == arrayBufferTag;\n }\n\n /**\n * The base implementation of `_.isDate` without Node.js optimizations.\n *\n * @private\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is a date object, else `false`.\n */\n function baseIsDate(value) {\n return isObjectLike(value) && baseGetTag(value) == dateTag;\n }\n\n /**\n * The base implementation of `_.isEqual` which supports partial comparisons\n * and tracks traversed objects.\n *\n * @private\n * @param {*} value The value to compare.\n * @param {*} other The other value to compare.\n * @param {boolean} bitmask The bitmask flags.\n * 1 - Unordered comparison\n * 2 - Partial comparison\n * @param {Function} [customizer] The function to customize comparisons.\n * @param {Object} [stack] Tracks traversed `value` and `other` objects.\n * @returns {boolean} Returns `true` if the values are equivalent, else `false`.\n */\n function baseIsEqual(value, other, bitmask, customizer, stack) {\n if (value === other) {\n return true;\n }\n if (value == null || other == null || (!isObjectLike(value) && !isObjectLike(other))) {\n return value !== value && other !== other;\n }\n return baseIsEqualDeep(value, other, bitmask, customizer, baseIsEqual, stack);\n }\n\n /**\n * A specialized version of `baseIsEqual` for arrays and objects which performs\n * deep comparisons and tracks traversed objects enabling objects with circular\n * references to be compared.\n *\n * @private\n * @param {Object} object The object to compare.\n * @param {Object} other The other object to compare.\n * @param {number} bitmask The bitmask flags. See `baseIsEqual` for more details.\n * @param {Function} customizer The function to customize comparisons.\n * @param {Function} equalFunc The function to determine equivalents of values.\n * @param {Object} [stack] Tracks traversed `object` and `other` objects.\n * @returns {boolean} Returns `true` if the objects are equivalent, else `false`.\n */\n function baseIsEqualDeep(object, other, bitmask, customizer, equalFunc, stack) {\n var objIsArr = isArray(object),\n othIsArr = isArray(other),\n objTag = objIsArr ? arrayTag : getTag(object),\n othTag = othIsArr ? arrayTag : getTag(other);\n\n objTag = objTag == argsTag ? objectTag : objTag;\n othTag = othTag == argsTag ? objectTag : othTag;\n\n var objIsObj = objTag == objectTag,\n othIsObj = othTag == objectTag,\n isSameTag = objTag == othTag;\n\n if (isSameTag && isBuffer(object)) {\n if (!isBuffer(other)) {\n return false;\n }\n objIsArr = true;\n objIsObj = false;\n }\n if (isSameTag && !objIsObj) {\n stack || (stack = new Stack);\n return (objIsArr || isTypedArray(object))\n ? equalArrays(object, other, bitmask, customizer, equalFunc, stack)\n : equalByTag(object, other, objTag, bitmask, customizer, equalFunc, stack);\n }\n if (!(bitmask & COMPARE_PARTIAL_FLAG)) {\n var objIsWrapped = objIsObj && hasOwnProperty.call(object, '__wrapped__'),\n othIsWrapped = othIsObj && hasOwnProperty.call(other, '__wrapped__');\n\n if (objIsWrapped || othIsWrapped) {\n var objUnwrapped = objIsWrapped ? object.value() : object,\n othUnwrapped = othIsWrapped ? other.value() : other;\n\n stack || (stack = new Stack);\n return equalFunc(objUnwrapped, othUnwrapped, bitmask, customizer, stack);\n }\n }\n if (!isSameTag) {\n return false;\n }\n stack || (stack = new Stack);\n return equalObjects(object, other, bitmask, customizer, equalFunc, stack);\n }\n\n /**\n * The base implementation of `_.isMap` without Node.js optimizations.\n *\n * @private\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is a map, else `false`.\n */\n function baseIsMap(value) {\n return isObjectLike(value) && getTag(value) == mapTag;\n }\n\n /**\n * The base implementation of `_.isMatch` without support for iteratee shorthands.\n *\n * @private\n * @param {Object} object The object to inspect.\n * @param {Object} source The object of property values to match.\n * @param {Array} matchData The property names, values, and compare flags to match.\n * @param {Function} [customizer] The function to customize comparisons.\n * @returns {boolean} Returns `true` if `object` is a match, else `false`.\n */\n function baseIsMatch(object, source, matchData, customizer) {\n var index = matchData.length,\n length = index,\n noCustomizer = !customizer;\n\n if (object == null) {\n return !length;\n }\n object = Object(object);\n while (index--) {\n var data = matchData[index];\n if ((noCustomizer && data[2])\n ? data[1] !== object[data[0]]\n : !(data[0] in object)\n ) {\n return false;\n }\n }\n while (++index < length) {\n data = matchData[index];\n var key = data[0],\n objValue = object[key],\n srcValue = data[1];\n\n if (noCustomizer && data[2]) {\n if (objValue === undefined && !(key in object)) {\n return false;\n }\n } else {\n var stack = new Stack;\n if (customizer) {\n var result = customizer(objValue, srcValue, key, object, source, stack);\n }\n if (!(result === undefined\n ? baseIsEqual(srcValue, objValue, COMPARE_PARTIAL_FLAG | COMPARE_UNORDERED_FLAG, customizer, stack)\n : result\n )) {\n return false;\n }\n }\n }\n return true;\n }\n\n /**\n * The base implementation of `_.isNative` without bad shim checks.\n *\n * @private\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is a native function,\n * else `false`.\n */\n function baseIsNative(value) {\n if (!isObject(value) || isMasked(value)) {\n return false;\n }\n var pattern = isFunction(value) ? reIsNative : reIsHostCtor;\n return pattern.test(toSource(value));\n }\n\n /**\n * The base implementation of `_.isRegExp` without Node.js optimizations.\n *\n * @private\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is a regexp, else `false`.\n */\n function baseIsRegExp(value) {\n return isObjectLike(value) && baseGetTag(value) == regexpTag;\n }\n\n /**\n * The base implementation of `_.isSet` without Node.js optimizations.\n *\n * @private\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is a set, else `false`.\n */\n function baseIsSet(value) {\n return isObjectLike(value) && getTag(value) == setTag;\n }\n\n /**\n * The base implementation of `_.isTypedArray` without Node.js optimizations.\n *\n * @private\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is a typed array, else `false`.\n */\n function baseIsTypedArray(value) {\n return isObjectLike(value) &&\n isLength(value.length) && !!typedArrayTags[baseGetTag(value)];\n }\n\n /**\n * The base implementation of `_.iteratee`.\n *\n * @private\n * @param {*} [value=_.identity] The value to convert to an iteratee.\n * @returns {Function} Returns the iteratee.\n */\n function baseIteratee(value) {\n // Don't store the `typeof` result in a variable to avoid a JIT bug in Safari 9.\n // See https://bugs.webkit.org/show_bug.cgi?id=156034 for more details.\n if (typeof value == 'function') {\n return value;\n }\n if (value == null) {\n return identity;\n }\n if (typeof value == 'object') {\n return isArray(value)\n ? baseMatchesProperty(value[0], value[1])\n : baseMatches(value);\n }\n return property(value);\n }\n\n /**\n * The base implementation of `_.keys` which doesn't treat sparse arrays as dense.\n *\n * @private\n * @param {Object} object The object to query.\n * @returns {Array} Returns the array of property names.\n */\n function baseKeys(object) {\n if (!isPrototype(object)) {\n return nativeKeys(object);\n }\n var result = [];\n for (var key in Object(object)) {\n if (hasOwnProperty.call(object, key) && key != 'constructor') {\n result.push(key);\n }\n }\n return result;\n }\n\n /**\n * The base implementation of `_.keysIn` which doesn't treat sparse arrays as dense.\n *\n * @private\n * @param {Object} object The object to query.\n * @returns {Array} Returns the array of property names.\n */\n function baseKeysIn(object) {\n if (!isObject(object)) {\n return nativeKeysIn(object);\n }\n var isProto = isPrototype(object),\n result = [];\n\n for (var key in object) {\n if (!(key == 'constructor' && (isProto || !hasOwnProperty.call(object, key)))) {\n result.push(key);\n }\n }\n return result;\n }\n\n /**\n * The base implementation of `_.lt` which doesn't coerce arguments.\n *\n * @private\n * @param {*} value The value to compare.\n * @param {*} other The other value to compare.\n * @returns {boolean} Returns `true` if `value` is less than `other`,\n * else `false`.\n */\n function baseLt(value, other) {\n return value < other;\n }\n\n /**\n * The base implementation of `_.map` without support for iteratee shorthands.\n *\n * @private\n * @param {Array|Object} collection The collection to iterate over.\n * @param {Function} iteratee The function invoked per iteration.\n * @returns {Array} Returns the new mapped array.\n */\n function baseMap(collection, iteratee) {\n var index = -1,\n result = isArrayLike(collection) ? Array(collection.length) : [];\n\n baseEach(collection, function(value, key, collection) {\n result[++index] = iteratee(value, key, collection);\n });\n return result;\n }\n\n /**\n * The base implementation of `_.matches` which doesn't clone `source`.\n *\n * @private\n * @param {Object} source The object of property values to match.\n * @returns {Function} Returns the new spec function.\n */\n function baseMatches(source) {\n var matchData = getMatchData(source);\n if (matchData.length == 1 && matchData[0][2]) {\n return matchesStrictComparable(matchData[0][0], matchData[0][1]);\n }\n return function(object) {\n return object === source || baseIsMatch(object, source, matchData);\n };\n }\n\n /**\n * The base implementation of `_.matchesProperty` which doesn't clone `srcValue`.\n *\n * @private\n * @param {string} path The path of the property to get.\n * @param {*} srcValue The value to match.\n * @returns {Function} Returns the new spec function.\n */\n function baseMatchesProperty(path, srcValue) {\n if (isKey(path) && isStrictComparable(srcValue)) {\n return matchesStrictComparable(toKey(path), srcValue);\n }\n return function(object) {\n var objValue = get(object, path);\n return (objValue === undefined && objValue === srcValue)\n ? hasIn(object, path)\n : baseIsEqual(srcValue, objValue, COMPARE_PARTIAL_FLAG | COMPARE_UNORDERED_FLAG);\n };\n }\n\n /**\n * The base implementation of `_.merge` without support for multiple sources.\n *\n * @private\n * @param {Object} object The destination object.\n * @param {Object} source The source object.\n * @param {number} srcIndex The index of `source`.\n * @param {Function} [customizer] The function to customize merged values.\n * @param {Object} [stack] Tracks traversed source values and their merged\n * counterparts.\n */\n function baseMerge(object, source, srcIndex, customizer, stack) {\n if (object === source) {\n return;\n }\n baseFor(source, function(srcValue, key) {\n stack || (stack = new Stack);\n if (isObject(srcValue)) {\n baseMergeDeep(object, source, key, srcIndex, baseMerge, customizer, stack);\n }\n else {\n var newValue = customizer\n ? customizer(safeGet(object, key), srcValue, (key + ''), object, source, stack)\n : undefined;\n\n if (newValue === undefined) {\n newValue = srcValue;\n }\n assignMergeValue(object, key, newValue);\n }\n }, keysIn);\n }\n\n /**\n * A specialized version of `baseMerge` for arrays and objects which performs\n * deep merges and tracks traversed objects enabling objects with circular\n * references to be merged.\n *\n * @private\n * @param {Object} object The destination object.\n * @param {Object} source The source object.\n * @param {string} key The key of the value to merge.\n * @param {number} srcIndex The index of `source`.\n * @param {Function} mergeFunc The function to merge values.\n * @param {Function} [customizer] The function to customize assigned values.\n * @param {Object} [stack] Tracks traversed source values and their merged\n * counterparts.\n */\n function baseMergeDeep(object, source, key, srcIndex, mergeFunc, customizer, stack) {\n var objValue = safeGet(object, key),\n srcValue = safeGet(source, key),\n stacked = stack.get(srcValue);\n\n if (stacked) {\n assignMergeValue(object, key, stacked);\n return;\n }\n var newValue = customizer\n ? customizer(objValue, srcValue, (key + ''), object, source, stack)\n : undefined;\n\n var isCommon = newValue === undefined;\n\n if (isCommon) {\n var isArr = isArray(srcValue),\n isBuff = !isArr && isBuffer(srcValue),\n isTyped = !isArr && !isBuff && isTypedArray(srcValue);\n\n newValue = srcValue;\n if (isArr || isBuff || isTyped) {\n if (isArray(objValue)) {\n newValue = objValue;\n }\n else if (isArrayLikeObject(objValue)) {\n newValue = copyArray(objValue);\n }\n else if (isBuff) {\n isCommon = false;\n newValue = cloneBuffer(srcValue, true);\n }\n else if (isTyped) {\n isCommon = false;\n newValue = cloneTypedArray(srcValue, true);\n }\n else {\n newValue = [];\n }\n }\n else if (isPlainObject(srcValue) || isArguments(srcValue)) {\n newValue = objValue;\n if (isArguments(objValue)) {\n newValue = toPlainObject(objValue);\n }\n else if (!isObject(objValue) || isFunction(objValue)) {\n newValue = initCloneObject(srcValue);\n }\n }\n else {\n isCommon = false;\n }\n }\n if (isCommon) {\n // Recursively merge objects and arrays (susceptible to call stack limits).\n stack.set(srcValue, newValue);\n mergeFunc(newValue, srcValue, srcIndex, customizer, stack);\n stack['delete'](srcValue);\n }\n assignMergeValue(object, key, newValue);\n }\n\n /**\n * The base implementation of `_.nth` which doesn't coerce arguments.\n *\n * @private\n * @param {Array} array The array to query.\n * @param {number} n The index of the element to return.\n * @returns {*} Returns the nth element of `array`.\n */\n function baseNth(array, n) {\n var length = array.length;\n if (!length) {\n return;\n }\n n += n < 0 ? length : 0;\n return isIndex(n, length) ? array[n] : undefined;\n }\n\n /**\n * The base implementation of `_.orderBy` without param guards.\n *\n * @private\n * @param {Array|Object} collection The collection to iterate over.\n * @param {Function[]|Object[]|string[]} iteratees The iteratees to sort by.\n * @param {string[]} orders The sort orders of `iteratees`.\n * @returns {Array} Returns the new sorted array.\n */\n function baseOrderBy(collection, iteratees, orders) {\n if (iteratees.length) {\n iteratees = arrayMap(iteratees, function(iteratee) {\n if (isArray(iteratee)) {\n return function(value) {\n return baseGet(value, iteratee.length === 1 ? iteratee[0] : iteratee);\n }\n }\n return iteratee;\n });\n } else {\n iteratees = [identity];\n }\n\n var index = -1;\n iteratees = arrayMap(iteratees, baseUnary(getIteratee()));\n\n var result = baseMap(collection, function(value, key, collection) {\n var criteria = arrayMap(iteratees, function(iteratee) {\n return iteratee(value);\n });\n return { 'criteria': criteria, 'index': ++index, 'value': value };\n });\n\n return baseSortBy(result, function(object, other) {\n return compareMultiple(object, other, orders);\n });\n }\n\n /**\n * The base implementation of `_.pick` without support for individual\n * property identifiers.\n *\n * @private\n * @param {Object} object The source object.\n * @param {string[]} paths The property paths to pick.\n * @returns {Object} Returns the new object.\n */\n function basePick(object, paths) {\n return basePickBy(object, paths, function(value, path) {\n return hasIn(object, path);\n });\n }\n\n /**\n * The base implementation of `_.pickBy` without support for iteratee shorthands.\n *\n * @private\n * @param {Object} object The source object.\n * @param {string[]} paths The property paths to pick.\n * @param {Function} predicate The function invoked per property.\n * @returns {Object} Returns the new object.\n */\n function basePickBy(object, paths, predicate) {\n var index = -1,\n length = paths.length,\n result = {};\n\n while (++index < length) {\n var path = paths[index],\n value = baseGet(object, path);\n\n if (predicate(value, path)) {\n baseSet(result, castPath(path, object), value);\n }\n }\n return result;\n }\n\n /**\n * A specialized version of `baseProperty` which supports deep paths.\n *\n * @private\n * @param {Array|string} path The path of the property to get.\n * @returns {Function} Returns the new accessor function.\n */\n function basePropertyDeep(path) {\n return function(object) {\n return baseGet(object, path);\n };\n }\n\n /**\n * The base implementation of `_.pullAllBy` without support for iteratee\n * shorthands.\n *\n * @private\n * @param {Array} array The array to modify.\n * @param {Array} values The values to remove.\n * @param {Function} [iteratee] The iteratee invoked per element.\n * @param {Function} [comparator] The comparator invoked per element.\n * @returns {Array} Returns `array`.\n */\n function basePullAll(array, values, iteratee, comparator) {\n var indexOf = comparator ? baseIndexOfWith : baseIndexOf,\n index = -1,\n length = values.length,\n seen = array;\n\n if (array === values) {\n values = copyArray(values);\n }\n if (iteratee) {\n seen = arrayMap(array, baseUnary(iteratee));\n }\n while (++index < length) {\n var fromIndex = 0,\n value = values[index],\n computed = iteratee ? iteratee(value) : value;\n\n while ((fromIndex = indexOf(seen, computed, fromIndex, comparator)) > -1) {\n if (seen !== array) {\n splice.call(seen, fromIndex, 1);\n }\n splice.call(array, fromIndex, 1);\n }\n }\n return array;\n }\n\n /**\n * The base implementation of `_.pullAt` without support for individual\n * indexes or capturing the removed elements.\n *\n * @private\n * @param {Array} array The array to modify.\n * @param {number[]} indexes The indexes of elements to remove.\n * @returns {Array} Returns `array`.\n */\n function basePullAt(array, indexes) {\n var length = array ? indexes.length : 0,\n lastIndex = length - 1;\n\n while (length--) {\n var index = indexes[length];\n if (length == lastIndex || index !== previous) {\n var previous = index;\n if (isIndex(index)) {\n splice.call(array, index, 1);\n } else {\n baseUnset(array, index);\n }\n }\n }\n return array;\n }\n\n /**\n * The base implementation of `_.random` without support for returning\n * floating-point numbers.\n *\n * @private\n * @param {number} lower The lower bound.\n * @param {number} upper The upper bound.\n * @returns {number} Returns the random number.\n */\n function baseRandom(lower, upper) {\n return lower + nativeFloor(nativeRandom() * (upper - lower + 1));\n }\n\n /**\n * The base implementation of `_.range` and `_.rangeRight` which doesn't\n * coerce arguments.\n *\n * @private\n * @param {number} start The start of the range.\n * @param {number} end The end of the range.\n * @param {number} step The value to increment or decrement by.\n * @param {boolean} [fromRight] Specify iterating from right to left.\n * @returns {Array} Returns the range of numbers.\n */\n function baseRange(start, end, step, fromRight) {\n var index = -1,\n length = nativeMax(nativeCeil((end - start) / (step || 1)), 0),\n result = Array(length);\n\n while (length--) {\n result[fromRight ? length : ++index] = start;\n start += step;\n }\n return result;\n }\n\n /**\n * The base implementation of `_.repeat` which doesn't coerce arguments.\n *\n * @private\n * @param {string} string The string to repeat.\n * @param {number} n The number of times to repeat the string.\n * @returns {string} Returns the repeated string.\n */\n function baseRepeat(string, n) {\n var result = '';\n if (!string || n < 1 || n > MAX_SAFE_INTEGER) {\n return result;\n }\n // Leverage the exponentiation by squaring algorithm for a faster repeat.\n // See https://en.wikipedia.org/wiki/Exponentiation_by_squaring for more details.\n do {\n if (n % 2) {\n result += string;\n }\n n = nativeFloor(n / 2);\n if (n) {\n string += string;\n }\n } while (n);\n\n return result;\n }\n\n /**\n * The base implementation of `_.rest` which doesn't validate or coerce arguments.\n *\n * @private\n * @param {Function} func The function to apply a rest parameter to.\n * @param {number} [start=func.length-1] The start position of the rest parameter.\n * @returns {Function} Returns the new function.\n */\n function baseRest(func, start) {\n return setToString(overRest(func, start, identity), func + '');\n }\n\n /**\n * The base implementation of `_.sample`.\n *\n * @private\n * @param {Array|Object} collection The collection to sample.\n * @returns {*} Returns the random element.\n */\n function baseSample(collection) {\n return arraySample(values(collection));\n }\n\n /**\n * The base implementation of `_.sampleSize` without param guards.\n *\n * @private\n * @param {Array|Object} collection The collection to sample.\n * @param {number} n The number of elements to sample.\n * @returns {Array} Returns the random elements.\n */\n function baseSampleSize(collection, n) {\n var array = values(collection);\n return shuffleSelf(array, baseClamp(n, 0, array.length));\n }\n\n /**\n * The base implementation of `_.set`.\n *\n * @private\n * @param {Object} object The object to modify.\n * @param {Array|string} path The path of the property to set.\n * @param {*} value The value to set.\n * @param {Function} [customizer] The function to customize path creation.\n * @returns {Object} Returns `object`.\n */\n function baseSet(object, path, value, customizer) {\n if (!isObject(object)) {\n return object;\n }\n path = castPath(path, object);\n\n var index = -1,\n length = path.length,\n lastIndex = length - 1,\n nested = object;\n\n while (nested != null && ++index < length) {\n var key = toKey(path[index]),\n newValue = value;\n\n if (key === '__proto__' || key === 'constructor' || key === 'prototype') {\n return object;\n }\n\n if (index != lastIndex) {\n var objValue = nested[key];\n newValue = customizer ? customizer(objValue, key, nested) : undefined;\n if (newValue === undefined) {\n newValue = isObject(objValue)\n ? objValue\n : (isIndex(path[index + 1]) ? [] : {});\n }\n }\n assignValue(nested, key, newValue);\n nested = nested[key];\n }\n return object;\n }\n\n /**\n * The base implementation of `setData` without support for hot loop shorting.\n *\n * @private\n * @param {Function} func The function to associate metadata with.\n * @param {*} data The metadata.\n * @returns {Function} Returns `func`.\n */\n var baseSetData = !metaMap ? identity : function(func, data) {\n metaMap.set(func, data);\n return func;\n };\n\n /**\n * The base implementation of `setToString` without support for hot loop shorting.\n *\n * @private\n * @param {Function} func The function to modify.\n * @param {Function} string The `toString` result.\n * @returns {Function} Returns `func`.\n */\n var baseSetToString = !defineProperty ? identity : function(func, string) {\n return defineProperty(func, 'toString', {\n 'configurable': true,\n 'enumerable': false,\n 'value': constant(string),\n 'writable': true\n });\n };\n\n /**\n * The base implementation of `_.shuffle`.\n *\n * @private\n * @param {Array|Object} collection The collection to shuffle.\n * @returns {Array} Returns the new shuffled array.\n */\n function baseShuffle(collection) {\n return shuffleSelf(values(collection));\n }\n\n /**\n * The base implementation of `_.slice` without an iteratee call guard.\n *\n * @private\n * @param {Array} array The array to slice.\n * @param {number} [start=0] The start position.\n * @param {number} [end=array.length] The end position.\n * @returns {Array} Returns the slice of `array`.\n */\n function baseSlice(array, start, end) {\n var index = -1,\n length = array.length;\n\n if (start < 0) {\n start = -start > length ? 0 : (length + start);\n }\n end = end > length ? length : end;\n if (end < 0) {\n end += length;\n }\n length = start > end ? 0 : ((end - start) >>> 0);\n start >>>= 0;\n\n var result = Array(length);\n while (++index < length) {\n result[index] = array[index + start];\n }\n return result;\n }\n\n /**\n * The base implementation of `_.some` without support for iteratee shorthands.\n *\n * @private\n * @param {Array|Object} collection The collection to iterate over.\n * @param {Function} predicate The function invoked per iteration.\n * @returns {boolean} Returns `true` if any element passes the predicate check,\n * else `false`.\n */\n function baseSome(collection, predicate) {\n var result;\n\n baseEach(collection, function(value, index, collection) {\n result = predicate(value, index, collection);\n return !result;\n });\n return !!result;\n }\n\n /**\n * The base implementation of `_.sortedIndex` and `_.sortedLastIndex` which\n * performs a binary search of `array` to determine the index at which `value`\n * should be inserted into `array` in order to maintain its sort order.\n *\n * @private\n * @param {Array} array The sorted array to inspect.\n * @param {*} value The value to evaluate.\n * @param {boolean} [retHighest] Specify returning the highest qualified index.\n * @returns {number} Returns the index at which `value` should be inserted\n * into `array`.\n */\n function baseSortedIndex(array, value, retHighest) {\n var low = 0,\n high = array == null ? low : array.length;\n\n if (typeof value == 'number' && value === value && high <= HALF_MAX_ARRAY_LENGTH) {\n while (low < high) {\n var mid = (low + high) >>> 1,\n computed = array[mid];\n\n if (computed !== null && !isSymbol(computed) &&\n (retHighest ? (computed <= value) : (computed < value))) {\n low = mid + 1;\n } else {\n high = mid;\n }\n }\n return high;\n }\n return baseSortedIndexBy(array, value, identity, retHighest);\n }\n\n /**\n * The base implementation of `_.sortedIndexBy` and `_.sortedLastIndexBy`\n * which invokes `iteratee` for `value` and each element of `array` to compute\n * their sort ranking. The iteratee is invoked with one argument; (value).\n *\n * @private\n * @param {Array} array The sorted array to inspect.\n * @param {*} value The value to evaluate.\n * @param {Function} iteratee The iteratee invoked per element.\n * @param {boolean} [retHighest] Specify returning the highest qualified index.\n * @returns {number} Returns the index at which `value` should be inserted\n * into `array`.\n */\n function baseSortedIndexBy(array, value, iteratee, retHighest) {\n var low = 0,\n high = array == null ? 0 : array.length;\n if (high === 0) {\n return 0;\n }\n\n value = iteratee(value);\n var valIsNaN = value !== value,\n valIsNull = value === null,\n valIsSymbol = isSymbol(value),\n valIsUndefined = value === undefined;\n\n while (low < high) {\n var mid = nativeFloor((low + high) / 2),\n computed = iteratee(array[mid]),\n othIsDefined = computed !== undefined,\n othIsNull = computed === null,\n othIsReflexive = computed === computed,\n othIsSymbol = isSymbol(computed);\n\n if (valIsNaN) {\n var setLow = retHighest || othIsReflexive;\n } else if (valIsUndefined) {\n setLow = othIsReflexive && (retHighest || othIsDefined);\n } else if (valIsNull) {\n setLow = othIsReflexive && othIsDefined && (retHighest || !othIsNull);\n } else if (valIsSymbol) {\n setLow = othIsReflexive && othIsDefined && !othIsNull && (retHighest || !othIsSymbol);\n } else if (othIsNull || othIsSymbol) {\n setLow = false;\n } else {\n setLow = retHighest ? (computed <= value) : (computed < value);\n }\n if (setLow) {\n low = mid + 1;\n } else {\n high = mid;\n }\n }\n return nativeMin(high, MAX_ARRAY_INDEX);\n }\n\n /**\n * The base implementation of `_.sortedUniq` and `_.sortedUniqBy` without\n * support for iteratee shorthands.\n *\n * @private\n * @param {Array} array The array to inspect.\n * @param {Function} [iteratee] The iteratee invoked per element.\n * @returns {Array} Returns the new duplicate free array.\n */\n function baseSortedUniq(array, iteratee) {\n var index = -1,\n length = array.length,\n resIndex = 0,\n result = [];\n\n while (++index < length) {\n var value = array[index],\n computed = iteratee ? iteratee(value) : value;\n\n if (!index || !eq(computed, seen)) {\n var seen = computed;\n result[resIndex++] = value === 0 ? 0 : value;\n }\n }\n return result;\n }\n\n /**\n * The base implementation of `_.toNumber` which doesn't ensure correct\n * conversions of binary, hexadecimal, or octal string values.\n *\n * @private\n * @param {*} value The value to process.\n * @returns {number} Returns the number.\n */\n function baseToNumber(value) {\n if (typeof value == 'number') {\n return value;\n }\n if (isSymbol(value)) {\n return NAN;\n }\n return +value;\n }\n\n /**\n * The base implementation of `_.toString` which doesn't convert nullish\n * values to empty strings.\n *\n * @private\n * @param {*} value The value to process.\n * @returns {string} Returns the string.\n */\n function baseToString(value) {\n // Exit early for strings to avoid a performance hit in some environments.\n if (typeof value == 'string') {\n return value;\n }\n if (isArray(value)) {\n // Recursively convert values (susceptible to call stack limits).\n return arrayMap(value, baseToString) + '';\n }\n if (isSymbol(value)) {\n return symbolToString ? symbolToString.call(value) : '';\n }\n var result = (value + '');\n return (result == '0' && (1 / value) == -INFINITY) ? '-0' : result;\n }\n\n /**\n * The base implementation of `_.uniqBy` without support for iteratee shorthands.\n *\n * @private\n * @param {Array} array The array to inspect.\n * @param {Function} [iteratee] The iteratee invoked per element.\n * @param {Function} [comparator] The comparator invoked per element.\n * @returns {Array} Returns the new duplicate free array.\n */\n function baseUniq(array, iteratee, comparator) {\n var index = -1,\n includes = arrayIncludes,\n length = array.length,\n isCommon = true,\n result = [],\n seen = result;\n\n if (comparator) {\n isCommon = false;\n includes = arrayIncludesWith;\n }\n else if (length >= LARGE_ARRAY_SIZE) {\n var set = iteratee ? null : createSet(array);\n if (set) {\n return setToArray(set);\n }\n isCommon = false;\n includes = cacheHas;\n seen = new SetCache;\n }\n else {\n seen = iteratee ? [] : result;\n }\n outer:\n while (++index < length) {\n var value = array[index],\n computed = iteratee ? iteratee(value) : value;\n\n value = (comparator || value !== 0) ? value : 0;\n if (isCommon && computed === computed) {\n var seenIndex = seen.length;\n while (seenIndex--) {\n if (seen[seenIndex] === computed) {\n continue outer;\n }\n }\n if (iteratee) {\n seen.push(computed);\n }\n result.push(value);\n }\n else if (!includes(seen, computed, comparator)) {\n if (seen !== result) {\n seen.push(computed);\n }\n result.push(value);\n }\n }\n return result;\n }\n\n /**\n * The base implementation of `_.unset`.\n *\n * @private\n * @param {Object} object The object to modify.\n * @param {Array|string} path The property path to unset.\n * @returns {boolean} Returns `true` if the property is deleted, else `false`.\n */\n function baseUnset(object, path) {\n path = castPath(path, object);\n object = parent(object, path);\n return object == null || delete object[toKey(last(path))];\n }\n\n /**\n * The base implementation of `_.update`.\n *\n * @private\n * @param {Object} object The object to modify.\n * @param {Array|string} path The path of the property to update.\n * @param {Function} updater The function to produce the updated value.\n * @param {Function} [customizer] The function to customize path creation.\n * @returns {Object} Returns `object`.\n */\n function baseUpdate(object, path, updater, customizer) {\n return baseSet(object, path, updater(baseGet(object, path)), customizer);\n }\n\n /**\n * The base implementation of methods like `_.dropWhile` and `_.takeWhile`\n * without support for iteratee shorthands.\n *\n * @private\n * @param {Array} array The array to query.\n * @param {Function} predicate The function invoked per iteration.\n * @param {boolean} [isDrop] Specify dropping elements instead of taking them.\n * @param {boolean} [fromRight] Specify iterating from right to left.\n * @returns {Array} Returns the slice of `array`.\n */\n function baseWhile(array, predicate, isDrop, fromRight) {\n var length = array.length,\n index = fromRight ? length : -1;\n\n while ((fromRight ? index-- : ++index < length) &&\n predicate(array[index], index, array)) {}\n\n return isDrop\n ? baseSlice(array, (fromRight ? 0 : index), (fromRight ? index + 1 : length))\n : baseSlice(array, (fromRight ? index + 1 : 0), (fromRight ? length : index));\n }\n\n /**\n * The base implementation of `wrapperValue` which returns the result of\n * performing a sequence of actions on the unwrapped `value`, where each\n * successive action is supplied the return value of the previous.\n *\n * @private\n * @param {*} value The unwrapped value.\n * @param {Array} actions Actions to perform to resolve the unwrapped value.\n * @returns {*} Returns the resolved value.\n */\n function baseWrapperValue(value, actions) {\n var result = value;\n if (result instanceof LazyWrapper) {\n result = result.value();\n }\n return arrayReduce(actions, function(result, action) {\n return action.func.apply(action.thisArg, arrayPush([result], action.args));\n }, result);\n }\n\n /**\n * The base implementation of methods like `_.xor`, without support for\n * iteratee shorthands, that accepts an array of arrays to inspect.\n *\n * @private\n * @param {Array} arrays The arrays to inspect.\n * @param {Function} [iteratee] The iteratee invoked per element.\n * @param {Function} [comparator] The comparator invoked per element.\n * @returns {Array} Returns the new array of values.\n */\n function baseXor(arrays, iteratee, comparator) {\n var length = arrays.length;\n if (length < 2) {\n return length ? baseUniq(arrays[0]) : [];\n }\n var index = -1,\n result = Array(length);\n\n while (++index < length) {\n var array = arrays[index],\n othIndex = -1;\n\n while (++othIndex < length) {\n if (othIndex != index) {\n result[index] = baseDifference(result[index] || array, arrays[othIndex], iteratee, comparator);\n }\n }\n }\n return baseUniq(baseFlatten(result, 1), iteratee, comparator);\n }\n\n /**\n * This base implementation of `_.zipObject` which assigns values using `assignFunc`.\n *\n * @private\n * @param {Array} props The property identifiers.\n * @param {Array} values The property values.\n * @param {Function} assignFunc The function to assign values.\n * @returns {Object} Returns the new object.\n */\n function baseZipObject(props, values, assignFunc) {\n var index = -1,\n length = props.length,\n valsLength = values.length,\n result = {};\n\n while (++index < length) {\n var value = index < valsLength ? values[index] : undefined;\n assignFunc(result, props[index], value);\n }\n return result;\n }\n\n /**\n * Casts `value` to an empty array if it's not an array like object.\n *\n * @private\n * @param {*} value The value to inspect.\n * @returns {Array|Object} Returns the cast array-like object.\n */\n function castArrayLikeObject(value) {\n return isArrayLikeObject(value) ? value : [];\n }\n\n /**\n * Casts `value` to `identity` if it's not a function.\n *\n * @private\n * @param {*} value The value to inspect.\n * @returns {Function} Returns cast function.\n */\n function castFunction(value) {\n return typeof value == 'function' ? value : identity;\n }\n\n /**\n * Casts `value` to a path array if it's not one.\n *\n * @private\n * @param {*} value The value to inspect.\n * @param {Object} [object] The object to query keys on.\n * @returns {Array} Returns the cast property path array.\n */\n function castPath(value, object) {\n if (isArray(value)) {\n return value;\n }\n return isKey(value, object) ? [value] : stringToPath(toString(value));\n }\n\n /**\n * A `baseRest` alias which can be replaced with `identity` by module\n * replacement plugins.\n *\n * @private\n * @type {Function}\n * @param {Function} func The function to apply a rest parameter to.\n * @returns {Function} Returns the new function.\n */\n var castRest = baseRest;\n\n /**\n * Casts `array` to a slice if it's needed.\n *\n * @private\n * @param {Array} array The array to inspect.\n * @param {number} start The start position.\n * @param {number} [end=array.length] The end position.\n * @returns {Array} Returns the cast slice.\n */\n function castSlice(array, start, end) {\n var length = array.length;\n end = end === undefined ? length : end;\n return (!start && end >= length) ? array : baseSlice(array, start, end);\n }\n\n /**\n * A simple wrapper around the global [`clearTimeout`](https://mdn.io/clearTimeout).\n *\n * @private\n * @param {number|Object} id The timer id or timeout object of the timer to clear.\n */\n var clearTimeout = ctxClearTimeout || function(id) {\n return root.clearTimeout(id);\n };\n\n /**\n * Creates a clone of `buffer`.\n *\n * @private\n * @param {Buffer} buffer The buffer to clone.\n * @param {boolean} [isDeep] Specify a deep clone.\n * @returns {Buffer} Returns the cloned buffer.\n */\n function cloneBuffer(buffer, isDeep) {\n if (isDeep) {\n return buffer.slice();\n }\n var length = buffer.length,\n result = allocUnsafe ? allocUnsafe(length) : new buffer.constructor(length);\n\n buffer.copy(result);\n return result;\n }\n\n /**\n * Creates a clone of `arrayBuffer`.\n *\n * @private\n * @param {ArrayBuffer} arrayBuffer The array buffer to clone.\n * @returns {ArrayBuffer} Returns the cloned array buffer.\n */\n function cloneArrayBuffer(arrayBuffer) {\n var result = new arrayBuffer.constructor(arrayBuffer.byteLength);\n new Uint8Array(result).set(new Uint8Array(arrayBuffer));\n return result;\n }\n\n /**\n * Creates a clone of `dataView`.\n *\n * @private\n * @param {Object} dataView The data view to clone.\n * @param {boolean} [isDeep] Specify a deep clone.\n * @returns {Object} Returns the cloned data view.\n */\n function cloneDataView(dataView, isDeep) {\n var buffer = isDeep ? cloneArrayBuffer(dataView.buffer) : dataView.buffer;\n return new dataView.constructor(buffer, dataView.byteOffset, dataView.byteLength);\n }\n\n /**\n * Creates a clone of `regexp`.\n *\n * @private\n * @param {Object} regexp The regexp to clone.\n * @returns {Object} Returns the cloned regexp.\n */\n function cloneRegExp(regexp) {\n var result = new regexp.constructor(regexp.source, reFlags.exec(regexp));\n result.lastIndex = regexp.lastIndex;\n return result;\n }\n\n /**\n * Creates a clone of the `symbol` object.\n *\n * @private\n * @param {Object} symbol The symbol object to clone.\n * @returns {Object} Returns the cloned symbol object.\n */\n function cloneSymbol(symbol) {\n return symbolValueOf ? Object(symbolValueOf.call(symbol)) : {};\n }\n\n /**\n * Creates a clone of `typedArray`.\n *\n * @private\n * @param {Object} typedArray The typed array to clone.\n * @param {boolean} [isDeep] Specify a deep clone.\n * @returns {Object} Returns the cloned typed array.\n */\n function cloneTypedArray(typedArray, isDeep) {\n var buffer = isDeep ? cloneArrayBuffer(typedArray.buffer) : typedArray.buffer;\n return new typedArray.constructor(buffer, typedArray.byteOffset, typedArray.length);\n }\n\n /**\n * Compares values to sort them in ascending order.\n *\n * @private\n * @param {*} value The value to compare.\n * @param {*} other The other value to compare.\n * @returns {number} Returns the sort order indicator for `value`.\n */\n function compareAscending(value, other) {\n if (value !== other) {\n var valIsDefined = value !== undefined,\n valIsNull = value === null,\n valIsReflexive = value === value,\n valIsSymbol = isSymbol(value);\n\n var othIsDefined = other !== undefined,\n othIsNull = other === null,\n othIsReflexive = other === other,\n othIsSymbol = isSymbol(other);\n\n if ((!othIsNull && !othIsSymbol && !valIsSymbol && value > other) ||\n (valIsSymbol && othIsDefined && othIsReflexive && !othIsNull && !othIsSymbol) ||\n (valIsNull && othIsDefined && othIsReflexive) ||\n (!valIsDefined && othIsReflexive) ||\n !valIsReflexive) {\n return 1;\n }\n if ((!valIsNull && !valIsSymbol && !othIsSymbol && value < other) ||\n (othIsSymbol && valIsDefined && valIsReflexive && !valIsNull && !valIsSymbol) ||\n (othIsNull && valIsDefined && valIsReflexive) ||\n (!othIsDefined && valIsReflexive) ||\n !othIsReflexive) {\n return -1;\n }\n }\n return 0;\n }\n\n /**\n * Used by `_.orderBy` to compare multiple properties of a value to another\n * and stable sort them.\n *\n * If `orders` is unspecified, all values are sorted in ascending order. Otherwise,\n * specify an order of \"desc\" for descending or \"asc\" for ascending sort order\n * of corresponding values.\n *\n * @private\n * @param {Object} object The object to compare.\n * @param {Object} other The other object to compare.\n * @param {boolean[]|string[]} orders The order to sort by for each property.\n * @returns {number} Returns the sort order indicator for `object`.\n */\n function compareMultiple(object, other, orders) {\n var index = -1,\n objCriteria = object.criteria,\n othCriteria = other.criteria,\n length = objCriteria.length,\n ordersLength = orders.length;\n\n while (++index < length) {\n var result = compareAscending(objCriteria[index], othCriteria[index]);\n if (result) {\n if (index >= ordersLength) {\n return result;\n }\n var order = orders[index];\n return result * (order == 'desc' ? -1 : 1);\n }\n }\n // Fixes an `Array#sort` bug in the JS engine embedded in Adobe applications\n // that causes it, under certain circumstances, to provide the same value for\n // `object` and `other`. See https://github.com/jashkenas/underscore/pull/1247\n // for more details.\n //\n // This also ensures a stable sort in V8 and other engines.\n // See https://bugs.chromium.org/p/v8/issues/detail?id=90 for more details.\n return object.index - other.index;\n }\n\n /**\n * Creates an array that is the composition of partially applied arguments,\n * placeholders, and provided arguments into a single array of arguments.\n *\n * @private\n * @param {Array} args The provided arguments.\n * @param {Array} partials The arguments to prepend to those provided.\n * @param {Array} holders The `partials` placeholder indexes.\n * @params {boolean} [isCurried] Specify composing for a curried function.\n * @returns {Array} Returns the new array of composed arguments.\n */\n function composeArgs(args, partials, holders, isCurried) {\n var argsIndex = -1,\n argsLength = args.length,\n holdersLength = holders.length,\n leftIndex = -1,\n leftLength = partials.length,\n rangeLength = nativeMax(argsLength - holdersLength, 0),\n result = Array(leftLength + rangeLength),\n isUncurried = !isCurried;\n\n while (++leftIndex < leftLength) {\n result[leftIndex] = partials[leftIndex];\n }\n while (++argsIndex < holdersLength) {\n if (isUncurried || argsIndex < argsLength) {\n result[holders[argsIndex]] = args[argsIndex];\n }\n }\n while (rangeLength--) {\n result[leftIndex++] = args[argsIndex++];\n }\n return result;\n }\n\n /**\n * This function is like `composeArgs` except that the arguments composition\n * is tailored for `_.partialRight`.\n *\n * @private\n * @param {Array} args The provided arguments.\n * @param {Array} partials The arguments to append to those provided.\n * @param {Array} holders The `partials` placeholder indexes.\n * @params {boolean} [isCurried] Specify composing for a curried function.\n * @returns {Array} Returns the new array of composed arguments.\n */\n function composeArgsRight(args, partials, holders, isCurried) {\n var argsIndex = -1,\n argsLength = args.length,\n holdersIndex = -1,\n holdersLength = holders.length,\n rightIndex = -1,\n rightLength = partials.length,\n rangeLength = nativeMax(argsLength - holdersLength, 0),\n result = Array(rangeLength + rightLength),\n isUncurried = !isCurried;\n\n while (++argsIndex < rangeLength) {\n result[argsIndex] = args[argsIndex];\n }\n var offset = argsIndex;\n while (++rightIndex < rightLength) {\n result[offset + rightIndex] = partials[rightIndex];\n }\n while (++holdersIndex < holdersLength) {\n if (isUncurried || argsIndex < argsLength) {\n result[offset + holders[holdersIndex]] = args[argsIndex++];\n }\n }\n return result;\n }\n\n /**\n * Copies the values of `source` to `array`.\n *\n * @private\n * @param {Array} source The array to copy values from.\n * @param {Array} [array=[]] The array to copy values to.\n * @returns {Array} Returns `array`.\n */\n function copyArray(source, array) {\n var index = -1,\n length = source.length;\n\n array || (array = Array(length));\n while (++index < length) {\n array[index] = source[index];\n }\n return array;\n }\n\n /**\n * Copies properties of `source` to `object`.\n *\n * @private\n * @param {Object} source The object to copy properties from.\n * @param {Array} props The property identifiers to copy.\n * @param {Object} [object={}] The object to copy properties to.\n * @param {Function} [customizer] The function to customize copied values.\n * @returns {Object} Returns `object`.\n */\n function copyObject(source, props, object, customizer) {\n var isNew = !object;\n object || (object = {});\n\n var index = -1,\n length = props.length;\n\n while (++index < length) {\n var key = props[index];\n\n var newValue = customizer\n ? customizer(object[key], source[key], key, object, source)\n : undefined;\n\n if (newValue === undefined) {\n newValue = source[key];\n }\n if (isNew) {\n baseAssignValue(object, key, newValue);\n } else {\n assignValue(object, key, newValue);\n }\n }\n return object;\n }\n\n /**\n * Copies own symbols of `source` to `object`.\n *\n * @private\n * @param {Object} source The object to copy symbols from.\n * @param {Object} [object={}] The object to copy symbols to.\n * @returns {Object} Returns `object`.\n */\n function copySymbols(source, object) {\n return copyObject(source, getSymbols(source), object);\n }\n\n /**\n * Copies own and inherited symbols of `source` to `object`.\n *\n * @private\n * @param {Object} source The object to copy symbols from.\n * @param {Object} [object={}] The object to copy symbols to.\n * @returns {Object} Returns `object`.\n */\n function copySymbolsIn(source, object) {\n return copyObject(source, getSymbolsIn(source), object);\n }\n\n /**\n * Creates a function like `_.groupBy`.\n *\n * @private\n * @param {Function} setter The function to set accumulator values.\n * @param {Function} [initializer] The accumulator object initializer.\n * @returns {Function} Returns the new aggregator function.\n */\n function createAggregator(setter, initializer) {\n return function(collection, iteratee) {\n var func = isArray(collection) ? arrayAggregator : baseAggregator,\n accumulator = initializer ? initializer() : {};\n\n return func(collection, setter, getIteratee(iteratee, 2), accumulator);\n };\n }\n\n /**\n * Creates a function like `_.assign`.\n *\n * @private\n * @param {Function} assigner The function to assign values.\n * @returns {Function} Returns the new assigner function.\n */\n function createAssigner(assigner) {\n return baseRest(function(object, sources) {\n var index = -1,\n length = sources.length,\n customizer = length > 1 ? sources[length - 1] : undefined,\n guard = length > 2 ? sources[2] : undefined;\n\n customizer = (assigner.length > 3 && typeof customizer == 'function')\n ? (length--, customizer)\n : undefined;\n\n if (guard && isIterateeCall(sources[0], sources[1], guard)) {\n customizer = length < 3 ? undefined : customizer;\n length = 1;\n }\n object = Object(object);\n while (++index < length) {\n var source = sources[index];\n if (source) {\n assigner(object, source, index, customizer);\n }\n }\n return object;\n });\n }\n\n /**\n * Creates a `baseEach` or `baseEachRight` function.\n *\n * @private\n * @param {Function} eachFunc The function to iterate over a collection.\n * @param {boolean} [fromRight] Specify iterating from right to left.\n * @returns {Function} Returns the new base function.\n */\n function createBaseEach(eachFunc, fromRight) {\n return function(collection, iteratee) {\n if (collection == null) {\n return collection;\n }\n if (!isArrayLike(collection)) {\n return eachFunc(collection, iteratee);\n }\n var length = collection.length,\n index = fromRight ? length : -1,\n iterable = Object(collection);\n\n while ((fromRight ? index-- : ++index < length)) {\n if (iteratee(iterable[index], index, iterable) === false) {\n break;\n }\n }\n return collection;\n };\n }\n\n /**\n * Creates a base function for methods like `_.forIn` and `_.forOwn`.\n *\n * @private\n * @param {boolean} [fromRight] Specify iterating from right to left.\n * @returns {Function} Returns the new base function.\n */\n function createBaseFor(fromRight) {\n return function(object, iteratee, keysFunc) {\n var index = -1,\n iterable = Object(object),\n props = keysFunc(object),\n length = props.length;\n\n while (length--) {\n var key = props[fromRight ? length : ++index];\n if (iteratee(iterable[key], key, iterable) === false) {\n break;\n }\n }\n return object;\n };\n }\n\n /**\n * Creates a function that wraps `func` to invoke it with the optional `this`\n * binding of `thisArg`.\n *\n * @private\n * @param {Function} func The function to wrap.\n * @param {number} bitmask The bitmask flags. See `createWrap` for more details.\n * @param {*} [thisArg] The `this` binding of `func`.\n * @returns {Function} Returns the new wrapped function.\n */\n function createBind(func, bitmask, thisArg) {\n var isBind = bitmask & WRAP_BIND_FLAG,\n Ctor = createCtor(func);\n\n function wrapper() {\n var fn = (this && this !== root && this instanceof wrapper) ? Ctor : func;\n return fn.apply(isBind ? thisArg : this, arguments);\n }\n return wrapper;\n }\n\n /**\n * Creates a function like `_.lowerFirst`.\n *\n * @private\n * @param {string} methodName The name of the `String` case method to use.\n * @returns {Function} Returns the new case function.\n */\n function createCaseFirst(methodName) {\n return function(string) {\n string = toString(string);\n\n var strSymbols = hasUnicode(string)\n ? stringToArray(string)\n : undefined;\n\n var chr = strSymbols\n ? strSymbols[0]\n : string.charAt(0);\n\n var trailing = strSymbols\n ? castSlice(strSymbols, 1).join('')\n : string.slice(1);\n\n return chr[methodName]() + trailing;\n };\n }\n\n /**\n * Creates a function like `_.camelCase`.\n *\n * @private\n * @param {Function} callback The function to combine each word.\n * @returns {Function} Returns the new compounder function.\n */\n function createCompounder(callback) {\n return function(string) {\n return arrayReduce(words(deburr(string).replace(reApos, '')), callback, '');\n };\n }\n\n /**\n * Creates a function that produces an instance of `Ctor` regardless of\n * whether it was invoked as part of a `new` expression or by `call` or `apply`.\n *\n * @private\n * @param {Function} Ctor The constructor to wrap.\n * @returns {Function} Returns the new wrapped function.\n */\n function createCtor(Ctor) {\n return function() {\n // Use a `switch` statement to work with class constructors. See\n // http://ecma-international.org/ecma-262/7.0/#sec-ecmascript-function-objects-call-thisargument-argumentslist\n // for more details.\n var args = arguments;\n switch (args.length) {\n case 0: return new Ctor;\n case 1: return new Ctor(args[0]);\n case 2: return new Ctor(args[0], args[1]);\n case 3: return new Ctor(args[0], args[1], args[2]);\n case 4: return new Ctor(args[0], args[1], args[2], args[3]);\n case 5: return new Ctor(args[0], args[1], args[2], args[3], args[4]);\n case 6: return new Ctor(args[0], args[1], args[2], args[3], args[4], args[5]);\n case 7: return new Ctor(args[0], args[1], args[2], args[3], args[4], args[5], args[6]);\n }\n var thisBinding = baseCreate(Ctor.prototype),\n result = Ctor.apply(thisBinding, args);\n\n // Mimic the constructor's `return` behavior.\n // See https://es5.github.io/#x13.2.2 for more details.\n return isObject(result) ? result : thisBinding;\n };\n }\n\n /**\n * Creates a function that wraps `func` to enable currying.\n *\n * @private\n * @param {Function} func The function to wrap.\n * @param {number} bitmask The bitmask flags. See `createWrap` for more details.\n * @param {number} arity The arity of `func`.\n * @returns {Function} Returns the new wrapped function.\n */\n function createCurry(func, bitmask, arity) {\n var Ctor = createCtor(func);\n\n function wrapper() {\n var length = arguments.length,\n args = Array(length),\n index = length,\n placeholder = getHolder(wrapper);\n\n while (index--) {\n args[index] = arguments[index];\n }\n var holders = (length < 3 && args[0] !== placeholder && args[length - 1] !== placeholder)\n ? []\n : replaceHolders(args, placeholder);\n\n length -= holders.length;\n if (length < arity) {\n return createRecurry(\n func, bitmask, createHybrid, wrapper.placeholder, undefined,\n args, holders, undefined, undefined, arity - length);\n }\n var fn = (this && this !== root && this instanceof wrapper) ? Ctor : func;\n return apply(fn, this, args);\n }\n return wrapper;\n }\n\n /**\n * Creates a `_.find` or `_.findLast` function.\n *\n * @private\n * @param {Function} findIndexFunc The function to find the collection index.\n * @returns {Function} Returns the new find function.\n */\n function createFind(findIndexFunc) {\n return function(collection, predicate, fromIndex) {\n var iterable = Object(collection);\n if (!isArrayLike(collection)) {\n var iteratee = getIteratee(predicate, 3);\n collection = keys(collection);\n predicate = function(key) { return iteratee(iterable[key], key, iterable); };\n }\n var index = findIndexFunc(collection, predicate, fromIndex);\n return index > -1 ? iterable[iteratee ? collection[index] : index] : undefined;\n };\n }\n\n /**\n * Creates a `_.flow` or `_.flowRight` function.\n *\n * @private\n * @param {boolean} [fromRight] Specify iterating from right to left.\n * @returns {Function} Returns the new flow function.\n */\n function createFlow(fromRight) {\n return flatRest(function(funcs) {\n var length = funcs.length,\n index = length,\n prereq = LodashWrapper.prototype.thru;\n\n if (fromRight) {\n funcs.reverse();\n }\n while (index--) {\n var func = funcs[index];\n if (typeof func != 'function') {\n throw new TypeError(FUNC_ERROR_TEXT);\n }\n if (prereq && !wrapper && getFuncName(func) == 'wrapper') {\n var wrapper = new LodashWrapper([], true);\n }\n }\n index = wrapper ? index : length;\n while (++index < length) {\n func = funcs[index];\n\n var funcName = getFuncName(func),\n data = funcName == 'wrapper' ? getData(func) : undefined;\n\n if (data && isLaziable(data[0]) &&\n data[1] == (WRAP_ARY_FLAG | WRAP_CURRY_FLAG | WRAP_PARTIAL_FLAG | WRAP_REARG_FLAG) &&\n !data[4].length && data[9] == 1\n ) {\n wrapper = wrapper[getFuncName(data[0])].apply(wrapper, data[3]);\n } else {\n wrapper = (func.length == 1 && isLaziable(func))\n ? wrapper[funcName]()\n : wrapper.thru(func);\n }\n }\n return function() {\n var args = arguments,\n value = args[0];\n\n if (wrapper && args.length == 1 && isArray(value)) {\n return wrapper.plant(value).value();\n }\n var index = 0,\n result = length ? funcs[index].apply(this, args) : value;\n\n while (++index < length) {\n result = funcs[index].call(this, result);\n }\n return result;\n };\n });\n }\n\n /**\n * Creates a function that wraps `func` to invoke it with optional `this`\n * binding of `thisArg`, partial application, and currying.\n *\n * @private\n * @param {Function|string} func The function or method name to wrap.\n * @param {number} bitmask The bitmask flags. See `createWrap` for more details.\n * @param {*} [thisArg] The `this` binding of `func`.\n * @param {Array} [partials] The arguments to prepend to those provided to\n * the new function.\n * @param {Array} [holders] The `partials` placeholder indexes.\n * @param {Array} [partialsRight] The arguments to append to those provided\n * to the new function.\n * @param {Array} [holdersRight] The `partialsRight` placeholder indexes.\n * @param {Array} [argPos] The argument positions of the new function.\n * @param {number} [ary] The arity cap of `func`.\n * @param {number} [arity] The arity of `func`.\n * @returns {Function} Returns the new wrapped function.\n */\n function createHybrid(func, bitmask, thisArg, partials, holders, partialsRight, holdersRight, argPos, ary, arity) {\n var isAry = bitmask & WRAP_ARY_FLAG,\n isBind = bitmask & WRAP_BIND_FLAG,\n isBindKey = bitmask & WRAP_BIND_KEY_FLAG,\n isCurried = bitmask & (WRAP_CURRY_FLAG | WRAP_CURRY_RIGHT_FLAG),\n isFlip = bitmask & WRAP_FLIP_FLAG,\n Ctor = isBindKey ? undefined : createCtor(func);\n\n function wrapper() {\n var length = arguments.length,\n args = Array(length),\n index = length;\n\n while (index--) {\n args[index] = arguments[index];\n }\n if (isCurried) {\n var placeholder = getHolder(wrapper),\n holdersCount = countHolders(args, placeholder);\n }\n if (partials) {\n args = composeArgs(args, partials, holders, isCurried);\n }\n if (partialsRight) {\n args = composeArgsRight(args, partialsRight, holdersRight, isCurried);\n }\n length -= holdersCount;\n if (isCurried && length < arity) {\n var newHolders = replaceHolders(args, placeholder);\n return createRecurry(\n func, bitmask, createHybrid, wrapper.placeholder, thisArg,\n args, newHolders, argPos, ary, arity - length\n );\n }\n var thisBinding = isBind ? thisArg : this,\n fn = isBindKey ? thisBinding[func] : func;\n\n length = args.length;\n if (argPos) {\n args = reorder(args, argPos);\n } else if (isFlip && length > 1) {\n args.reverse();\n }\n if (isAry && ary < length) {\n args.length = ary;\n }\n if (this && this !== root && this instanceof wrapper) {\n fn = Ctor || createCtor(fn);\n }\n return fn.apply(thisBinding, args);\n }\n return wrapper;\n }\n\n /**\n * Creates a function like `_.invertBy`.\n *\n * @private\n * @param {Function} setter The function to set accumulator values.\n * @param {Function} toIteratee The function to resolve iteratees.\n * @returns {Function} Returns the new inverter function.\n */\n function createInverter(setter, toIteratee) {\n return function(object, iteratee) {\n return baseInverter(object, setter, toIteratee(iteratee), {});\n };\n }\n\n /**\n * Creates a function that performs a mathematical operation on two values.\n *\n * @private\n * @param {Function} operator The function to perform the operation.\n * @param {number} [defaultValue] The value used for `undefined` arguments.\n * @returns {Function} Returns the new mathematical operation function.\n */\n function createMathOperation(operator, defaultValue) {\n return function(value, other) {\n var result;\n if (value === undefined && other === undefined) {\n return defaultValue;\n }\n if (value !== undefined) {\n result = value;\n }\n if (other !== undefined) {\n if (result === undefined) {\n return other;\n }\n if (typeof value == 'string' || typeof other == 'string') {\n value = baseToString(value);\n other = baseToString(other);\n } else {\n value = baseToNumber(value);\n other = baseToNumber(other);\n }\n result = operator(value, other);\n }\n return result;\n };\n }\n\n /**\n * Creates a function like `_.over`.\n *\n * @private\n * @param {Function} arrayFunc The function to iterate over iteratees.\n * @returns {Function} Returns the new over function.\n */\n function createOver(arrayFunc) {\n return flatRest(function(iteratees) {\n iteratees = arrayMap(iteratees, baseUnary(getIteratee()));\n return baseRest(function(args) {\n var thisArg = this;\n return arrayFunc(iteratees, function(iteratee) {\n return apply(iteratee, thisArg, args);\n });\n });\n });\n }\n\n /**\n * Creates the padding for `string` based on `length`. The `chars` string\n * is truncated if the number of characters exceeds `length`.\n *\n * @private\n * @param {number} length The padding length.\n * @param {string} [chars=' '] The string used as padding.\n * @returns {string} Returns the padding for `string`.\n */\n function createPadding(length, chars) {\n chars = chars === undefined ? ' ' : baseToString(chars);\n\n var charsLength = chars.length;\n if (charsLength < 2) {\n return charsLength ? baseRepeat(chars, length) : chars;\n }\n var result = baseRepeat(chars, nativeCeil(length / stringSize(chars)));\n return hasUnicode(chars)\n ? castSlice(stringToArray(result), 0, length).join('')\n : result.slice(0, length);\n }\n\n /**\n * Creates a function that wraps `func` to invoke it with the `this` binding\n * of `thisArg` and `partials` prepended to the arguments it receives.\n *\n * @private\n * @param {Function} func The function to wrap.\n * @param {number} bitmask The bitmask flags. See `createWrap` for more details.\n * @param {*} thisArg The `this` binding of `func`.\n * @param {Array} partials The arguments to prepend to those provided to\n * the new function.\n * @returns {Function} Returns the new wrapped function.\n */\n function createPartial(func, bitmask, thisArg, partials) {\n var isBind = bitmask & WRAP_BIND_FLAG,\n Ctor = createCtor(func);\n\n function wrapper() {\n var argsIndex = -1,\n argsLength = arguments.length,\n leftIndex = -1,\n leftLength = partials.length,\n args = Array(leftLength + argsLength),\n fn = (this && this !== root && this instanceof wrapper) ? Ctor : func;\n\n while (++leftIndex < leftLength) {\n args[leftIndex] = partials[leftIndex];\n }\n while (argsLength--) {\n args[leftIndex++] = arguments[++argsIndex];\n }\n return apply(fn, isBind ? thisArg : this, args);\n }\n return wrapper;\n }\n\n /**\n * Creates a `_.range` or `_.rangeRight` function.\n *\n * @private\n * @param {boolean} [fromRight] Specify iterating from right to left.\n * @returns {Function} Returns the new range function.\n */\n function createRange(fromRight) {\n return function(start, end, step) {\n if (step && typeof step != 'number' && isIterateeCall(start, end, step)) {\n end = step = undefined;\n }\n // Ensure the sign of `-0` is preserved.\n start = toFinite(start);\n if (end === undefined) {\n end = start;\n start = 0;\n } else {\n end = toFinite(end);\n }\n step = step === undefined ? (start < end ? 1 : -1) : toFinite(step);\n return baseRange(start, end, step, fromRight);\n };\n }\n\n /**\n * Creates a function that performs a relational operation on two values.\n *\n * @private\n * @param {Function} operator The function to perform the operation.\n * @returns {Function} Returns the new relational operation function.\n */\n function createRelationalOperation(operator) {\n return function(value, other) {\n if (!(typeof value == 'string' && typeof other == 'string')) {\n value = toNumber(value);\n other = toNumber(other);\n }\n return operator(value, other);\n };\n }\n\n /**\n * Creates a function that wraps `func` to continue currying.\n *\n * @private\n * @param {Function} func The function to wrap.\n * @param {number} bitmask The bitmask flags. See `createWrap` for more details.\n * @param {Function} wrapFunc The function to create the `func` wrapper.\n * @param {*} placeholder The placeholder value.\n * @param {*} [thisArg] The `this` binding of `func`.\n * @param {Array} [partials] The arguments to prepend to those provided to\n * the new function.\n * @param {Array} [holders] The `partials` placeholder indexes.\n * @param {Array} [argPos] The argument positions of the new function.\n * @param {number} [ary] The arity cap of `func`.\n * @param {number} [arity] The arity of `func`.\n * @returns {Function} Returns the new wrapped function.\n */\n function createRecurry(func, bitmask, wrapFunc, placeholder, thisArg, partials, holders, argPos, ary, arity) {\n var isCurry = bitmask & WRAP_CURRY_FLAG,\n newHolders = isCurry ? holders : undefined,\n newHoldersRight = isCurry ? undefined : holders,\n newPartials = isCurry ? partials : undefined,\n newPartialsRight = isCurry ? undefined : partials;\n\n bitmask |= (isCurry ? WRAP_PARTIAL_FLAG : WRAP_PARTIAL_RIGHT_FLAG);\n bitmask &= ~(isCurry ? WRAP_PARTIAL_RIGHT_FLAG : WRAP_PARTIAL_FLAG);\n\n if (!(bitmask & WRAP_CURRY_BOUND_FLAG)) {\n bitmask &= ~(WRAP_BIND_FLAG | WRAP_BIND_KEY_FLAG);\n }\n var newData = [\n func, bitmask, thisArg, newPartials, newHolders, newPartialsRight,\n newHoldersRight, argPos, ary, arity\n ];\n\n var result = wrapFunc.apply(undefined, newData);\n if (isLaziable(func)) {\n setData(result, newData);\n }\n result.placeholder = placeholder;\n return setWrapToString(result, func, bitmask);\n }\n\n /**\n * Creates a function like `_.round`.\n *\n * @private\n * @param {string} methodName The name of the `Math` method to use when rounding.\n * @returns {Function} Returns the new round function.\n */\n function createRound(methodName) {\n var func = Math[methodName];\n return function(number, precision) {\n number = toNumber(number);\n precision = precision == null ? 0 : nativeMin(toInteger(precision), 292);\n if (precision && nativeIsFinite(number)) {\n // Shift with exponential notation to avoid floating-point issues.\n // See [MDN](https://mdn.io/round#Examples) for more details.\n var pair = (toString(number) + 'e').split('e'),\n value = func(pair[0] + 'e' + (+pair[1] + precision));\n\n pair = (toString(value) + 'e').split('e');\n return +(pair[0] + 'e' + (+pair[1] - precision));\n }\n return func(number);\n };\n }\n\n /**\n * Creates a set object of `values`.\n *\n * @private\n * @param {Array} values The values to add to the set.\n * @returns {Object} Returns the new set.\n */\n var createSet = !(Set && (1 / setToArray(new Set([,-0]))[1]) == INFINITY) ? noop : function(values) {\n return new Set(values);\n };\n\n /**\n * Creates a `_.toPairs` or `_.toPairsIn` function.\n *\n * @private\n * @param {Function} keysFunc The function to get the keys of a given object.\n * @returns {Function} Returns the new pairs function.\n */\n function createToPairs(keysFunc) {\n return function(object) {\n var tag = getTag(object);\n if (tag == mapTag) {\n return mapToArray(object);\n }\n if (tag == setTag) {\n return setToPairs(object);\n }\n return baseToPairs(object, keysFunc(object));\n };\n }\n\n /**\n * Creates a function that either curries or invokes `func` with optional\n * `this` binding and partially applied arguments.\n *\n * @private\n * @param {Function|string} func The function or method name to wrap.\n * @param {number} bitmask The bitmask flags.\n * 1 - `_.bind`\n * 2 - `_.bindKey`\n * 4 - `_.curry` or `_.curryRight` of a bound function\n * 8 - `_.curry`\n * 16 - `_.curryRight`\n * 32 - `_.partial`\n * 64 - `_.partialRight`\n * 128 - `_.rearg`\n * 256 - `_.ary`\n * 512 - `_.flip`\n * @param {*} [thisArg] The `this` binding of `func`.\n * @param {Array} [partials] The arguments to be partially applied.\n * @param {Array} [holders] The `partials` placeholder indexes.\n * @param {Array} [argPos] The argument positions of the new function.\n * @param {number} [ary] The arity cap of `func`.\n * @param {number} [arity] The arity of `func`.\n * @returns {Function} Returns the new wrapped function.\n */\n function createWrap(func, bitmask, thisArg, partials, holders, argPos, ary, arity) {\n var isBindKey = bitmask & WRAP_BIND_KEY_FLAG;\n if (!isBindKey && typeof func != 'function') {\n throw new TypeError(FUNC_ERROR_TEXT);\n }\n var length = partials ? partials.length : 0;\n if (!length) {\n bitmask &= ~(WRAP_PARTIAL_FLAG | WRAP_PARTIAL_RIGHT_FLAG);\n partials = holders = undefined;\n }\n ary = ary === undefined ? ary : nativeMax(toInteger(ary), 0);\n arity = arity === undefined ? arity : toInteger(arity);\n length -= holders ? holders.length : 0;\n\n if (bitmask & WRAP_PARTIAL_RIGHT_FLAG) {\n var partialsRight = partials,\n holdersRight = holders;\n\n partials = holders = undefined;\n }\n var data = isBindKey ? undefined : getData(func);\n\n var newData = [\n func, bitmask, thisArg, partials, holders, partialsRight, holdersRight,\n argPos, ary, arity\n ];\n\n if (data) {\n mergeData(newData, data);\n }\n func = newData[0];\n bitmask = newData[1];\n thisArg = newData[2];\n partials = newData[3];\n holders = newData[4];\n arity = newData[9] = newData[9] === undefined\n ? (isBindKey ? 0 : func.length)\n : nativeMax(newData[9] - length, 0);\n\n if (!arity && bitmask & (WRAP_CURRY_FLAG | WRAP_CURRY_RIGHT_FLAG)) {\n bitmask &= ~(WRAP_CURRY_FLAG | WRAP_CURRY_RIGHT_FLAG);\n }\n if (!bitmask || bitmask == WRAP_BIND_FLAG) {\n var result = createBind(func, bitmask, thisArg);\n } else if (bitmask == WRAP_CURRY_FLAG || bitmask == WRAP_CURRY_RIGHT_FLAG) {\n result = createCurry(func, bitmask, arity);\n } else if ((bitmask == WRAP_PARTIAL_FLAG || bitmask == (WRAP_BIND_FLAG | WRAP_PARTIAL_FLAG)) && !holders.length) {\n result = createPartial(func, bitmask, thisArg, partials);\n } else {\n result = createHybrid.apply(undefined, newData);\n }\n var setter = data ? baseSetData : setData;\n return setWrapToString(setter(result, newData), func, bitmask);\n }\n\n /**\n * Used by `_.defaults` to customize its `_.assignIn` use to assign properties\n * of source objects to the destination object for all destination properties\n * that resolve to `undefined`.\n *\n * @private\n * @param {*} objValue The destination value.\n * @param {*} srcValue The source value.\n * @param {string} key The key of the property to assign.\n * @param {Object} object The parent object of `objValue`.\n * @returns {*} Returns the value to assign.\n */\n function customDefaultsAssignIn(objValue, srcValue, key, object) {\n if (objValue === undefined ||\n (eq(objValue, objectProto[key]) && !hasOwnProperty.call(object, key))) {\n return srcValue;\n }\n return objValue;\n }\n\n /**\n * Used by `_.defaultsDeep` to customize its `_.merge` use to merge source\n * objects into destination objects that are passed thru.\n *\n * @private\n * @param {*} objValue The destination value.\n * @param {*} srcValue The source value.\n * @param {string} key The key of the property to merge.\n * @param {Object} object The parent object of `objValue`.\n * @param {Object} source The parent object of `srcValue`.\n * @param {Object} [stack] Tracks traversed source values and their merged\n * counterparts.\n * @returns {*} Returns the value to assign.\n */\n function customDefaultsMerge(objValue, srcValue, key, object, source, stack) {\n if (isObject(objValue) && isObject(srcValue)) {\n // Recursively merge objects and arrays (susceptible to call stack limits).\n stack.set(srcValue, objValue);\n baseMerge(objValue, srcValue, undefined, customDefaultsMerge, stack);\n stack['delete'](srcValue);\n }\n return objValue;\n }\n\n /**\n * Used by `_.omit` to customize its `_.cloneDeep` use to only clone plain\n * objects.\n *\n * @private\n * @param {*} value The value to inspect.\n * @param {string} key The key of the property to inspect.\n * @returns {*} Returns the uncloned value or `undefined` to defer cloning to `_.cloneDeep`.\n */\n function customOmitClone(value) {\n return isPlainObject(value) ? undefined : value;\n }\n\n /**\n * A specialized version of `baseIsEqualDeep` for arrays with support for\n * partial deep comparisons.\n *\n * @private\n * @param {Array} array The array to compare.\n * @param {Array} other The other array to compare.\n * @param {number} bitmask The bitmask flags. See `baseIsEqual` for more details.\n * @param {Function} customizer The function to customize comparisons.\n * @param {Function} equalFunc The function to determine equivalents of values.\n * @param {Object} stack Tracks traversed `array` and `other` objects.\n * @returns {boolean} Returns `true` if the arrays are equivalent, else `false`.\n */\n function equalArrays(array, other, bitmask, customizer, equalFunc, stack) {\n var isPartial = bitmask & COMPARE_PARTIAL_FLAG,\n arrLength = array.length,\n othLength = other.length;\n\n if (arrLength != othLength && !(isPartial && othLength > arrLength)) {\n return false;\n }\n // Check that cyclic values are equal.\n var arrStacked = stack.get(array);\n var othStacked = stack.get(other);\n if (arrStacked && othStacked) {\n return arrStacked == other && othStacked == array;\n }\n var index = -1,\n result = true,\n seen = (bitmask & COMPARE_UNORDERED_FLAG) ? new SetCache : undefined;\n\n stack.set(array, other);\n stack.set(other, array);\n\n // Ignore non-index properties.\n while (++index < arrLength) {\n var arrValue = array[index],\n othValue = other[index];\n\n if (customizer) {\n var compared = isPartial\n ? customizer(othValue, arrValue, index, other, array, stack)\n : customizer(arrValue, othValue, index, array, other, stack);\n }\n if (compared !== undefined) {\n if (compared) {\n continue;\n }\n result = false;\n break;\n }\n // Recursively compare arrays (susceptible to call stack limits).\n if (seen) {\n if (!arraySome(other, function(othValue, othIndex) {\n if (!cacheHas(seen, othIndex) &&\n (arrValue === othValue || equalFunc(arrValue, othValue, bitmask, customizer, stack))) {\n return seen.push(othIndex);\n }\n })) {\n result = false;\n break;\n }\n } else if (!(\n arrValue === othValue ||\n equalFunc(arrValue, othValue, bitmask, customizer, stack)\n )) {\n result = false;\n break;\n }\n }\n stack['delete'](array);\n stack['delete'](other);\n return result;\n }\n\n /**\n * A specialized version of `baseIsEqualDeep` for comparing objects of\n * the same `toStringTag`.\n *\n * **Note:** This function only supports comparing values with tags of\n * `Boolean`, `Date`, `Error`, `Number`, `RegExp`, or `String`.\n *\n * @private\n * @param {Object} object The object to compare.\n * @param {Object} other The other object to compare.\n * @param {string} tag The `toStringTag` of the objects to compare.\n * @param {number} bitmask The bitmask flags. See `baseIsEqual` for more details.\n * @param {Function} customizer The function to customize comparisons.\n * @param {Function} equalFunc The function to determine equivalents of values.\n * @param {Object} stack Tracks traversed `object` and `other` objects.\n * @returns {boolean} Returns `true` if the objects are equivalent, else `false`.\n */\n function equalByTag(object, other, tag, bitmask, customizer, equalFunc, stack) {\n switch (tag) {\n case dataViewTag:\n if ((object.byteLength != other.byteLength) ||\n (object.byteOffset != other.byteOffset)) {\n return false;\n }\n object = object.buffer;\n other = other.buffer;\n\n case arrayBufferTag:\n if ((object.byteLength != other.byteLength) ||\n !equalFunc(new Uint8Array(object), new Uint8Array(other))) {\n return false;\n }\n return true;\n\n case boolTag:\n case dateTag:\n case numberTag:\n // Coerce booleans to `1` or `0` and dates to milliseconds.\n // Invalid dates are coerced to `NaN`.\n return eq(+object, +other);\n\n case errorTag:\n return object.name == other.name && object.message == other.message;\n\n case regexpTag:\n case stringTag:\n // Coerce regexes to strings and treat strings, primitives and objects,\n // as equal. See http://www.ecma-international.org/ecma-262/7.0/#sec-regexp.prototype.tostring\n // for more details.\n return object == (other + '');\n\n case mapTag:\n var convert = mapToArray;\n\n case setTag:\n var isPartial = bitmask & COMPARE_PARTIAL_FLAG;\n convert || (convert = setToArray);\n\n if (object.size != other.size && !isPartial) {\n return false;\n }\n // Assume cyclic values are equal.\n var stacked = stack.get(object);\n if (stacked) {\n return stacked == other;\n }\n bitmask |= COMPARE_UNORDERED_FLAG;\n\n // Recursively compare objects (susceptible to call stack limits).\n stack.set(object, other);\n var result = equalArrays(convert(object), convert(other), bitmask, customizer, equalFunc, stack);\n stack['delete'](object);\n return result;\n\n case symbolTag:\n if (symbolValueOf) {\n return symbolValueOf.call(object) == symbolValueOf.call(other);\n }\n }\n return false;\n }\n\n /**\n * A specialized version of `baseIsEqualDeep` for objects with support for\n * partial deep comparisons.\n *\n * @private\n * @param {Object} object The object to compare.\n * @param {Object} other The other object to compare.\n * @param {number} bitmask The bitmask flags. See `baseIsEqual` for more details.\n * @param {Function} customizer The function to customize comparisons.\n * @param {Function} equalFunc The function to determine equivalents of values.\n * @param {Object} stack Tracks traversed `object` and `other` objects.\n * @returns {boolean} Returns `true` if the objects are equivalent, else `false`.\n */\n function equalObjects(object, other, bitmask, customizer, equalFunc, stack) {\n var isPartial = bitmask & COMPARE_PARTIAL_FLAG,\n objProps = getAllKeys(object),\n objLength = objProps.length,\n othProps = getAllKeys(other),\n othLength = othProps.length;\n\n if (objLength != othLength && !isPartial) {\n return false;\n }\n var index = objLength;\n while (index--) {\n var key = objProps[index];\n if (!(isPartial ? key in other : hasOwnProperty.call(other, key))) {\n return false;\n }\n }\n // Check that cyclic values are equal.\n var objStacked = stack.get(object);\n var othStacked = stack.get(other);\n if (objStacked && othStacked) {\n return objStacked == other && othStacked == object;\n }\n var result = true;\n stack.set(object, other);\n stack.set(other, object);\n\n var skipCtor = isPartial;\n while (++index < objLength) {\n key = objProps[index];\n var objValue = object[key],\n othValue = other[key];\n\n if (customizer) {\n var compared = isPartial\n ? customizer(othValue, objValue, key, other, object, stack)\n : customizer(objValue, othValue, key, object, other, stack);\n }\n // Recursively compare objects (susceptible to call stack limits).\n if (!(compared === undefined\n ? (objValue === othValue || equalFunc(objValue, othValue, bitmask, customizer, stack))\n : compared\n )) {\n result = false;\n break;\n }\n skipCtor || (skipCtor = key == 'constructor');\n }\n if (result && !skipCtor) {\n var objCtor = object.constructor,\n othCtor = other.constructor;\n\n // Non `Object` object instances with different constructors are not equal.\n if (objCtor != othCtor &&\n ('constructor' in object && 'constructor' in other) &&\n !(typeof objCtor == 'function' && objCtor instanceof objCtor &&\n typeof othCtor == 'function' && othCtor instanceof othCtor)) {\n result = false;\n }\n }\n stack['delete'](object);\n stack['delete'](other);\n return result;\n }\n\n /**\n * A specialized version of `baseRest` which flattens the rest array.\n *\n * @private\n * @param {Function} func The function to apply a rest parameter to.\n * @returns {Function} Returns the new function.\n */\n function flatRest(func) {\n return setToString(overRest(func, undefined, flatten), func + '');\n }\n\n /**\n * Creates an array of own enumerable property names and symbols of `object`.\n *\n * @private\n * @param {Object} object The object to query.\n * @returns {Array} Returns the array of property names and symbols.\n */\n function getAllKeys(object) {\n return baseGetAllKeys(object, keys, getSymbols);\n }\n\n /**\n * Creates an array of own and inherited enumerable property names and\n * symbols of `object`.\n *\n * @private\n * @param {Object} object The object to query.\n * @returns {Array} Returns the array of property names and symbols.\n */\n function getAllKeysIn(object) {\n return baseGetAllKeys(object, keysIn, getSymbolsIn);\n }\n\n /**\n * Gets metadata for `func`.\n *\n * @private\n * @param {Function} func The function to query.\n * @returns {*} Returns the metadata for `func`.\n */\n var getData = !metaMap ? noop : function(func) {\n return metaMap.get(func);\n };\n\n /**\n * Gets the name of `func`.\n *\n * @private\n * @param {Function} func The function to query.\n * @returns {string} Returns the function name.\n */\n function getFuncName(func) {\n var result = (func.name + ''),\n array = realNames[result],\n length = hasOwnProperty.call(realNames, result) ? array.length : 0;\n\n while (length--) {\n var data = array[length],\n otherFunc = data.func;\n if (otherFunc == null || otherFunc == func) {\n return data.name;\n }\n }\n return result;\n }\n\n /**\n * Gets the argument placeholder value for `func`.\n *\n * @private\n * @param {Function} func The function to inspect.\n * @returns {*} Returns the placeholder value.\n */\n function getHolder(func) {\n var object = hasOwnProperty.call(lodash, 'placeholder') ? lodash : func;\n return object.placeholder;\n }\n\n /**\n * Gets the appropriate \"iteratee\" function. If `_.iteratee` is customized,\n * this function returns the custom method, otherwise it returns `baseIteratee`.\n * If arguments are provided, the chosen function is invoked with them and\n * its result is returned.\n *\n * @private\n * @param {*} [value] The value to convert to an iteratee.\n * @param {number} [arity] The arity of the created iteratee.\n * @returns {Function} Returns the chosen function or its result.\n */\n function getIteratee() {\n var result = lodash.iteratee || iteratee;\n result = result === iteratee ? baseIteratee : result;\n return arguments.length ? result(arguments[0], arguments[1]) : result;\n }\n\n /**\n * Gets the data for `map`.\n *\n * @private\n * @param {Object} map The map to query.\n * @param {string} key The reference key.\n * @returns {*} Returns the map data.\n */\n function getMapData(map, key) {\n var data = map.__data__;\n return isKeyable(key)\n ? data[typeof key == 'string' ? 'string' : 'hash']\n : data.map;\n }\n\n /**\n * Gets the property names, values, and compare flags of `object`.\n *\n * @private\n * @param {Object} object The object to query.\n * @returns {Array} Returns the match data of `object`.\n */\n function getMatchData(object) {\n var result = keys(object),\n length = result.length;\n\n while (length--) {\n var key = result[length],\n value = object[key];\n\n result[length] = [key, value, isStrictComparable(value)];\n }\n return result;\n }\n\n /**\n * Gets the native function at `key` of `object`.\n *\n * @private\n * @param {Object} object The object to query.\n * @param {string} key The key of the method to get.\n * @returns {*} Returns the function if it's native, else `undefined`.\n */\n function getNative(object, key) {\n var value = getValue(object, key);\n return baseIsNative(value) ? value : undefined;\n }\n\n /**\n * A specialized version of `baseGetTag` which ignores `Symbol.toStringTag` values.\n *\n * @private\n * @param {*} value The value to query.\n * @returns {string} Returns the raw `toStringTag`.\n */\n function getRawTag(value) {\n var isOwn = hasOwnProperty.call(value, symToStringTag),\n tag = value[symToStringTag];\n\n try {\n value[symToStringTag] = undefined;\n var unmasked = true;\n } catch (e) {}\n\n var result = nativeObjectToString.call(value);\n if (unmasked) {\n if (isOwn) {\n value[symToStringTag] = tag;\n } else {\n delete value[symToStringTag];\n }\n }\n return result;\n }\n\n /**\n * Creates an array of the own enumerable symbols of `object`.\n *\n * @private\n * @param {Object} object The object to query.\n * @returns {Array} Returns the array of symbols.\n */\n var getSymbols = !nativeGetSymbols ? stubArray : function(object) {\n if (object == null) {\n return [];\n }\n object = Object(object);\n return arrayFilter(nativeGetSymbols(object), function(symbol) {\n return propertyIsEnumerable.call(object, symbol);\n });\n };\n\n /**\n * Creates an array of the own and inherited enumerable symbols of `object`.\n *\n * @private\n * @param {Object} object The object to query.\n * @returns {Array} Returns the array of symbols.\n */\n var getSymbolsIn = !nativeGetSymbols ? stubArray : function(object) {\n var result = [];\n while (object) {\n arrayPush(result, getSymbols(object));\n object = getPrototype(object);\n }\n return result;\n };\n\n /**\n * Gets the `toStringTag` of `value`.\n *\n * @private\n * @param {*} value The value to query.\n * @returns {string} Returns the `toStringTag`.\n */\n var getTag = baseGetTag;\n\n // Fallback for data views, maps, sets, and weak maps in IE 11 and promises in Node.js < 6.\n if ((DataView && getTag(new DataView(new ArrayBuffer(1))) != dataViewTag) ||\n (Map && getTag(new Map) != mapTag) ||\n (Promise && getTag(Promise.resolve()) != promiseTag) ||\n (Set && getTag(new Set) != setTag) ||\n (WeakMap && getTag(new WeakMap) != weakMapTag)) {\n getTag = function(value) {\n var result = baseGetTag(value),\n Ctor = result == objectTag ? value.constructor : undefined,\n ctorString = Ctor ? toSource(Ctor) : '';\n\n if (ctorString) {\n switch (ctorString) {\n case dataViewCtorString: return dataViewTag;\n case mapCtorString: return mapTag;\n case promiseCtorString: return promiseTag;\n case setCtorString: return setTag;\n case weakMapCtorString: return weakMapTag;\n }\n }\n return result;\n };\n }\n\n /**\n * Gets the view, applying any `transforms` to the `start` and `end` positions.\n *\n * @private\n * @param {number} start The start of the view.\n * @param {number} end The end of the view.\n * @param {Array} transforms The transformations to apply to the view.\n * @returns {Object} Returns an object containing the `start` and `end`\n * positions of the view.\n */\n function getView(start, end, transforms) {\n var index = -1,\n length = transforms.length;\n\n while (++index < length) {\n var data = transforms[index],\n size = data.size;\n\n switch (data.type) {\n case 'drop': start += size; break;\n case 'dropRight': end -= size; break;\n case 'take': end = nativeMin(end, start + size); break;\n case 'takeRight': start = nativeMax(start, end - size); break;\n }\n }\n return { 'start': start, 'end': end };\n }\n\n /**\n * Extracts wrapper details from the `source` body comment.\n *\n * @private\n * @param {string} source The source to inspect.\n * @returns {Array} Returns the wrapper details.\n */\n function getWrapDetails(source) {\n var match = source.match(reWrapDetails);\n return match ? match[1].split(reSplitDetails) : [];\n }\n\n /**\n * Checks if `path` exists on `object`.\n *\n * @private\n * @param {Object} object The object to query.\n * @param {Array|string} path The path to check.\n * @param {Function} hasFunc The function to check properties.\n * @returns {boolean} Returns `true` if `path` exists, else `false`.\n */\n function hasPath(object, path, hasFunc) {\n path = castPath(path, object);\n\n var index = -1,\n length = path.length,\n result = false;\n\n while (++index < length) {\n var key = toKey(path[index]);\n if (!(result = object != null && hasFunc(object, key))) {\n break;\n }\n object = object[key];\n }\n if (result || ++index != length) {\n return result;\n }\n length = object == null ? 0 : object.length;\n return !!length && isLength(length) && isIndex(key, length) &&\n (isArray(object) || isArguments(object));\n }\n\n /**\n * Initializes an array clone.\n *\n * @private\n * @param {Array} array The array to clone.\n * @returns {Array} Returns the initialized clone.\n */\n function initCloneArray(array) {\n var length = array.length,\n result = new array.constructor(length);\n\n // Add properties assigned by `RegExp#exec`.\n if (length && typeof array[0] == 'string' && hasOwnProperty.call(array, 'index')) {\n result.index = array.index;\n result.input = array.input;\n }\n return result;\n }\n\n /**\n * Initializes an object clone.\n *\n * @private\n * @param {Object} object The object to clone.\n * @returns {Object} Returns the initialized clone.\n */\n function initCloneObject(object) {\n return (typeof object.constructor == 'function' && !isPrototype(object))\n ? baseCreate(getPrototype(object))\n : {};\n }\n\n /**\n * Initializes an object clone based on its `toStringTag`.\n *\n * **Note:** This function only supports cloning values with tags of\n * `Boolean`, `Date`, `Error`, `Map`, `Number`, `RegExp`, `Set`, or `String`.\n *\n * @private\n * @param {Object} object The object to clone.\n * @param {string} tag The `toStringTag` of the object to clone.\n * @param {boolean} [isDeep] Specify a deep clone.\n * @returns {Object} Returns the initialized clone.\n */\n function initCloneByTag(object, tag, isDeep) {\n var Ctor = object.constructor;\n switch (tag) {\n case arrayBufferTag:\n return cloneArrayBuffer(object);\n\n case boolTag:\n case dateTag:\n return new Ctor(+object);\n\n case dataViewTag:\n return cloneDataView(object, isDeep);\n\n case float32Tag: case float64Tag:\n case int8Tag: case int16Tag: case int32Tag:\n case uint8Tag: case uint8ClampedTag: case uint16Tag: case uint32Tag:\n return cloneTypedArray(object, isDeep);\n\n case mapTag:\n return new Ctor;\n\n case numberTag:\n case stringTag:\n return new Ctor(object);\n\n case regexpTag:\n return cloneRegExp(object);\n\n case setTag:\n return new Ctor;\n\n case symbolTag:\n return cloneSymbol(object);\n }\n }\n\n /**\n * Inserts wrapper `details` in a comment at the top of the `source` body.\n *\n * @private\n * @param {string} source The source to modify.\n * @returns {Array} details The details to insert.\n * @returns {string} Returns the modified source.\n */\n function insertWrapDetails(source, details) {\n var length = details.length;\n if (!length) {\n return source;\n }\n var lastIndex = length - 1;\n details[lastIndex] = (length > 1 ? '& ' : '') + details[lastIndex];\n details = details.join(length > 2 ? ', ' : ' ');\n return source.replace(reWrapComment, '{\\n/* [wrapped with ' + details + '] */\\n');\n }\n\n /**\n * Checks if `value` is a flattenable `arguments` object or array.\n *\n * @private\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is flattenable, else `false`.\n */\n function isFlattenable(value) {\n return isArray(value) || isArguments(value) ||\n !!(spreadableSymbol && value && value[spreadableSymbol]);\n }\n\n /**\n * Checks if `value` is a valid array-like index.\n *\n * @private\n * @param {*} value The value to check.\n * @param {number} [length=MAX_SAFE_INTEGER] The upper bounds of a valid index.\n * @returns {boolean} Returns `true` if `value` is a valid index, else `false`.\n */\n function isIndex(value, length) {\n var type = typeof value;\n length = length == null ? MAX_SAFE_INTEGER : length;\n\n return !!length &&\n (type == 'number' ||\n (type != 'symbol' && reIsUint.test(value))) &&\n (value > -1 && value % 1 == 0 && value < length);\n }\n\n /**\n * Checks if the given arguments are from an iteratee call.\n *\n * @private\n * @param {*} value The potential iteratee value argument.\n * @param {*} index The potential iteratee index or key argument.\n * @param {*} object The potential iteratee object argument.\n * @returns {boolean} Returns `true` if the arguments are from an iteratee call,\n * else `false`.\n */\n function isIterateeCall(value, index, object) {\n if (!isObject(object)) {\n return false;\n }\n var type = typeof index;\n if (type == 'number'\n ? (isArrayLike(object) && isIndex(index, object.length))\n : (type == 'string' && index in object)\n ) {\n return eq(object[index], value);\n }\n return false;\n }\n\n /**\n * Checks if `value` is a property name and not a property path.\n *\n * @private\n * @param {*} value The value to check.\n * @param {Object} [object] The object to query keys on.\n * @returns {boolean} Returns `true` if `value` is a property name, else `false`.\n */\n function isKey(value, object) {\n if (isArray(value)) {\n return false;\n }\n var type = typeof value;\n if (type == 'number' || type == 'symbol' || type == 'boolean' ||\n value == null || isSymbol(value)) {\n return true;\n }\n return reIsPlainProp.test(value) || !reIsDeepProp.test(value) ||\n (object != null && value in Object(object));\n }\n\n /**\n * Checks if `value` is suitable for use as unique object key.\n *\n * @private\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is suitable, else `false`.\n */\n function isKeyable(value) {\n var type = typeof value;\n return (type == 'string' || type == 'number' || type == 'symbol' || type == 'boolean')\n ? (value !== '__proto__')\n : (value === null);\n }\n\n /**\n * Checks if `func` has a lazy counterpart.\n *\n * @private\n * @param {Function} func The function to check.\n * @returns {boolean} Returns `true` if `func` has a lazy counterpart,\n * else `false`.\n */\n function isLaziable(func) {\n var funcName = getFuncName(func),\n other = lodash[funcName];\n\n if (typeof other != 'function' || !(funcName in LazyWrapper.prototype)) {\n return false;\n }\n if (func === other) {\n return true;\n }\n var data = getData(other);\n return !!data && func === data[0];\n }\n\n /**\n * Checks if `func` has its source masked.\n *\n * @private\n * @param {Function} func The function to check.\n * @returns {boolean} Returns `true` if `func` is masked, else `false`.\n */\n function isMasked(func) {\n return !!maskSrcKey && (maskSrcKey in func);\n }\n\n /**\n * Checks if `func` is capable of being masked.\n *\n * @private\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `func` is maskable, else `false`.\n */\n var isMaskable = coreJsData ? isFunction : stubFalse;\n\n /**\n * Checks if `value` is likely a prototype object.\n *\n * @private\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is a prototype, else `false`.\n */\n function isPrototype(value) {\n var Ctor = value && value.constructor,\n proto = (typeof Ctor == 'function' && Ctor.prototype) || objectProto;\n\n return value === proto;\n }\n\n /**\n * Checks if `value` is suitable for strict equality comparisons, i.e. `===`.\n *\n * @private\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` if suitable for strict\n * equality comparisons, else `false`.\n */\n function isStrictComparable(value) {\n return value === value && !isObject(value);\n }\n\n /**\n * A specialized version of `matchesProperty` for source values suitable\n * for strict equality comparisons, i.e. `===`.\n *\n * @private\n * @param {string} key The key of the property to get.\n * @param {*} srcValue The value to match.\n * @returns {Function} Returns the new spec function.\n */\n function matchesStrictComparable(key, srcValue) {\n return function(object) {\n if (object == null) {\n return false;\n }\n return object[key] === srcValue &&\n (srcValue !== undefined || (key in Object(object)));\n };\n }\n\n /**\n * A specialized version of `_.memoize` which clears the memoized function's\n * cache when it exceeds `MAX_MEMOIZE_SIZE`.\n *\n * @private\n * @param {Function} func The function to have its output memoized.\n * @returns {Function} Returns the new memoized function.\n */\n function memoizeCapped(func) {\n var result = memoize(func, function(key) {\n if (cache.size === MAX_MEMOIZE_SIZE) {\n cache.clear();\n }\n return key;\n });\n\n var cache = result.cache;\n return result;\n }\n\n /**\n * Merges the function metadata of `source` into `data`.\n *\n * Merging metadata reduces the number of wrappers used to invoke a function.\n * This is possible because methods like `_.bind`, `_.curry`, and `_.partial`\n * may be applied regardless of execution order. Methods like `_.ary` and\n * `_.rearg` modify function arguments, making the order in which they are\n * executed important, preventing the merging of metadata. However, we make\n * an exception for a safe combined case where curried functions have `_.ary`\n * and or `_.rearg` applied.\n *\n * @private\n * @param {Array} data The destination metadata.\n * @param {Array} source The source metadata.\n * @returns {Array} Returns `data`.\n */\n function mergeData(data, source) {\n var bitmask = data[1],\n srcBitmask = source[1],\n newBitmask = bitmask | srcBitmask,\n isCommon = newBitmask < (WRAP_BIND_FLAG | WRAP_BIND_KEY_FLAG | WRAP_ARY_FLAG);\n\n var isCombo =\n ((srcBitmask == WRAP_ARY_FLAG) && (bitmask == WRAP_CURRY_FLAG)) ||\n ((srcBitmask == WRAP_ARY_FLAG) && (bitmask == WRAP_REARG_FLAG) && (data[7].length <= source[8])) ||\n ((srcBitmask == (WRAP_ARY_FLAG | WRAP_REARG_FLAG)) && (source[7].length <= source[8]) && (bitmask == WRAP_CURRY_FLAG));\n\n // Exit early if metadata can't be merged.\n if (!(isCommon || isCombo)) {\n return data;\n }\n // Use source `thisArg` if available.\n if (srcBitmask & WRAP_BIND_FLAG) {\n data[2] = source[2];\n // Set when currying a bound function.\n newBitmask |= bitmask & WRAP_BIND_FLAG ? 0 : WRAP_CURRY_BOUND_FLAG;\n }\n // Compose partial arguments.\n var value = source[3];\n if (value) {\n var partials = data[3];\n data[3] = partials ? composeArgs(partials, value, source[4]) : value;\n data[4] = partials ? replaceHolders(data[3], PLACEHOLDER) : source[4];\n }\n // Compose partial right arguments.\n value = source[5];\n if (value) {\n partials = data[5];\n data[5] = partials ? composeArgsRight(partials, value, source[6]) : value;\n data[6] = partials ? replaceHolders(data[5], PLACEHOLDER) : source[6];\n }\n // Use source `argPos` if available.\n value = source[7];\n if (value) {\n data[7] = value;\n }\n // Use source `ary` if it's smaller.\n if (srcBitmask & WRAP_ARY_FLAG) {\n data[8] = data[8] == null ? source[8] : nativeMin(data[8], source[8]);\n }\n // Use source `arity` if one is not provided.\n if (data[9] == null) {\n data[9] = source[9];\n }\n // Use source `func` and merge bitmasks.\n data[0] = source[0];\n data[1] = newBitmask;\n\n return data;\n }\n\n /**\n * This function is like\n * [`Object.keys`](http://ecma-international.org/ecma-262/7.0/#sec-object.keys)\n * except that it includes inherited enumerable properties.\n *\n * @private\n * @param {Object} object The object to query.\n * @returns {Array} Returns the array of property names.\n */\n function nativeKeysIn(object) {\n var result = [];\n if (object != null) {\n for (var key in Object(object)) {\n result.push(key);\n }\n }\n return result;\n }\n\n /**\n * Converts `value` to a string using `Object.prototype.toString`.\n *\n * @private\n * @param {*} value The value to convert.\n * @returns {string} Returns the converted string.\n */\n function objectToString(value) {\n return nativeObjectToString.call(value);\n }\n\n /**\n * A specialized version of `baseRest` which transforms the rest array.\n *\n * @private\n * @param {Function} func The function to apply a rest parameter to.\n * @param {number} [start=func.length-1] The start position of the rest parameter.\n * @param {Function} transform The rest array transform.\n * @returns {Function} Returns the new function.\n */\n function overRest(func, start, transform) {\n start = nativeMax(start === undefined ? (func.length - 1) : start, 0);\n return function() {\n var args = arguments,\n index = -1,\n length = nativeMax(args.length - start, 0),\n array = Array(length);\n\n while (++index < length) {\n array[index] = args[start + index];\n }\n index = -1;\n var otherArgs = Array(start + 1);\n while (++index < start) {\n otherArgs[index] = args[index];\n }\n otherArgs[start] = transform(array);\n return apply(func, this, otherArgs);\n };\n }\n\n /**\n * Gets the parent value at `path` of `object`.\n *\n * @private\n * @param {Object} object The object to query.\n * @param {Array} path The path to get the parent value of.\n * @returns {*} Returns the parent value.\n */\n function parent(object, path) {\n return path.length < 2 ? object : baseGet(object, baseSlice(path, 0, -1));\n }\n\n /**\n * Reorder `array` according to the specified indexes where the element at\n * the first index is assigned as the first element, the element at\n * the second index is assigned as the second element, and so on.\n *\n * @private\n * @param {Array} array The array to reorder.\n * @param {Array} indexes The arranged array indexes.\n * @returns {Array} Returns `array`.\n */\n function reorder(array, indexes) {\n var arrLength = array.length,\n length = nativeMin(indexes.length, arrLength),\n oldArray = copyArray(array);\n\n while (length--) {\n var index = indexes[length];\n array[length] = isIndex(index, arrLength) ? oldArray[index] : undefined;\n }\n return array;\n }\n\n /**\n * Gets the value at `key`, unless `key` is \"__proto__\" or \"constructor\".\n *\n * @private\n * @param {Object} object The object to query.\n * @param {string} key The key of the property to get.\n * @returns {*} Returns the property value.\n */\n function safeGet(object, key) {\n if (key === 'constructor' && typeof object[key] === 'function') {\n return;\n }\n\n if (key == '__proto__') {\n return;\n }\n\n return object[key];\n }\n\n /**\n * Sets metadata for `func`.\n *\n * **Note:** If this function becomes hot, i.e. is invoked a lot in a short\n * period of time, it will trip its breaker and transition to an identity\n * function to avoid garbage collection pauses in V8. See\n * [V8 issue 2070](https://bugs.chromium.org/p/v8/issues/detail?id=2070)\n * for more details.\n *\n * @private\n * @param {Function} func The function to associate metadata with.\n * @param {*} data The metadata.\n * @returns {Function} Returns `func`.\n */\n var setData = shortOut(baseSetData);\n\n /**\n * A simple wrapper around the global [`setTimeout`](https://mdn.io/setTimeout).\n *\n * @private\n * @param {Function} func The function to delay.\n * @param {number} wait The number of milliseconds to delay invocation.\n * @returns {number|Object} Returns the timer id or timeout object.\n */\n var setTimeout = ctxSetTimeout || function(func, wait) {\n return root.setTimeout(func, wait);\n };\n\n /**\n * Sets the `toString` method of `func` to return `string`.\n *\n * @private\n * @param {Function} func The function to modify.\n * @param {Function} string The `toString` result.\n * @returns {Function} Returns `func`.\n */\n var setToString = shortOut(baseSetToString);\n\n /**\n * Sets the `toString` method of `wrapper` to mimic the source of `reference`\n * with wrapper details in a comment at the top of the source body.\n *\n * @private\n * @param {Function} wrapper The function to modify.\n * @param {Function} reference The reference function.\n * @param {number} bitmask The bitmask flags. See `createWrap` for more details.\n * @returns {Function} Returns `wrapper`.\n */\n function setWrapToString(wrapper, reference, bitmask) {\n var source = (reference + '');\n return setToString(wrapper, insertWrapDetails(source, updateWrapDetails(getWrapDetails(source), bitmask)));\n }\n\n /**\n * Creates a function that'll short out and invoke `identity` instead\n * of `func` when it's called `HOT_COUNT` or more times in `HOT_SPAN`\n * milliseconds.\n *\n * @private\n * @param {Function} func The function to restrict.\n * @returns {Function} Returns the new shortable function.\n */\n function shortOut(func) {\n var count = 0,\n lastCalled = 0;\n\n return function() {\n var stamp = nativeNow(),\n remaining = HOT_SPAN - (stamp - lastCalled);\n\n lastCalled = stamp;\n if (remaining > 0) {\n if (++count >= HOT_COUNT) {\n return arguments[0];\n }\n } else {\n count = 0;\n }\n return func.apply(undefined, arguments);\n };\n }\n\n /**\n * A specialized version of `_.shuffle` which mutates and sets the size of `array`.\n *\n * @private\n * @param {Array} array The array to shuffle.\n * @param {number} [size=array.length] The size of `array`.\n * @returns {Array} Returns `array`.\n */\n function shuffleSelf(array, size) {\n var index = -1,\n length = array.length,\n lastIndex = length - 1;\n\n size = size === undefined ? length : size;\n while (++index < size) {\n var rand = baseRandom(index, lastIndex),\n value = array[rand];\n\n array[rand] = array[index];\n array[index] = value;\n }\n array.length = size;\n return array;\n }\n\n /**\n * Converts `string` to a property path array.\n *\n * @private\n * @param {string} string The string to convert.\n * @returns {Array} Returns the property path array.\n */\n var stringToPath = memoizeCapped(function(string) {\n var result = [];\n if (string.charCodeAt(0) === 46 /* . */) {\n result.push('');\n }\n string.replace(rePropName, function(match, number, quote, subString) {\n result.push(quote ? subString.replace(reEscapeChar, '$1') : (number || match));\n });\n return result;\n });\n\n /**\n * Converts `value` to a string key if it's not a string or symbol.\n *\n * @private\n * @param {*} value The value to inspect.\n * @returns {string|symbol} Returns the key.\n */\n function toKey(value) {\n if (typeof value == 'string' || isSymbol(value)) {\n return value;\n }\n var result = (value + '');\n return (result == '0' && (1 / value) == -INFINITY) ? '-0' : result;\n }\n\n /**\n * Converts `func` to its source code.\n *\n * @private\n * @param {Function} func The function to convert.\n * @returns {string} Returns the source code.\n */\n function toSource(func) {\n if (func != null) {\n try {\n return funcToString.call(func);\n } catch (e) {}\n try {\n return (func + '');\n } catch (e) {}\n }\n return '';\n }\n\n /**\n * Updates wrapper `details` based on `bitmask` flags.\n *\n * @private\n * @returns {Array} details The details to modify.\n * @param {number} bitmask The bitmask flags. See `createWrap` for more details.\n * @returns {Array} Returns `details`.\n */\n function updateWrapDetails(details, bitmask) {\n arrayEach(wrapFlags, function(pair) {\n var value = '_.' + pair[0];\n if ((bitmask & pair[1]) && !arrayIncludes(details, value)) {\n details.push(value);\n }\n });\n return details.sort();\n }\n\n /**\n * Creates a clone of `wrapper`.\n *\n * @private\n * @param {Object} wrapper The wrapper to clone.\n * @returns {Object} Returns the cloned wrapper.\n */\n function wrapperClone(wrapper) {\n if (wrapper instanceof LazyWrapper) {\n return wrapper.clone();\n }\n var result = new LodashWrapper(wrapper.__wrapped__, wrapper.__chain__);\n result.__actions__ = copyArray(wrapper.__actions__);\n result.__index__ = wrapper.__index__;\n result.__values__ = wrapper.__values__;\n return result;\n }\n\n /*------------------------------------------------------------------------*/\n\n /**\n * Creates an array of elements split into groups the length of `size`.\n * If `array` can't be split evenly, the final chunk will be the remaining\n * elements.\n *\n * @static\n * @memberOf _\n * @since 3.0.0\n * @category Array\n * @param {Array} array The array to process.\n * @param {number} [size=1] The length of each chunk\n * @param- {Object} [guard] Enables use as an iteratee for methods like `_.map`.\n * @returns {Array} Returns the new array of chunks.\n * @example\n *\n * _.chunk(['a', 'b', 'c', 'd'], 2);\n * // => [['a', 'b'], ['c', 'd']]\n *\n * _.chunk(['a', 'b', 'c', 'd'], 3);\n * // => [['a', 'b', 'c'], ['d']]\n */\n function chunk(array, size, guard) {\n if ((guard ? isIterateeCall(array, size, guard) : size === undefined)) {\n size = 1;\n } else {\n size = nativeMax(toInteger(size), 0);\n }\n var length = array == null ? 0 : array.length;\n if (!length || size < 1) {\n return [];\n }\n var index = 0,\n resIndex = 0,\n result = Array(nativeCeil(length / size));\n\n while (index < length) {\n result[resIndex++] = baseSlice(array, index, (index += size));\n }\n return result;\n }\n\n /**\n * Creates an array with all falsey values removed. The values `false`, `null`,\n * `0`, `\"\"`, `undefined`, and `NaN` are falsey.\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Array\n * @param {Array} array The array to compact.\n * @returns {Array} Returns the new array of filtered values.\n * @example\n *\n * _.compact([0, 1, false, 2, '', 3]);\n * // => [1, 2, 3]\n */\n function compact(array) {\n var index = -1,\n length = array == null ? 0 : array.length,\n resIndex = 0,\n result = [];\n\n while (++index < length) {\n var value = array[index];\n if (value) {\n result[resIndex++] = value;\n }\n }\n return result;\n }\n\n /**\n * Creates a new array concatenating `array` with any additional arrays\n * and/or values.\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Array\n * @param {Array} array The array to concatenate.\n * @param {...*} [values] The values to concatenate.\n * @returns {Array} Returns the new concatenated array.\n * @example\n *\n * var array = [1];\n * var other = _.concat(array, 2, [3], [[4]]);\n *\n * console.log(other);\n * // => [1, 2, 3, [4]]\n *\n * console.log(array);\n * // => [1]\n */\n function concat() {\n var length = arguments.length;\n if (!length) {\n return [];\n }\n var args = Array(length - 1),\n array = arguments[0],\n index = length;\n\n while (index--) {\n args[index - 1] = arguments[index];\n }\n return arrayPush(isArray(array) ? copyArray(array) : [array], baseFlatten(args, 1));\n }\n\n /**\n * Creates an array of `array` values not included in the other given arrays\n * using [`SameValueZero`](http://ecma-international.org/ecma-262/7.0/#sec-samevaluezero)\n * for equality comparisons. The order and references of result values are\n * determined by the first array.\n *\n * **Note:** Unlike `_.pullAll`, this method returns a new array.\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Array\n * @param {Array} array The array to inspect.\n * @param {...Array} [values] The values to exclude.\n * @returns {Array} Returns the new array of filtered values.\n * @see _.without, _.xor\n * @example\n *\n * _.difference([2, 1], [2, 3]);\n * // => [1]\n */\n var difference = baseRest(function(array, values) {\n return isArrayLikeObject(array)\n ? baseDifference(array, baseFlatten(values, 1, isArrayLikeObject, true))\n : [];\n });\n\n /**\n * This method is like `_.difference` except that it accepts `iteratee` which\n * is invoked for each element of `array` and `values` to generate the criterion\n * by which they're compared. The order and references of result values are\n * determined by the first array. The iteratee is invoked with one argument:\n * (value).\n *\n * **Note:** Unlike `_.pullAllBy`, this method returns a new array.\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Array\n * @param {Array} array The array to inspect.\n * @param {...Array} [values] The values to exclude.\n * @param {Function} [iteratee=_.identity] The iteratee invoked per element.\n * @returns {Array} Returns the new array of filtered values.\n * @example\n *\n * _.differenceBy([2.1, 1.2], [2.3, 3.4], Math.floor);\n * // => [1.2]\n *\n * // The `_.property` iteratee shorthand.\n * _.differenceBy([{ 'x': 2 }, { 'x': 1 }], [{ 'x': 1 }], 'x');\n * // => [{ 'x': 2 }]\n */\n var differenceBy = baseRest(function(array, values) {\n var iteratee = last(values);\n if (isArrayLikeObject(iteratee)) {\n iteratee = undefined;\n }\n return isArrayLikeObject(array)\n ? baseDifference(array, baseFlatten(values, 1, isArrayLikeObject, true), getIteratee(iteratee, 2))\n : [];\n });\n\n /**\n * This method is like `_.difference` except that it accepts `comparator`\n * which is invoked to compare elements of `array` to `values`. The order and\n * references of result values are determined by the first array. The comparator\n * is invoked with two arguments: (arrVal, othVal).\n *\n * **Note:** Unlike `_.pullAllWith`, this method returns a new array.\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Array\n * @param {Array} array The array to inspect.\n * @param {...Array} [values] The values to exclude.\n * @param {Function} [comparator] The comparator invoked per element.\n * @returns {Array} Returns the new array of filtered values.\n * @example\n *\n * var objects = [{ 'x': 1, 'y': 2 }, { 'x': 2, 'y': 1 }];\n *\n * _.differenceWith(objects, [{ 'x': 1, 'y': 2 }], _.isEqual);\n * // => [{ 'x': 2, 'y': 1 }]\n */\n var differenceWith = baseRest(function(array, values) {\n var comparator = last(values);\n if (isArrayLikeObject(comparator)) {\n comparator = undefined;\n }\n return isArrayLikeObject(array)\n ? baseDifference(array, baseFlatten(values, 1, isArrayLikeObject, true), undefined, comparator)\n : [];\n });\n\n /**\n * Creates a slice of `array` with `n` elements dropped from the beginning.\n *\n * @static\n * @memberOf _\n * @since 0.5.0\n * @category Array\n * @param {Array} array The array to query.\n * @param {number} [n=1] The number of elements to drop.\n * @param- {Object} [guard] Enables use as an iteratee for methods like `_.map`.\n * @returns {Array} Returns the slice of `array`.\n * @example\n *\n * _.drop([1, 2, 3]);\n * // => [2, 3]\n *\n * _.drop([1, 2, 3], 2);\n * // => [3]\n *\n * _.drop([1, 2, 3], 5);\n * // => []\n *\n * _.drop([1, 2, 3], 0);\n * // => [1, 2, 3]\n */\n function drop(array, n, guard) {\n var length = array == null ? 0 : array.length;\n if (!length) {\n return [];\n }\n n = (guard || n === undefined) ? 1 : toInteger(n);\n return baseSlice(array, n < 0 ? 0 : n, length);\n }\n\n /**\n * Creates a slice of `array` with `n` elements dropped from the end.\n *\n * @static\n * @memberOf _\n * @since 3.0.0\n * @category Array\n * @param {Array} array The array to query.\n * @param {number} [n=1] The number of elements to drop.\n * @param- {Object} [guard] Enables use as an iteratee for methods like `_.map`.\n * @returns {Array} Returns the slice of `array`.\n * @example\n *\n * _.dropRight([1, 2, 3]);\n * // => [1, 2]\n *\n * _.dropRight([1, 2, 3], 2);\n * // => [1]\n *\n * _.dropRight([1, 2, 3], 5);\n * // => []\n *\n * _.dropRight([1, 2, 3], 0);\n * // => [1, 2, 3]\n */\n function dropRight(array, n, guard) {\n var length = array == null ? 0 : array.length;\n if (!length) {\n return [];\n }\n n = (guard || n === undefined) ? 1 : toInteger(n);\n n = length - n;\n return baseSlice(array, 0, n < 0 ? 0 : n);\n }\n\n /**\n * Creates a slice of `array` excluding elements dropped from the end.\n * Elements are dropped until `predicate` returns falsey. The predicate is\n * invoked with three arguments: (value, index, array).\n *\n * @static\n * @memberOf _\n * @since 3.0.0\n * @category Array\n * @param {Array} array The array to query.\n * @param {Function} [predicate=_.identity] The function invoked per iteration.\n * @returns {Array} Returns the slice of `array`.\n * @example\n *\n * var users = [\n * { 'user': 'barney', 'active': true },\n * { 'user': 'fred', 'active': false },\n * { 'user': 'pebbles', 'active': false }\n * ];\n *\n * _.dropRightWhile(users, function(o) { return !o.active; });\n * // => objects for ['barney']\n *\n * // The `_.matches` iteratee shorthand.\n * _.dropRightWhile(users, { 'user': 'pebbles', 'active': false });\n * // => objects for ['barney', 'fred']\n *\n * // The `_.matchesProperty` iteratee shorthand.\n * _.dropRightWhile(users, ['active', false]);\n * // => objects for ['barney']\n *\n * // The `_.property` iteratee shorthand.\n * _.dropRightWhile(users, 'active');\n * // => objects for ['barney', 'fred', 'pebbles']\n */\n function dropRightWhile(array, predicate) {\n return (array && array.length)\n ? baseWhile(array, getIteratee(predicate, 3), true, true)\n : [];\n }\n\n /**\n * Creates a slice of `array` excluding elements dropped from the beginning.\n * Elements are dropped until `predicate` returns falsey. The predicate is\n * invoked with three arguments: (value, index, array).\n *\n * @static\n * @memberOf _\n * @since 3.0.0\n * @category Array\n * @param {Array} array The array to query.\n * @param {Function} [predicate=_.identity] The function invoked per iteration.\n * @returns {Array} Returns the slice of `array`.\n * @example\n *\n * var users = [\n * { 'user': 'barney', 'active': false },\n * { 'user': 'fred', 'active': false },\n * { 'user': 'pebbles', 'active': true }\n * ];\n *\n * _.dropWhile(users, function(o) { return !o.active; });\n * // => objects for ['pebbles']\n *\n * // The `_.matches` iteratee shorthand.\n * _.dropWhile(users, { 'user': 'barney', 'active': false });\n * // => objects for ['fred', 'pebbles']\n *\n * // The `_.matchesProperty` iteratee shorthand.\n * _.dropWhile(users, ['active', false]);\n * // => objects for ['pebbles']\n *\n * // The `_.property` iteratee shorthand.\n * _.dropWhile(users, 'active');\n * // => objects for ['barney', 'fred', 'pebbles']\n */\n function dropWhile(array, predicate) {\n return (array && array.length)\n ? baseWhile(array, getIteratee(predicate, 3), true)\n : [];\n }\n\n /**\n * Fills elements of `array` with `value` from `start` up to, but not\n * including, `end`.\n *\n * **Note:** This method mutates `array`.\n *\n * @static\n * @memberOf _\n * @since 3.2.0\n * @category Array\n * @param {Array} array The array to fill.\n * @param {*} value The value to fill `array` with.\n * @param {number} [start=0] The start position.\n * @param {number} [end=array.length] The end position.\n * @returns {Array} Returns `array`.\n * @example\n *\n * var array = [1, 2, 3];\n *\n * _.fill(array, 'a');\n * console.log(array);\n * // => ['a', 'a', 'a']\n *\n * _.fill(Array(3), 2);\n * // => [2, 2, 2]\n *\n * _.fill([4, 6, 8, 10], '*', 1, 3);\n * // => [4, '*', '*', 10]\n */\n function fill(array, value, start, end) {\n var length = array == null ? 0 : array.length;\n if (!length) {\n return [];\n }\n if (start && typeof start != 'number' && isIterateeCall(array, value, start)) {\n start = 0;\n end = length;\n }\n return baseFill(array, value, start, end);\n }\n\n /**\n * This method is like `_.find` except that it returns the index of the first\n * element `predicate` returns truthy for instead of the element itself.\n *\n * @static\n * @memberOf _\n * @since 1.1.0\n * @category Array\n * @param {Array} array The array to inspect.\n * @param {Function} [predicate=_.identity] The function invoked per iteration.\n * @param {number} [fromIndex=0] The index to search from.\n * @returns {number} Returns the index of the found element, else `-1`.\n * @example\n *\n * var users = [\n * { 'user': 'barney', 'active': false },\n * { 'user': 'fred', 'active': false },\n * { 'user': 'pebbles', 'active': true }\n * ];\n *\n * _.findIndex(users, function(o) { return o.user == 'barney'; });\n * // => 0\n *\n * // The `_.matches` iteratee shorthand.\n * _.findIndex(users, { 'user': 'fred', 'active': false });\n * // => 1\n *\n * // The `_.matchesProperty` iteratee shorthand.\n * _.findIndex(users, ['active', false]);\n * // => 0\n *\n * // The `_.property` iteratee shorthand.\n * _.findIndex(users, 'active');\n * // => 2\n */\n function findIndex(array, predicate, fromIndex) {\n var length = array == null ? 0 : array.length;\n if (!length) {\n return -1;\n }\n var index = fromIndex == null ? 0 : toInteger(fromIndex);\n if (index < 0) {\n index = nativeMax(length + index, 0);\n }\n return baseFindIndex(array, getIteratee(predicate, 3), index);\n }\n\n /**\n * This method is like `_.findIndex` except that it iterates over elements\n * of `collection` from right to left.\n *\n * @static\n * @memberOf _\n * @since 2.0.0\n * @category Array\n * @param {Array} array The array to inspect.\n * @param {Function} [predicate=_.identity] The function invoked per iteration.\n * @param {number} [fromIndex=array.length-1] The index to search from.\n * @returns {number} Returns the index of the found element, else `-1`.\n * @example\n *\n * var users = [\n * { 'user': 'barney', 'active': true },\n * { 'user': 'fred', 'active': false },\n * { 'user': 'pebbles', 'active': false }\n * ];\n *\n * _.findLastIndex(users, function(o) { return o.user == 'pebbles'; });\n * // => 2\n *\n * // The `_.matches` iteratee shorthand.\n * _.findLastIndex(users, { 'user': 'barney', 'active': true });\n * // => 0\n *\n * // The `_.matchesProperty` iteratee shorthand.\n * _.findLastIndex(users, ['active', false]);\n * // => 2\n *\n * // The `_.property` iteratee shorthand.\n * _.findLastIndex(users, 'active');\n * // => 0\n */\n function findLastIndex(array, predicate, fromIndex) {\n var length = array == null ? 0 : array.length;\n if (!length) {\n return -1;\n }\n var index = length - 1;\n if (fromIndex !== undefined) {\n index = toInteger(fromIndex);\n index = fromIndex < 0\n ? nativeMax(length + index, 0)\n : nativeMin(index, length - 1);\n }\n return baseFindIndex(array, getIteratee(predicate, 3), index, true);\n }\n\n /**\n * Flattens `array` a single level deep.\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Array\n * @param {Array} array The array to flatten.\n * @returns {Array} Returns the new flattened array.\n * @example\n *\n * _.flatten([1, [2, [3, [4]], 5]]);\n * // => [1, 2, [3, [4]], 5]\n */\n function flatten(array) {\n var length = array == null ? 0 : array.length;\n return length ? baseFlatten(array, 1) : [];\n }\n\n /**\n * Recursively flattens `array`.\n *\n * @static\n * @memberOf _\n * @since 3.0.0\n * @category Array\n * @param {Array} array The array to flatten.\n * @returns {Array} Returns the new flattened array.\n * @example\n *\n * _.flattenDeep([1, [2, [3, [4]], 5]]);\n * // => [1, 2, 3, 4, 5]\n */\n function flattenDeep(array) {\n var length = array == null ? 0 : array.length;\n return length ? baseFlatten(array, INFINITY) : [];\n }\n\n /**\n * Recursively flatten `array` up to `depth` times.\n *\n * @static\n * @memberOf _\n * @since 4.4.0\n * @category Array\n * @param {Array} array The array to flatten.\n * @param {number} [depth=1] The maximum recursion depth.\n * @returns {Array} Returns the new flattened array.\n * @example\n *\n * var array = [1, [2, [3, [4]], 5]];\n *\n * _.flattenDepth(array, 1);\n * // => [1, 2, [3, [4]], 5]\n *\n * _.flattenDepth(array, 2);\n * // => [1, 2, 3, [4], 5]\n */\n function flattenDepth(array, depth) {\n var length = array == null ? 0 : array.length;\n if (!length) {\n return [];\n }\n depth = depth === undefined ? 1 : toInteger(depth);\n return baseFlatten(array, depth);\n }\n\n /**\n * The inverse of `_.toPairs`; this method returns an object composed\n * from key-value `pairs`.\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Array\n * @param {Array} pairs The key-value pairs.\n * @returns {Object} Returns the new object.\n * @example\n *\n * _.fromPairs([['a', 1], ['b', 2]]);\n * // => { 'a': 1, 'b': 2 }\n */\n function fromPairs(pairs) {\n var index = -1,\n length = pairs == null ? 0 : pairs.length,\n result = {};\n\n while (++index < length) {\n var pair = pairs[index];\n result[pair[0]] = pair[1];\n }\n return result;\n }\n\n /**\n * Gets the first element of `array`.\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @alias first\n * @category Array\n * @param {Array} array The array to query.\n * @returns {*} Returns the first element of `array`.\n * @example\n *\n * _.head([1, 2, 3]);\n * // => 1\n *\n * _.head([]);\n * // => undefined\n */\n function head(array) {\n return (array && array.length) ? array[0] : undefined;\n }\n\n /**\n * Gets the index at which the first occurrence of `value` is found in `array`\n * using [`SameValueZero`](http://ecma-international.org/ecma-262/7.0/#sec-samevaluezero)\n * for equality comparisons. If `fromIndex` is negative, it's used as the\n * offset from the end of `array`.\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Array\n * @param {Array} array The array to inspect.\n * @param {*} value The value to search for.\n * @param {number} [fromIndex=0] The index to search from.\n * @returns {number} Returns the index of the matched value, else `-1`.\n * @example\n *\n * _.indexOf([1, 2, 1, 2], 2);\n * // => 1\n *\n * // Search from the `fromIndex`.\n * _.indexOf([1, 2, 1, 2], 2, 2);\n * // => 3\n */\n function indexOf(array, value, fromIndex) {\n var length = array == null ? 0 : array.length;\n if (!length) {\n return -1;\n }\n var index = fromIndex == null ? 0 : toInteger(fromIndex);\n if (index < 0) {\n index = nativeMax(length + index, 0);\n }\n return baseIndexOf(array, value, index);\n }\n\n /**\n * Gets all but the last element of `array`.\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Array\n * @param {Array} array The array to query.\n * @returns {Array} Returns the slice of `array`.\n * @example\n *\n * _.initial([1, 2, 3]);\n * // => [1, 2]\n */\n function initial(array) {\n var length = array == null ? 0 : array.length;\n return length ? baseSlice(array, 0, -1) : [];\n }\n\n /**\n * Creates an array of unique values that are included in all given arrays\n * using [`SameValueZero`](http://ecma-international.org/ecma-262/7.0/#sec-samevaluezero)\n * for equality comparisons. The order and references of result values are\n * determined by the first array.\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Array\n * @param {...Array} [arrays] The arrays to inspect.\n * @returns {Array} Returns the new array of intersecting values.\n * @example\n *\n * _.intersection([2, 1], [2, 3]);\n * // => [2]\n */\n var intersection = baseRest(function(arrays) {\n var mapped = arrayMap(arrays, castArrayLikeObject);\n return (mapped.length && mapped[0] === arrays[0])\n ? baseIntersection(mapped)\n : [];\n });\n\n /**\n * This method is like `_.intersection` except that it accepts `iteratee`\n * which is invoked for each element of each `arrays` to generate the criterion\n * by which they're compared. The order and references of result values are\n * determined by the first array. The iteratee is invoked with one argument:\n * (value).\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Array\n * @param {...Array} [arrays] The arrays to inspect.\n * @param {Function} [iteratee=_.identity] The iteratee invoked per element.\n * @returns {Array} Returns the new array of intersecting values.\n * @example\n *\n * _.intersectionBy([2.1, 1.2], [2.3, 3.4], Math.floor);\n * // => [2.1]\n *\n * // The `_.property` iteratee shorthand.\n * _.intersectionBy([{ 'x': 1 }], [{ 'x': 2 }, { 'x': 1 }], 'x');\n * // => [{ 'x': 1 }]\n */\n var intersectionBy = baseRest(function(arrays) {\n var iteratee = last(arrays),\n mapped = arrayMap(arrays, castArrayLikeObject);\n\n if (iteratee === last(mapped)) {\n iteratee = undefined;\n } else {\n mapped.pop();\n }\n return (mapped.length && mapped[0] === arrays[0])\n ? baseIntersection(mapped, getIteratee(iteratee, 2))\n : [];\n });\n\n /**\n * This method is like `_.intersection` except that it accepts `comparator`\n * which is invoked to compare elements of `arrays`. The order and references\n * of result values are determined by the first array. The comparator is\n * invoked with two arguments: (arrVal, othVal).\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Array\n * @param {...Array} [arrays] The arrays to inspect.\n * @param {Function} [comparator] The comparator invoked per element.\n * @returns {Array} Returns the new array of intersecting values.\n * @example\n *\n * var objects = [{ 'x': 1, 'y': 2 }, { 'x': 2, 'y': 1 }];\n * var others = [{ 'x': 1, 'y': 1 }, { 'x': 1, 'y': 2 }];\n *\n * _.intersectionWith(objects, others, _.isEqual);\n * // => [{ 'x': 1, 'y': 2 }]\n */\n var intersectionWith = baseRest(function(arrays) {\n var comparator = last(arrays),\n mapped = arrayMap(arrays, castArrayLikeObject);\n\n comparator = typeof comparator == 'function' ? comparator : undefined;\n if (comparator) {\n mapped.pop();\n }\n return (mapped.length && mapped[0] === arrays[0])\n ? baseIntersection(mapped, undefined, comparator)\n : [];\n });\n\n /**\n * Converts all elements in `array` into a string separated by `separator`.\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Array\n * @param {Array} array The array to convert.\n * @param {string} [separator=','] The element separator.\n * @returns {string} Returns the joined string.\n * @example\n *\n * _.join(['a', 'b', 'c'], '~');\n * // => 'a~b~c'\n */\n function join(array, separator) {\n return array == null ? '' : nativeJoin.call(array, separator);\n }\n\n /**\n * Gets the last element of `array`.\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Array\n * @param {Array} array The array to query.\n * @returns {*} Returns the last element of `array`.\n * @example\n *\n * _.last([1, 2, 3]);\n * // => 3\n */\n function last(array) {\n var length = array == null ? 0 : array.length;\n return length ? array[length - 1] : undefined;\n }\n\n /**\n * This method is like `_.indexOf` except that it iterates over elements of\n * `array` from right to left.\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Array\n * @param {Array} array The array to inspect.\n * @param {*} value The value to search for.\n * @param {number} [fromIndex=array.length-1] The index to search from.\n * @returns {number} Returns the index of the matched value, else `-1`.\n * @example\n *\n * _.lastIndexOf([1, 2, 1, 2], 2);\n * // => 3\n *\n * // Search from the `fromIndex`.\n * _.lastIndexOf([1, 2, 1, 2], 2, 2);\n * // => 1\n */\n function lastIndexOf(array, value, fromIndex) {\n var length = array == null ? 0 : array.length;\n if (!length) {\n return -1;\n }\n var index = length;\n if (fromIndex !== undefined) {\n index = toInteger(fromIndex);\n index = index < 0 ? nativeMax(length + index, 0) : nativeMin(index, length - 1);\n }\n return value === value\n ? strictLastIndexOf(array, value, index)\n : baseFindIndex(array, baseIsNaN, index, true);\n }\n\n /**\n * Gets the element at index `n` of `array`. If `n` is negative, the nth\n * element from the end is returned.\n *\n * @static\n * @memberOf _\n * @since 4.11.0\n * @category Array\n * @param {Array} array The array to query.\n * @param {number} [n=0] The index of the element to return.\n * @returns {*} Returns the nth element of `array`.\n * @example\n *\n * var array = ['a', 'b', 'c', 'd'];\n *\n * _.nth(array, 1);\n * // => 'b'\n *\n * _.nth(array, -2);\n * // => 'c';\n */\n function nth(array, n) {\n return (array && array.length) ? baseNth(array, toInteger(n)) : undefined;\n }\n\n /**\n * Removes all given values from `array` using\n * [`SameValueZero`](http://ecma-international.org/ecma-262/7.0/#sec-samevaluezero)\n * for equality comparisons.\n *\n * **Note:** Unlike `_.without`, this method mutates `array`. Use `_.remove`\n * to remove elements from an array by predicate.\n *\n * @static\n * @memberOf _\n * @since 2.0.0\n * @category Array\n * @param {Array} array The array to modify.\n * @param {...*} [values] The values to remove.\n * @returns {Array} Returns `array`.\n * @example\n *\n * var array = ['a', 'b', 'c', 'a', 'b', 'c'];\n *\n * _.pull(array, 'a', 'c');\n * console.log(array);\n * // => ['b', 'b']\n */\n var pull = baseRest(pullAll);\n\n /**\n * This method is like `_.pull` except that it accepts an array of values to remove.\n *\n * **Note:** Unlike `_.difference`, this method mutates `array`.\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Array\n * @param {Array} array The array to modify.\n * @param {Array} values The values to remove.\n * @returns {Array} Returns `array`.\n * @example\n *\n * var array = ['a', 'b', 'c', 'a', 'b', 'c'];\n *\n * _.pullAll(array, ['a', 'c']);\n * console.log(array);\n * // => ['b', 'b']\n */\n function pullAll(array, values) {\n return (array && array.length && values && values.length)\n ? basePullAll(array, values)\n : array;\n }\n\n /**\n * This method is like `_.pullAll` except that it accepts `iteratee` which is\n * invoked for each element of `array` and `values` to generate the criterion\n * by which they're compared. The iteratee is invoked with one argument: (value).\n *\n * **Note:** Unlike `_.differenceBy`, this method mutates `array`.\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Array\n * @param {Array} array The array to modify.\n * @param {Array} values The values to remove.\n * @param {Function} [iteratee=_.identity] The iteratee invoked per element.\n * @returns {Array} Returns `array`.\n * @example\n *\n * var array = [{ 'x': 1 }, { 'x': 2 }, { 'x': 3 }, { 'x': 1 }];\n *\n * _.pullAllBy(array, [{ 'x': 1 }, { 'x': 3 }], 'x');\n * console.log(array);\n * // => [{ 'x': 2 }]\n */\n function pullAllBy(array, values, iteratee) {\n return (array && array.length && values && values.length)\n ? basePullAll(array, values, getIteratee(iteratee, 2))\n : array;\n }\n\n /**\n * This method is like `_.pullAll` except that it accepts `comparator` which\n * is invoked to compare elements of `array` to `values`. The comparator is\n * invoked with two arguments: (arrVal, othVal).\n *\n * **Note:** Unlike `_.differenceWith`, this method mutates `array`.\n *\n * @static\n * @memberOf _\n * @since 4.6.0\n * @category Array\n * @param {Array} array The array to modify.\n * @param {Array} values The values to remove.\n * @param {Function} [comparator] The comparator invoked per element.\n * @returns {Array} Returns `array`.\n * @example\n *\n * var array = [{ 'x': 1, 'y': 2 }, { 'x': 3, 'y': 4 }, { 'x': 5, 'y': 6 }];\n *\n * _.pullAllWith(array, [{ 'x': 3, 'y': 4 }], _.isEqual);\n * console.log(array);\n * // => [{ 'x': 1, 'y': 2 }, { 'x': 5, 'y': 6 }]\n */\n function pullAllWith(array, values, comparator) {\n return (array && array.length && values && values.length)\n ? basePullAll(array, values, undefined, comparator)\n : array;\n }\n\n /**\n * Removes elements from `array` corresponding to `indexes` and returns an\n * array of removed elements.\n *\n * **Note:** Unlike `_.at`, this method mutates `array`.\n *\n * @static\n * @memberOf _\n * @since 3.0.0\n * @category Array\n * @param {Array} array The array to modify.\n * @param {...(number|number[])} [indexes] The indexes of elements to remove.\n * @returns {Array} Returns the new array of removed elements.\n * @example\n *\n * var array = ['a', 'b', 'c', 'd'];\n * var pulled = _.pullAt(array, [1, 3]);\n *\n * console.log(array);\n * // => ['a', 'c']\n *\n * console.log(pulled);\n * // => ['b', 'd']\n */\n var pullAt = flatRest(function(array, indexes) {\n var length = array == null ? 0 : array.length,\n result = baseAt(array, indexes);\n\n basePullAt(array, arrayMap(indexes, function(index) {\n return isIndex(index, length) ? +index : index;\n }).sort(compareAscending));\n\n return result;\n });\n\n /**\n * Removes all elements from `array` that `predicate` returns truthy for\n * and returns an array of the removed elements. The predicate is invoked\n * with three arguments: (value, index, array).\n *\n * **Note:** Unlike `_.filter`, this method mutates `array`. Use `_.pull`\n * to pull elements from an array by value.\n *\n * @static\n * @memberOf _\n * @since 2.0.0\n * @category Array\n * @param {Array} array The array to modify.\n * @param {Function} [predicate=_.identity] The function invoked per iteration.\n * @returns {Array} Returns the new array of removed elements.\n * @example\n *\n * var array = [1, 2, 3, 4];\n * var evens = _.remove(array, function(n) {\n * return n % 2 == 0;\n * });\n *\n * console.log(array);\n * // => [1, 3]\n *\n * console.log(evens);\n * // => [2, 4]\n */\n function remove(array, predicate) {\n var result = [];\n if (!(array && array.length)) {\n return result;\n }\n var index = -1,\n indexes = [],\n length = array.length;\n\n predicate = getIteratee(predicate, 3);\n while (++index < length) {\n var value = array[index];\n if (predicate(value, index, array)) {\n result.push(value);\n indexes.push(index);\n }\n }\n basePullAt(array, indexes);\n return result;\n }\n\n /**\n * Reverses `array` so that the first element becomes the last, the second\n * element becomes the second to last, and so on.\n *\n * **Note:** This method mutates `array` and is based on\n * [`Array#reverse`](https://mdn.io/Array/reverse).\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Array\n * @param {Array} array The array to modify.\n * @returns {Array} Returns `array`.\n * @example\n *\n * var array = [1, 2, 3];\n *\n * _.reverse(array);\n * // => [3, 2, 1]\n *\n * console.log(array);\n * // => [3, 2, 1]\n */\n function reverse(array) {\n return array == null ? array : nativeReverse.call(array);\n }\n\n /**\n * Creates a slice of `array` from `start` up to, but not including, `end`.\n *\n * **Note:** This method is used instead of\n * [`Array#slice`](https://mdn.io/Array/slice) to ensure dense arrays are\n * returned.\n *\n * @static\n * @memberOf _\n * @since 3.0.0\n * @category Array\n * @param {Array} array The array to slice.\n * @param {number} [start=0] The start position.\n * @param {number} [end=array.length] The end position.\n * @returns {Array} Returns the slice of `array`.\n */\n function slice(array, start, end) {\n var length = array == null ? 0 : array.length;\n if (!length) {\n return [];\n }\n if (end && typeof end != 'number' && isIterateeCall(array, start, end)) {\n start = 0;\n end = length;\n }\n else {\n start = start == null ? 0 : toInteger(start);\n end = end === undefined ? length : toInteger(end);\n }\n return baseSlice(array, start, end);\n }\n\n /**\n * Uses a binary search to determine the lowest index at which `value`\n * should be inserted into `array` in order to maintain its sort order.\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Array\n * @param {Array} array The sorted array to inspect.\n * @param {*} value The value to evaluate.\n * @returns {number} Returns the index at which `value` should be inserted\n * into `array`.\n * @example\n *\n * _.sortedIndex([30, 50], 40);\n * // => 1\n */\n function sortedIndex(array, value) {\n return baseSortedIndex(array, value);\n }\n\n /**\n * This method is like `_.sortedIndex` except that it accepts `iteratee`\n * which is invoked for `value` and each element of `array` to compute their\n * sort ranking. The iteratee is invoked with one argument: (value).\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Array\n * @param {Array} array The sorted array to inspect.\n * @param {*} value The value to evaluate.\n * @param {Function} [iteratee=_.identity] The iteratee invoked per element.\n * @returns {number} Returns the index at which `value` should be inserted\n * into `array`.\n * @example\n *\n * var objects = [{ 'x': 4 }, { 'x': 5 }];\n *\n * _.sortedIndexBy(objects, { 'x': 4 }, function(o) { return o.x; });\n * // => 0\n *\n * // The `_.property` iteratee shorthand.\n * _.sortedIndexBy(objects, { 'x': 4 }, 'x');\n * // => 0\n */\n function sortedIndexBy(array, value, iteratee) {\n return baseSortedIndexBy(array, value, getIteratee(iteratee, 2));\n }\n\n /**\n * This method is like `_.indexOf` except that it performs a binary\n * search on a sorted `array`.\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Array\n * @param {Array} array The array to inspect.\n * @param {*} value The value to search for.\n * @returns {number} Returns the index of the matched value, else `-1`.\n * @example\n *\n * _.sortedIndexOf([4, 5, 5, 5, 6], 5);\n * // => 1\n */\n function sortedIndexOf(array, value) {\n var length = array == null ? 0 : array.length;\n if (length) {\n var index = baseSortedIndex(array, value);\n if (index < length && eq(array[index], value)) {\n return index;\n }\n }\n return -1;\n }\n\n /**\n * This method is like `_.sortedIndex` except that it returns the highest\n * index at which `value` should be inserted into `array` in order to\n * maintain its sort order.\n *\n * @static\n * @memberOf _\n * @since 3.0.0\n * @category Array\n * @param {Array} array The sorted array to inspect.\n * @param {*} value The value to evaluate.\n * @returns {number} Returns the index at which `value` should be inserted\n * into `array`.\n * @example\n *\n * _.sortedLastIndex([4, 5, 5, 5, 6], 5);\n * // => 4\n */\n function sortedLastIndex(array, value) {\n return baseSortedIndex(array, value, true);\n }\n\n /**\n * This method is like `_.sortedLastIndex` except that it accepts `iteratee`\n * which is invoked for `value` and each element of `array` to compute their\n * sort ranking. The iteratee is invoked with one argument: (value).\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Array\n * @param {Array} array The sorted array to inspect.\n * @param {*} value The value to evaluate.\n * @param {Function} [iteratee=_.identity] The iteratee invoked per element.\n * @returns {number} Returns the index at which `value` should be inserted\n * into `array`.\n * @example\n *\n * var objects = [{ 'x': 4 }, { 'x': 5 }];\n *\n * _.sortedLastIndexBy(objects, { 'x': 4 }, function(o) { return o.x; });\n * // => 1\n *\n * // The `_.property` iteratee shorthand.\n * _.sortedLastIndexBy(objects, { 'x': 4 }, 'x');\n * // => 1\n */\n function sortedLastIndexBy(array, value, iteratee) {\n return baseSortedIndexBy(array, value, getIteratee(iteratee, 2), true);\n }\n\n /**\n * This method is like `_.lastIndexOf` except that it performs a binary\n * search on a sorted `array`.\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Array\n * @param {Array} array The array to inspect.\n * @param {*} value The value to search for.\n * @returns {number} Returns the index of the matched value, else `-1`.\n * @example\n *\n * _.sortedLastIndexOf([4, 5, 5, 5, 6], 5);\n * // => 3\n */\n function sortedLastIndexOf(array, value) {\n var length = array == null ? 0 : array.length;\n if (length) {\n var index = baseSortedIndex(array, value, true) - 1;\n if (eq(array[index], value)) {\n return index;\n }\n }\n return -1;\n }\n\n /**\n * This method is like `_.uniq` except that it's designed and optimized\n * for sorted arrays.\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Array\n * @param {Array} array The array to inspect.\n * @returns {Array} Returns the new duplicate free array.\n * @example\n *\n * _.sortedUniq([1, 1, 2]);\n * // => [1, 2]\n */\n function sortedUniq(array) {\n return (array && array.length)\n ? baseSortedUniq(array)\n : [];\n }\n\n /**\n * This method is like `_.uniqBy` except that it's designed and optimized\n * for sorted arrays.\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Array\n * @param {Array} array The array to inspect.\n * @param {Function} [iteratee] The iteratee invoked per element.\n * @returns {Array} Returns the new duplicate free array.\n * @example\n *\n * _.sortedUniqBy([1.1, 1.2, 2.3, 2.4], Math.floor);\n * // => [1.1, 2.3]\n */\n function sortedUniqBy(array, iteratee) {\n return (array && array.length)\n ? baseSortedUniq(array, getIteratee(iteratee, 2))\n : [];\n }\n\n /**\n * Gets all but the first element of `array`.\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Array\n * @param {Array} array The array to query.\n * @returns {Array} Returns the slice of `array`.\n * @example\n *\n * _.tail([1, 2, 3]);\n * // => [2, 3]\n */\n function tail(array) {\n var length = array == null ? 0 : array.length;\n return length ? baseSlice(array, 1, length) : [];\n }\n\n /**\n * Creates a slice of `array` with `n` elements taken from the beginning.\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Array\n * @param {Array} array The array to query.\n * @param {number} [n=1] The number of elements to take.\n * @param- {Object} [guard] Enables use as an iteratee for methods like `_.map`.\n * @returns {Array} Returns the slice of `array`.\n * @example\n *\n * _.take([1, 2, 3]);\n * // => [1]\n *\n * _.take([1, 2, 3], 2);\n * // => [1, 2]\n *\n * _.take([1, 2, 3], 5);\n * // => [1, 2, 3]\n *\n * _.take([1, 2, 3], 0);\n * // => []\n */\n function take(array, n, guard) {\n if (!(array && array.length)) {\n return [];\n }\n n = (guard || n === undefined) ? 1 : toInteger(n);\n return baseSlice(array, 0, n < 0 ? 0 : n);\n }\n\n /**\n * Creates a slice of `array` with `n` elements taken from the end.\n *\n * @static\n * @memberOf _\n * @since 3.0.0\n * @category Array\n * @param {Array} array The array to query.\n * @param {number} [n=1] The number of elements to take.\n * @param- {Object} [guard] Enables use as an iteratee for methods like `_.map`.\n * @returns {Array} Returns the slice of `array`.\n * @example\n *\n * _.takeRight([1, 2, 3]);\n * // => [3]\n *\n * _.takeRight([1, 2, 3], 2);\n * // => [2, 3]\n *\n * _.takeRight([1, 2, 3], 5);\n * // => [1, 2, 3]\n *\n * _.takeRight([1, 2, 3], 0);\n * // => []\n */\n function takeRight(array, n, guard) {\n var length = array == null ? 0 : array.length;\n if (!length) {\n return [];\n }\n n = (guard || n === undefined) ? 1 : toInteger(n);\n n = length - n;\n return baseSlice(array, n < 0 ? 0 : n, length);\n }\n\n /**\n * Creates a slice of `array` with elements taken from the end. Elements are\n * taken until `predicate` returns falsey. The predicate is invoked with\n * three arguments: (value, index, array).\n *\n * @static\n * @memberOf _\n * @since 3.0.0\n * @category Array\n * @param {Array} array The array to query.\n * @param {Function} [predicate=_.identity] The function invoked per iteration.\n * @returns {Array} Returns the slice of `array`.\n * @example\n *\n * var users = [\n * { 'user': 'barney', 'active': true },\n * { 'user': 'fred', 'active': false },\n * { 'user': 'pebbles', 'active': false }\n * ];\n *\n * _.takeRightWhile(users, function(o) { return !o.active; });\n * // => objects for ['fred', 'pebbles']\n *\n * // The `_.matches` iteratee shorthand.\n * _.takeRightWhile(users, { 'user': 'pebbles', 'active': false });\n * // => objects for ['pebbles']\n *\n * // The `_.matchesProperty` iteratee shorthand.\n * _.takeRightWhile(users, ['active', false]);\n * // => objects for ['fred', 'pebbles']\n *\n * // The `_.property` iteratee shorthand.\n * _.takeRightWhile(users, 'active');\n * // => []\n */\n function takeRightWhile(array, predicate) {\n return (array && array.length)\n ? baseWhile(array, getIteratee(predicate, 3), false, true)\n : [];\n }\n\n /**\n * Creates a slice of `array` with elements taken from the beginning. Elements\n * are taken until `predicate` returns falsey. The predicate is invoked with\n * three arguments: (value, index, array).\n *\n * @static\n * @memberOf _\n * @since 3.0.0\n * @category Array\n * @param {Array} array The array to query.\n * @param {Function} [predicate=_.identity] The function invoked per iteration.\n * @returns {Array} Returns the slice of `array`.\n * @example\n *\n * var users = [\n * { 'user': 'barney', 'active': false },\n * { 'user': 'fred', 'active': false },\n * { 'user': 'pebbles', 'active': true }\n * ];\n *\n * _.takeWhile(users, function(o) { return !o.active; });\n * // => objects for ['barney', 'fred']\n *\n * // The `_.matches` iteratee shorthand.\n * _.takeWhile(users, { 'user': 'barney', 'active': false });\n * // => objects for ['barney']\n *\n * // The `_.matchesProperty` iteratee shorthand.\n * _.takeWhile(users, ['active', false]);\n * // => objects for ['barney', 'fred']\n *\n * // The `_.property` iteratee shorthand.\n * _.takeWhile(users, 'active');\n * // => []\n */\n function takeWhile(array, predicate) {\n return (array && array.length)\n ? baseWhile(array, getIteratee(predicate, 3))\n : [];\n }\n\n /**\n * Creates an array of unique values, in order, from all given arrays using\n * [`SameValueZero`](http://ecma-international.org/ecma-262/7.0/#sec-samevaluezero)\n * for equality comparisons.\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Array\n * @param {...Array} [arrays] The arrays to inspect.\n * @returns {Array} Returns the new array of combined values.\n * @example\n *\n * _.union([2], [1, 2]);\n * // => [2, 1]\n */\n var union = baseRest(function(arrays) {\n return baseUniq(baseFlatten(arrays, 1, isArrayLikeObject, true));\n });\n\n /**\n * This method is like `_.union` except that it accepts `iteratee` which is\n * invoked for each element of each `arrays` to generate the criterion by\n * which uniqueness is computed. Result values are chosen from the first\n * array in which the value occurs. The iteratee is invoked with one argument:\n * (value).\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Array\n * @param {...Array} [arrays] The arrays to inspect.\n * @param {Function} [iteratee=_.identity] The iteratee invoked per element.\n * @returns {Array} Returns the new array of combined values.\n * @example\n *\n * _.unionBy([2.1], [1.2, 2.3], Math.floor);\n * // => [2.1, 1.2]\n *\n * // The `_.property` iteratee shorthand.\n * _.unionBy([{ 'x': 1 }], [{ 'x': 2 }, { 'x': 1 }], 'x');\n * // => [{ 'x': 1 }, { 'x': 2 }]\n */\n var unionBy = baseRest(function(arrays) {\n var iteratee = last(arrays);\n if (isArrayLikeObject(iteratee)) {\n iteratee = undefined;\n }\n return baseUniq(baseFlatten(arrays, 1, isArrayLikeObject, true), getIteratee(iteratee, 2));\n });\n\n /**\n * This method is like `_.union` except that it accepts `comparator` which\n * is invoked to compare elements of `arrays`. Result values are chosen from\n * the first array in which the value occurs. The comparator is invoked\n * with two arguments: (arrVal, othVal).\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Array\n * @param {...Array} [arrays] The arrays to inspect.\n * @param {Function} [comparator] The comparator invoked per element.\n * @returns {Array} Returns the new array of combined values.\n * @example\n *\n * var objects = [{ 'x': 1, 'y': 2 }, { 'x': 2, 'y': 1 }];\n * var others = [{ 'x': 1, 'y': 1 }, { 'x': 1, 'y': 2 }];\n *\n * _.unionWith(objects, others, _.isEqual);\n * // => [{ 'x': 1, 'y': 2 }, { 'x': 2, 'y': 1 }, { 'x': 1, 'y': 1 }]\n */\n var unionWith = baseRest(function(arrays) {\n var comparator = last(arrays);\n comparator = typeof comparator == 'function' ? comparator : undefined;\n return baseUniq(baseFlatten(arrays, 1, isArrayLikeObject, true), undefined, comparator);\n });\n\n /**\n * Creates a duplicate-free version of an array, using\n * [`SameValueZero`](http://ecma-international.org/ecma-262/7.0/#sec-samevaluezero)\n * for equality comparisons, in which only the first occurrence of each element\n * is kept. The order of result values is determined by the order they occur\n * in the array.\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Array\n * @param {Array} array The array to inspect.\n * @returns {Array} Returns the new duplicate free array.\n * @example\n *\n * _.uniq([2, 1, 2]);\n * // => [2, 1]\n */\n function uniq(array) {\n return (array && array.length) ? baseUniq(array) : [];\n }\n\n /**\n * This method is like `_.uniq` except that it accepts `iteratee` which is\n * invoked for each element in `array` to generate the criterion by which\n * uniqueness is computed. The order of result values is determined by the\n * order they occur in the array. The iteratee is invoked with one argument:\n * (value).\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Array\n * @param {Array} array The array to inspect.\n * @param {Function} [iteratee=_.identity] The iteratee invoked per element.\n * @returns {Array} Returns the new duplicate free array.\n * @example\n *\n * _.uniqBy([2.1, 1.2, 2.3], Math.floor);\n * // => [2.1, 1.2]\n *\n * // The `_.property` iteratee shorthand.\n * _.uniqBy([{ 'x': 1 }, { 'x': 2 }, { 'x': 1 }], 'x');\n * // => [{ 'x': 1 }, { 'x': 2 }]\n */\n function uniqBy(array, iteratee) {\n return (array && array.length) ? baseUniq(array, getIteratee(iteratee, 2)) : [];\n }\n\n /**\n * This method is like `_.uniq` except that it accepts `comparator` which\n * is invoked to compare elements of `array`. The order of result values is\n * determined by the order they occur in the array.The comparator is invoked\n * with two arguments: (arrVal, othVal).\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Array\n * @param {Array} array The array to inspect.\n * @param {Function} [comparator] The comparator invoked per element.\n * @returns {Array} Returns the new duplicate free array.\n * @example\n *\n * var objects = [{ 'x': 1, 'y': 2 }, { 'x': 2, 'y': 1 }, { 'x': 1, 'y': 2 }];\n *\n * _.uniqWith(objects, _.isEqual);\n * // => [{ 'x': 1, 'y': 2 }, { 'x': 2, 'y': 1 }]\n */\n function uniqWith(array, comparator) {\n comparator = typeof comparator == 'function' ? comparator : undefined;\n return (array && array.length) ? baseUniq(array, undefined, comparator) : [];\n }\n\n /**\n * This method is like `_.zip` except that it accepts an array of grouped\n * elements and creates an array regrouping the elements to their pre-zip\n * configuration.\n *\n * @static\n * @memberOf _\n * @since 1.2.0\n * @category Array\n * @param {Array} array The array of grouped elements to process.\n * @returns {Array} Returns the new array of regrouped elements.\n * @example\n *\n * var zipped = _.zip(['a', 'b'], [1, 2], [true, false]);\n * // => [['a', 1, true], ['b', 2, false]]\n *\n * _.unzip(zipped);\n * // => [['a', 'b'], [1, 2], [true, false]]\n */\n function unzip(array) {\n if (!(array && array.length)) {\n return [];\n }\n var length = 0;\n array = arrayFilter(array, function(group) {\n if (isArrayLikeObject(group)) {\n length = nativeMax(group.length, length);\n return true;\n }\n });\n return baseTimes(length, function(index) {\n return arrayMap(array, baseProperty(index));\n });\n }\n\n /**\n * This method is like `_.unzip` except that it accepts `iteratee` to specify\n * how regrouped values should be combined. The iteratee is invoked with the\n * elements of each group: (...group).\n *\n * @static\n * @memberOf _\n * @since 3.8.0\n * @category Array\n * @param {Array} array The array of grouped elements to process.\n * @param {Function} [iteratee=_.identity] The function to combine\n * regrouped values.\n * @returns {Array} Returns the new array of regrouped elements.\n * @example\n *\n * var zipped = _.zip([1, 2], [10, 20], [100, 200]);\n * // => [[1, 10, 100], [2, 20, 200]]\n *\n * _.unzipWith(zipped, _.add);\n * // => [3, 30, 300]\n */\n function unzipWith(array, iteratee) {\n if (!(array && array.length)) {\n return [];\n }\n var result = unzip(array);\n if (iteratee == null) {\n return result;\n }\n return arrayMap(result, function(group) {\n return apply(iteratee, undefined, group);\n });\n }\n\n /**\n * Creates an array excluding all given values using\n * [`SameValueZero`](http://ecma-international.org/ecma-262/7.0/#sec-samevaluezero)\n * for equality comparisons.\n *\n * **Note:** Unlike `_.pull`, this method returns a new array.\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Array\n * @param {Array} array The array to inspect.\n * @param {...*} [values] The values to exclude.\n * @returns {Array} Returns the new array of filtered values.\n * @see _.difference, _.xor\n * @example\n *\n * _.without([2, 1, 2, 3], 1, 2);\n * // => [3]\n */\n var without = baseRest(function(array, values) {\n return isArrayLikeObject(array)\n ? baseDifference(array, values)\n : [];\n });\n\n /**\n * Creates an array of unique values that is the\n * [symmetric difference](https://en.wikipedia.org/wiki/Symmetric_difference)\n * of the given arrays. The order of result values is determined by the order\n * they occur in the arrays.\n *\n * @static\n * @memberOf _\n * @since 2.4.0\n * @category Array\n * @param {...Array} [arrays] The arrays to inspect.\n * @returns {Array} Returns the new array of filtered values.\n * @see _.difference, _.without\n * @example\n *\n * _.xor([2, 1], [2, 3]);\n * // => [1, 3]\n */\n var xor = baseRest(function(arrays) {\n return baseXor(arrayFilter(arrays, isArrayLikeObject));\n });\n\n /**\n * This method is like `_.xor` except that it accepts `iteratee` which is\n * invoked for each element of each `arrays` to generate the criterion by\n * which by which they're compared. The order of result values is determined\n * by the order they occur in the arrays. The iteratee is invoked with one\n * argument: (value).\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Array\n * @param {...Array} [arrays] The arrays to inspect.\n * @param {Function} [iteratee=_.identity] The iteratee invoked per element.\n * @returns {Array} Returns the new array of filtered values.\n * @example\n *\n * _.xorBy([2.1, 1.2], [2.3, 3.4], Math.floor);\n * // => [1.2, 3.4]\n *\n * // The `_.property` iteratee shorthand.\n * _.xorBy([{ 'x': 1 }], [{ 'x': 2 }, { 'x': 1 }], 'x');\n * // => [{ 'x': 2 }]\n */\n var xorBy = baseRest(function(arrays) {\n var iteratee = last(arrays);\n if (isArrayLikeObject(iteratee)) {\n iteratee = undefined;\n }\n return baseXor(arrayFilter(arrays, isArrayLikeObject), getIteratee(iteratee, 2));\n });\n\n /**\n * This method is like `_.xor` except that it accepts `comparator` which is\n * invoked to compare elements of `arrays`. The order of result values is\n * determined by the order they occur in the arrays. The comparator is invoked\n * with two arguments: (arrVal, othVal).\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Array\n * @param {...Array} [arrays] The arrays to inspect.\n * @param {Function} [comparator] The comparator invoked per element.\n * @returns {Array} Returns the new array of filtered values.\n * @example\n *\n * var objects = [{ 'x': 1, 'y': 2 }, { 'x': 2, 'y': 1 }];\n * var others = [{ 'x': 1, 'y': 1 }, { 'x': 1, 'y': 2 }];\n *\n * _.xorWith(objects, others, _.isEqual);\n * // => [{ 'x': 2, 'y': 1 }, { 'x': 1, 'y': 1 }]\n */\n var xorWith = baseRest(function(arrays) {\n var comparator = last(arrays);\n comparator = typeof comparator == 'function' ? comparator : undefined;\n return baseXor(arrayFilter(arrays, isArrayLikeObject), undefined, comparator);\n });\n\n /**\n * Creates an array of grouped elements, the first of which contains the\n * first elements of the given arrays, the second of which contains the\n * second elements of the given arrays, and so on.\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Array\n * @param {...Array} [arrays] The arrays to process.\n * @returns {Array} Returns the new array of grouped elements.\n * @example\n *\n * _.zip(['a', 'b'], [1, 2], [true, false]);\n * // => [['a', 1, true], ['b', 2, false]]\n */\n var zip = baseRest(unzip);\n\n /**\n * This method is like `_.fromPairs` except that it accepts two arrays,\n * one of property identifiers and one of corresponding values.\n *\n * @static\n * @memberOf _\n * @since 0.4.0\n * @category Array\n * @param {Array} [props=[]] The property identifiers.\n * @param {Array} [values=[]] The property values.\n * @returns {Object} Returns the new object.\n * @example\n *\n * _.zipObject(['a', 'b'], [1, 2]);\n * // => { 'a': 1, 'b': 2 }\n */\n function zipObject(props, values) {\n return baseZipObject(props || [], values || [], assignValue);\n }\n\n /**\n * This method is like `_.zipObject` except that it supports property paths.\n *\n * @static\n * @memberOf _\n * @since 4.1.0\n * @category Array\n * @param {Array} [props=[]] The property identifiers.\n * @param {Array} [values=[]] The property values.\n * @returns {Object} Returns the new object.\n * @example\n *\n * _.zipObjectDeep(['a.b[0].c', 'a.b[1].d'], [1, 2]);\n * // => { 'a': { 'b': [{ 'c': 1 }, { 'd': 2 }] } }\n */\n function zipObjectDeep(props, values) {\n return baseZipObject(props || [], values || [], baseSet);\n }\n\n /**\n * This method is like `_.zip` except that it accepts `iteratee` to specify\n * how grouped values should be combined. The iteratee is invoked with the\n * elements of each group: (...group).\n *\n * @static\n * @memberOf _\n * @since 3.8.0\n * @category Array\n * @param {...Array} [arrays] The arrays to process.\n * @param {Function} [iteratee=_.identity] The function to combine\n * grouped values.\n * @returns {Array} Returns the new array of grouped elements.\n * @example\n *\n * _.zipWith([1, 2], [10, 20], [100, 200], function(a, b, c) {\n * return a + b + c;\n * });\n * // => [111, 222]\n */\n var zipWith = baseRest(function(arrays) {\n var length = arrays.length,\n iteratee = length > 1 ? arrays[length - 1] : undefined;\n\n iteratee = typeof iteratee == 'function' ? (arrays.pop(), iteratee) : undefined;\n return unzipWith(arrays, iteratee);\n });\n\n /*------------------------------------------------------------------------*/\n\n /**\n * Creates a `lodash` wrapper instance that wraps `value` with explicit method\n * chain sequences enabled. The result of such sequences must be unwrapped\n * with `_#value`.\n *\n * @static\n * @memberOf _\n * @since 1.3.0\n * @category Seq\n * @param {*} value The value to wrap.\n * @returns {Object} Returns the new `lodash` wrapper instance.\n * @example\n *\n * var users = [\n * { 'user': 'barney', 'age': 36 },\n * { 'user': 'fred', 'age': 40 },\n * { 'user': 'pebbles', 'age': 1 }\n * ];\n *\n * var youngest = _\n * .chain(users)\n * .sortBy('age')\n * .map(function(o) {\n * return o.user + ' is ' + o.age;\n * })\n * .head()\n * .value();\n * // => 'pebbles is 1'\n */\n function chain(value) {\n var result = lodash(value);\n result.__chain__ = true;\n return result;\n }\n\n /**\n * This method invokes `interceptor` and returns `value`. The interceptor\n * is invoked with one argument; (value). The purpose of this method is to\n * \"tap into\" a method chain sequence in order to modify intermediate results.\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Seq\n * @param {*} value The value to provide to `interceptor`.\n * @param {Function} interceptor The function to invoke.\n * @returns {*} Returns `value`.\n * @example\n *\n * _([1, 2, 3])\n * .tap(function(array) {\n * // Mutate input array.\n * array.pop();\n * })\n * .reverse()\n * .value();\n * // => [2, 1]\n */\n function tap(value, interceptor) {\n interceptor(value);\n return value;\n }\n\n /**\n * This method is like `_.tap` except that it returns the result of `interceptor`.\n * The purpose of this method is to \"pass thru\" values replacing intermediate\n * results in a method chain sequence.\n *\n * @static\n * @memberOf _\n * @since 3.0.0\n * @category Seq\n * @param {*} value The value to provide to `interceptor`.\n * @param {Function} interceptor The function to invoke.\n * @returns {*} Returns the result of `interceptor`.\n * @example\n *\n * _(' abc ')\n * .chain()\n * .trim()\n * .thru(function(value) {\n * return [value];\n * })\n * .value();\n * // => ['abc']\n */\n function thru(value, interceptor) {\n return interceptor(value);\n }\n\n /**\n * This method is the wrapper version of `_.at`.\n *\n * @name at\n * @memberOf _\n * @since 1.0.0\n * @category Seq\n * @param {...(string|string[])} [paths] The property paths to pick.\n * @returns {Object} Returns the new `lodash` wrapper instance.\n * @example\n *\n * var object = { 'a': [{ 'b': { 'c': 3 } }, 4] };\n *\n * _(object).at(['a[0].b.c', 'a[1]']).value();\n * // => [3, 4]\n */\n var wrapperAt = flatRest(function(paths) {\n var length = paths.length,\n start = length ? paths[0] : 0,\n value = this.__wrapped__,\n interceptor = function(object) { return baseAt(object, paths); };\n\n if (length > 1 || this.__actions__.length ||\n !(value instanceof LazyWrapper) || !isIndex(start)) {\n return this.thru(interceptor);\n }\n value = value.slice(start, +start + (length ? 1 : 0));\n value.__actions__.push({\n 'func': thru,\n 'args': [interceptor],\n 'thisArg': undefined\n });\n return new LodashWrapper(value, this.__chain__).thru(function(array) {\n if (length && !array.length) {\n array.push(undefined);\n }\n return array;\n });\n });\n\n /**\n * Creates a `lodash` wrapper instance with explicit method chain sequences enabled.\n *\n * @name chain\n * @memberOf _\n * @since 0.1.0\n * @category Seq\n * @returns {Object} Returns the new `lodash` wrapper instance.\n * @example\n *\n * var users = [\n * { 'user': 'barney', 'age': 36 },\n * { 'user': 'fred', 'age': 40 }\n * ];\n *\n * // A sequence without explicit chaining.\n * _(users).head();\n * // => { 'user': 'barney', 'age': 36 }\n *\n * // A sequence with explicit chaining.\n * _(users)\n * .chain()\n * .head()\n * .pick('user')\n * .value();\n * // => { 'user': 'barney' }\n */\n function wrapperChain() {\n return chain(this);\n }\n\n /**\n * Executes the chain sequence and returns the wrapped result.\n *\n * @name commit\n * @memberOf _\n * @since 3.2.0\n * @category Seq\n * @returns {Object} Returns the new `lodash` wrapper instance.\n * @example\n *\n * var array = [1, 2];\n * var wrapped = _(array).push(3);\n *\n * console.log(array);\n * // => [1, 2]\n *\n * wrapped = wrapped.commit();\n * console.log(array);\n * // => [1, 2, 3]\n *\n * wrapped.last();\n * // => 3\n *\n * console.log(array);\n * // => [1, 2, 3]\n */\n function wrapperCommit() {\n return new LodashWrapper(this.value(), this.__chain__);\n }\n\n /**\n * Gets the next value on a wrapped object following the\n * [iterator protocol](https://mdn.io/iteration_protocols#iterator).\n *\n * @name next\n * @memberOf _\n * @since 4.0.0\n * @category Seq\n * @returns {Object} Returns the next iterator value.\n * @example\n *\n * var wrapped = _([1, 2]);\n *\n * wrapped.next();\n * // => { 'done': false, 'value': 1 }\n *\n * wrapped.next();\n * // => { 'done': false, 'value': 2 }\n *\n * wrapped.next();\n * // => { 'done': true, 'value': undefined }\n */\n function wrapperNext() {\n if (this.__values__ === undefined) {\n this.__values__ = toArray(this.value());\n }\n var done = this.__index__ >= this.__values__.length,\n value = done ? undefined : this.__values__[this.__index__++];\n\n return { 'done': done, 'value': value };\n }\n\n /**\n * Enables the wrapper to be iterable.\n *\n * @name Symbol.iterator\n * @memberOf _\n * @since 4.0.0\n * @category Seq\n * @returns {Object} Returns the wrapper object.\n * @example\n *\n * var wrapped = _([1, 2]);\n *\n * wrapped[Symbol.iterator]() === wrapped;\n * // => true\n *\n * Array.from(wrapped);\n * // => [1, 2]\n */\n function wrapperToIterator() {\n return this;\n }\n\n /**\n * Creates a clone of the chain sequence planting `value` as the wrapped value.\n *\n * @name plant\n * @memberOf _\n * @since 3.2.0\n * @category Seq\n * @param {*} value The value to plant.\n * @returns {Object} Returns the new `lodash` wrapper instance.\n * @example\n *\n * function square(n) {\n * return n * n;\n * }\n *\n * var wrapped = _([1, 2]).map(square);\n * var other = wrapped.plant([3, 4]);\n *\n * other.value();\n * // => [9, 16]\n *\n * wrapped.value();\n * // => [1, 4]\n */\n function wrapperPlant(value) {\n var result,\n parent = this;\n\n while (parent instanceof baseLodash) {\n var clone = wrapperClone(parent);\n clone.__index__ = 0;\n clone.__values__ = undefined;\n if (result) {\n previous.__wrapped__ = clone;\n } else {\n result = clone;\n }\n var previous = clone;\n parent = parent.__wrapped__;\n }\n previous.__wrapped__ = value;\n return result;\n }\n\n /**\n * This method is the wrapper version of `_.reverse`.\n *\n * **Note:** This method mutates the wrapped array.\n *\n * @name reverse\n * @memberOf _\n * @since 0.1.0\n * @category Seq\n * @returns {Object} Returns the new `lodash` wrapper instance.\n * @example\n *\n * var array = [1, 2, 3];\n *\n * _(array).reverse().value()\n * // => [3, 2, 1]\n *\n * console.log(array);\n * // => [3, 2, 1]\n */\n function wrapperReverse() {\n var value = this.__wrapped__;\n if (value instanceof LazyWrapper) {\n var wrapped = value;\n if (this.__actions__.length) {\n wrapped = new LazyWrapper(this);\n }\n wrapped = wrapped.reverse();\n wrapped.__actions__.push({\n 'func': thru,\n 'args': [reverse],\n 'thisArg': undefined\n });\n return new LodashWrapper(wrapped, this.__chain__);\n }\n return this.thru(reverse);\n }\n\n /**\n * Executes the chain sequence to resolve the unwrapped value.\n *\n * @name value\n * @memberOf _\n * @since 0.1.0\n * @alias toJSON, valueOf\n * @category Seq\n * @returns {*} Returns the resolved unwrapped value.\n * @example\n *\n * _([1, 2, 3]).value();\n * // => [1, 2, 3]\n */\n function wrapperValue() {\n return baseWrapperValue(this.__wrapped__, this.__actions__);\n }\n\n /*------------------------------------------------------------------------*/\n\n /**\n * Creates an object composed of keys generated from the results of running\n * each element of `collection` thru `iteratee`. The corresponding value of\n * each key is the number of times the key was returned by `iteratee`. The\n * iteratee is invoked with one argument: (value).\n *\n * @static\n * @memberOf _\n * @since 0.5.0\n * @category Collection\n * @param {Array|Object} collection The collection to iterate over.\n * @param {Function} [iteratee=_.identity] The iteratee to transform keys.\n * @returns {Object} Returns the composed aggregate object.\n * @example\n *\n * _.countBy([6.1, 4.2, 6.3], Math.floor);\n * // => { '4': 1, '6': 2 }\n *\n * // The `_.property` iteratee shorthand.\n * _.countBy(['one', 'two', 'three'], 'length');\n * // => { '3': 2, '5': 1 }\n */\n var countBy = createAggregator(function(result, value, key) {\n if (hasOwnProperty.call(result, key)) {\n ++result[key];\n } else {\n baseAssignValue(result, key, 1);\n }\n });\n\n /**\n * Checks if `predicate` returns truthy for **all** elements of `collection`.\n * Iteration is stopped once `predicate` returns falsey. The predicate is\n * invoked with three arguments: (value, index|key, collection).\n *\n * **Note:** This method returns `true` for\n * [empty collections](https://en.wikipedia.org/wiki/Empty_set) because\n * [everything is true](https://en.wikipedia.org/wiki/Vacuous_truth) of\n * elements of empty collections.\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Collection\n * @param {Array|Object} collection The collection to iterate over.\n * @param {Function} [predicate=_.identity] The function invoked per iteration.\n * @param- {Object} [guard] Enables use as an iteratee for methods like `_.map`.\n * @returns {boolean} Returns `true` if all elements pass the predicate check,\n * else `false`.\n * @example\n *\n * _.every([true, 1, null, 'yes'], Boolean);\n * // => false\n *\n * var users = [\n * { 'user': 'barney', 'age': 36, 'active': false },\n * { 'user': 'fred', 'age': 40, 'active': false }\n * ];\n *\n * // The `_.matches` iteratee shorthand.\n * _.every(users, { 'user': 'barney', 'active': false });\n * // => false\n *\n * // The `_.matchesProperty` iteratee shorthand.\n * _.every(users, ['active', false]);\n * // => true\n *\n * // The `_.property` iteratee shorthand.\n * _.every(users, 'active');\n * // => false\n */\n function every(collection, predicate, guard) {\n var func = isArray(collection) ? arrayEvery : baseEvery;\n if (guard && isIterateeCall(collection, predicate, guard)) {\n predicate = undefined;\n }\n return func(collection, getIteratee(predicate, 3));\n }\n\n /**\n * Iterates over elements of `collection`, returning an array of all elements\n * `predicate` returns truthy for. The predicate is invoked with three\n * arguments: (value, index|key, collection).\n *\n * **Note:** Unlike `_.remove`, this method returns a new array.\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Collection\n * @param {Array|Object} collection The collection to iterate over.\n * @param {Function} [predicate=_.identity] The function invoked per iteration.\n * @returns {Array} Returns the new filtered array.\n * @see _.reject\n * @example\n *\n * var users = [\n * { 'user': 'barney', 'age': 36, 'active': true },\n * { 'user': 'fred', 'age': 40, 'active': false }\n * ];\n *\n * _.filter(users, function(o) { return !o.active; });\n * // => objects for ['fred']\n *\n * // The `_.matches` iteratee shorthand.\n * _.filter(users, { 'age': 36, 'active': true });\n * // => objects for ['barney']\n *\n * // The `_.matchesProperty` iteratee shorthand.\n * _.filter(users, ['active', false]);\n * // => objects for ['fred']\n *\n * // The `_.property` iteratee shorthand.\n * _.filter(users, 'active');\n * // => objects for ['barney']\n *\n * // Combining several predicates using `_.overEvery` or `_.overSome`.\n * _.filter(users, _.overSome([{ 'age': 36 }, ['age', 40]]));\n * // => objects for ['fred', 'barney']\n */\n function filter(collection, predicate) {\n var func = isArray(collection) ? arrayFilter : baseFilter;\n return func(collection, getIteratee(predicate, 3));\n }\n\n /**\n * Iterates over elements of `collection`, returning the first element\n * `predicate` returns truthy for. The predicate is invoked with three\n * arguments: (value, index|key, collection).\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Collection\n * @param {Array|Object} collection The collection to inspect.\n * @param {Function} [predicate=_.identity] The function invoked per iteration.\n * @param {number} [fromIndex=0] The index to search from.\n * @returns {*} Returns the matched element, else `undefined`.\n * @example\n *\n * var users = [\n * { 'user': 'barney', 'age': 36, 'active': true },\n * { 'user': 'fred', 'age': 40, 'active': false },\n * { 'user': 'pebbles', 'age': 1, 'active': true }\n * ];\n *\n * _.find(users, function(o) { return o.age < 40; });\n * // => object for 'barney'\n *\n * // The `_.matches` iteratee shorthand.\n * _.find(users, { 'age': 1, 'active': true });\n * // => object for 'pebbles'\n *\n * // The `_.matchesProperty` iteratee shorthand.\n * _.find(users, ['active', false]);\n * // => object for 'fred'\n *\n * // The `_.property` iteratee shorthand.\n * _.find(users, 'active');\n * // => object for 'barney'\n */\n var find = createFind(findIndex);\n\n /**\n * This method is like `_.find` except that it iterates over elements of\n * `collection` from right to left.\n *\n * @static\n * @memberOf _\n * @since 2.0.0\n * @category Collection\n * @param {Array|Object} collection The collection to inspect.\n * @param {Function} [predicate=_.identity] The function invoked per iteration.\n * @param {number} [fromIndex=collection.length-1] The index to search from.\n * @returns {*} Returns the matched element, else `undefined`.\n * @example\n *\n * _.findLast([1, 2, 3, 4], function(n) {\n * return n % 2 == 1;\n * });\n * // => 3\n */\n var findLast = createFind(findLastIndex);\n\n /**\n * Creates a flattened array of values by running each element in `collection`\n * thru `iteratee` and flattening the mapped results. The iteratee is invoked\n * with three arguments: (value, index|key, collection).\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Collection\n * @param {Array|Object} collection The collection to iterate over.\n * @param {Function} [iteratee=_.identity] The function invoked per iteration.\n * @returns {Array} Returns the new flattened array.\n * @example\n *\n * function duplicate(n) {\n * return [n, n];\n * }\n *\n * _.flatMap([1, 2], duplicate);\n * // => [1, 1, 2, 2]\n */\n function flatMap(collection, iteratee) {\n return baseFlatten(map(collection, iteratee), 1);\n }\n\n /**\n * This method is like `_.flatMap` except that it recursively flattens the\n * mapped results.\n *\n * @static\n * @memberOf _\n * @since 4.7.0\n * @category Collection\n * @param {Array|Object} collection The collection to iterate over.\n * @param {Function} [iteratee=_.identity] The function invoked per iteration.\n * @returns {Array} Returns the new flattened array.\n * @example\n *\n * function duplicate(n) {\n * return [[[n, n]]];\n * }\n *\n * _.flatMapDeep([1, 2], duplicate);\n * // => [1, 1, 2, 2]\n */\n function flatMapDeep(collection, iteratee) {\n return baseFlatten(map(collection, iteratee), INFINITY);\n }\n\n /**\n * This method is like `_.flatMap` except that it recursively flattens the\n * mapped results up to `depth` times.\n *\n * @static\n * @memberOf _\n * @since 4.7.0\n * @category Collection\n * @param {Array|Object} collection The collection to iterate over.\n * @param {Function} [iteratee=_.identity] The function invoked per iteration.\n * @param {number} [depth=1] The maximum recursion depth.\n * @returns {Array} Returns the new flattened array.\n * @example\n *\n * function duplicate(n) {\n * return [[[n, n]]];\n * }\n *\n * _.flatMapDepth([1, 2], duplicate, 2);\n * // => [[1, 1], [2, 2]]\n */\n function flatMapDepth(collection, iteratee, depth) {\n depth = depth === undefined ? 1 : toInteger(depth);\n return baseFlatten(map(collection, iteratee), depth);\n }\n\n /**\n * Iterates over elements of `collection` and invokes `iteratee` for each element.\n * The iteratee is invoked with three arguments: (value, index|key, collection).\n * Iteratee functions may exit iteration early by explicitly returning `false`.\n *\n * **Note:** As with other \"Collections\" methods, objects with a \"length\"\n * property are iterated like arrays. To avoid this behavior use `_.forIn`\n * or `_.forOwn` for object iteration.\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @alias each\n * @category Collection\n * @param {Array|Object} collection The collection to iterate over.\n * @param {Function} [iteratee=_.identity] The function invoked per iteration.\n * @returns {Array|Object} Returns `collection`.\n * @see _.forEachRight\n * @example\n *\n * _.forEach([1, 2], function(value) {\n * console.log(value);\n * });\n * // => Logs `1` then `2`.\n *\n * _.forEach({ 'a': 1, 'b': 2 }, function(value, key) {\n * console.log(key);\n * });\n * // => Logs 'a' then 'b' (iteration order is not guaranteed).\n */\n function forEach(collection, iteratee) {\n var func = isArray(collection) ? arrayEach : baseEach;\n return func(collection, getIteratee(iteratee, 3));\n }\n\n /**\n * This method is like `_.forEach` except that it iterates over elements of\n * `collection` from right to left.\n *\n * @static\n * @memberOf _\n * @since 2.0.0\n * @alias eachRight\n * @category Collection\n * @param {Array|Object} collection The collection to iterate over.\n * @param {Function} [iteratee=_.identity] The function invoked per iteration.\n * @returns {Array|Object} Returns `collection`.\n * @see _.forEach\n * @example\n *\n * _.forEachRight([1, 2], function(value) {\n * console.log(value);\n * });\n * // => Logs `2` then `1`.\n */\n function forEachRight(collection, iteratee) {\n var func = isArray(collection) ? arrayEachRight : baseEachRight;\n return func(collection, getIteratee(iteratee, 3));\n }\n\n /**\n * Creates an object composed of keys generated from the results of running\n * each element of `collection` thru `iteratee`. The order of grouped values\n * is determined by the order they occur in `collection`. The corresponding\n * value of each key is an array of elements responsible for generating the\n * key. The iteratee is invoked with one argument: (value).\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Collection\n * @param {Array|Object} collection The collection to iterate over.\n * @param {Function} [iteratee=_.identity] The iteratee to transform keys.\n * @returns {Object} Returns the composed aggregate object.\n * @example\n *\n * _.groupBy([6.1, 4.2, 6.3], Math.floor);\n * // => { '4': [4.2], '6': [6.1, 6.3] }\n *\n * // The `_.property` iteratee shorthand.\n * _.groupBy(['one', 'two', 'three'], 'length');\n * // => { '3': ['one', 'two'], '5': ['three'] }\n */\n var groupBy = createAggregator(function(result, value, key) {\n if (hasOwnProperty.call(result, key)) {\n result[key].push(value);\n } else {\n baseAssignValue(result, key, [value]);\n }\n });\n\n /**\n * Checks if `value` is in `collection`. If `collection` is a string, it's\n * checked for a substring of `value`, otherwise\n * [`SameValueZero`](http://ecma-international.org/ecma-262/7.0/#sec-samevaluezero)\n * is used for equality comparisons. If `fromIndex` is negative, it's used as\n * the offset from the end of `collection`.\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Collection\n * @param {Array|Object|string} collection The collection to inspect.\n * @param {*} value The value to search for.\n * @param {number} [fromIndex=0] The index to search from.\n * @param- {Object} [guard] Enables use as an iteratee for methods like `_.reduce`.\n * @returns {boolean} Returns `true` if `value` is found, else `false`.\n * @example\n *\n * _.includes([1, 2, 3], 1);\n * // => true\n *\n * _.includes([1, 2, 3], 1, 2);\n * // => false\n *\n * _.includes({ 'a': 1, 'b': 2 }, 1);\n * // => true\n *\n * _.includes('abcd', 'bc');\n * // => true\n */\n function includes(collection, value, fromIndex, guard) {\n collection = isArrayLike(collection) ? collection : values(collection);\n fromIndex = (fromIndex && !guard) ? toInteger(fromIndex) : 0;\n\n var length = collection.length;\n if (fromIndex < 0) {\n fromIndex = nativeMax(length + fromIndex, 0);\n }\n return isString(collection)\n ? (fromIndex <= length && collection.indexOf(value, fromIndex) > -1)\n : (!!length && baseIndexOf(collection, value, fromIndex) > -1);\n }\n\n /**\n * Invokes the method at `path` of each element in `collection`, returning\n * an array of the results of each invoked method. Any additional arguments\n * are provided to each invoked method. If `path` is a function, it's invoked\n * for, and `this` bound to, each element in `collection`.\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Collection\n * @param {Array|Object} collection The collection to iterate over.\n * @param {Array|Function|string} path The path of the method to invoke or\n * the function invoked per iteration.\n * @param {...*} [args] The arguments to invoke each method with.\n * @returns {Array} Returns the array of results.\n * @example\n *\n * _.invokeMap([[5, 1, 7], [3, 2, 1]], 'sort');\n * // => [[1, 5, 7], [1, 2, 3]]\n *\n * _.invokeMap([123, 456], String.prototype.split, '');\n * // => [['1', '2', '3'], ['4', '5', '6']]\n */\n var invokeMap = baseRest(function(collection, path, args) {\n var index = -1,\n isFunc = typeof path == 'function',\n result = isArrayLike(collection) ? Array(collection.length) : [];\n\n baseEach(collection, function(value) {\n result[++index] = isFunc ? apply(path, value, args) : baseInvoke(value, path, args);\n });\n return result;\n });\n\n /**\n * Creates an object composed of keys generated from the results of running\n * each element of `collection` thru `iteratee`. The corresponding value of\n * each key is the last element responsible for generating the key. The\n * iteratee is invoked with one argument: (value).\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Collection\n * @param {Array|Object} collection The collection to iterate over.\n * @param {Function} [iteratee=_.identity] The iteratee to transform keys.\n * @returns {Object} Returns the composed aggregate object.\n * @example\n *\n * var array = [\n * { 'dir': 'left', 'code': 97 },\n * { 'dir': 'right', 'code': 100 }\n * ];\n *\n * _.keyBy(array, function(o) {\n * return String.fromCharCode(o.code);\n * });\n * // => { 'a': { 'dir': 'left', 'code': 97 }, 'd': { 'dir': 'right', 'code': 100 } }\n *\n * _.keyBy(array, 'dir');\n * // => { 'left': { 'dir': 'left', 'code': 97 }, 'right': { 'dir': 'right', 'code': 100 } }\n */\n var keyBy = createAggregator(function(result, value, key) {\n baseAssignValue(result, key, value);\n });\n\n /**\n * Creates an array of values by running each element in `collection` thru\n * `iteratee`. The iteratee is invoked with three arguments:\n * (value, index|key, collection).\n *\n * Many lodash methods are guarded to work as iteratees for methods like\n * `_.every`, `_.filter`, `_.map`, `_.mapValues`, `_.reject`, and `_.some`.\n *\n * The guarded methods are:\n * `ary`, `chunk`, `curry`, `curryRight`, `drop`, `dropRight`, `every`,\n * `fill`, `invert`, `parseInt`, `random`, `range`, `rangeRight`, `repeat`,\n * `sampleSize`, `slice`, `some`, `sortBy`, `split`, `take`, `takeRight`,\n * `template`, `trim`, `trimEnd`, `trimStart`, and `words`\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Collection\n * @param {Array|Object} collection The collection to iterate over.\n * @param {Function} [iteratee=_.identity] The function invoked per iteration.\n * @returns {Array} Returns the new mapped array.\n * @example\n *\n * function square(n) {\n * return n * n;\n * }\n *\n * _.map([4, 8], square);\n * // => [16, 64]\n *\n * _.map({ 'a': 4, 'b': 8 }, square);\n * // => [16, 64] (iteration order is not guaranteed)\n *\n * var users = [\n * { 'user': 'barney' },\n * { 'user': 'fred' }\n * ];\n *\n * // The `_.property` iteratee shorthand.\n * _.map(users, 'user');\n * // => ['barney', 'fred']\n */\n function map(collection, iteratee) {\n var func = isArray(collection) ? arrayMap : baseMap;\n return func(collection, getIteratee(iteratee, 3));\n }\n\n /**\n * This method is like `_.sortBy` except that it allows specifying the sort\n * orders of the iteratees to sort by. If `orders` is unspecified, all values\n * are sorted in ascending order. Otherwise, specify an order of \"desc\" for\n * descending or \"asc\" for ascending sort order of corresponding values.\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Collection\n * @param {Array|Object} collection The collection to iterate over.\n * @param {Array[]|Function[]|Object[]|string[]} [iteratees=[_.identity]]\n * The iteratees to sort by.\n * @param {string[]} [orders] The sort orders of `iteratees`.\n * @param- {Object} [guard] Enables use as an iteratee for methods like `_.reduce`.\n * @returns {Array} Returns the new sorted array.\n * @example\n *\n * var users = [\n * { 'user': 'fred', 'age': 48 },\n * { 'user': 'barney', 'age': 34 },\n * { 'user': 'fred', 'age': 40 },\n * { 'user': 'barney', 'age': 36 }\n * ];\n *\n * // Sort by `user` in ascending order and by `age` in descending order.\n * _.orderBy(users, ['user', 'age'], ['asc', 'desc']);\n * // => objects for [['barney', 36], ['barney', 34], ['fred', 48], ['fred', 40]]\n */\n function orderBy(collection, iteratees, orders, guard) {\n if (collection == null) {\n return [];\n }\n if (!isArray(iteratees)) {\n iteratees = iteratees == null ? [] : [iteratees];\n }\n orders = guard ? undefined : orders;\n if (!isArray(orders)) {\n orders = orders == null ? [] : [orders];\n }\n return baseOrderBy(collection, iteratees, orders);\n }\n\n /**\n * Creates an array of elements split into two groups, the first of which\n * contains elements `predicate` returns truthy for, the second of which\n * contains elements `predicate` returns falsey for. The predicate is\n * invoked with one argument: (value).\n *\n * @static\n * @memberOf _\n * @since 3.0.0\n * @category Collection\n * @param {Array|Object} collection The collection to iterate over.\n * @param {Function} [predicate=_.identity] The function invoked per iteration.\n * @returns {Array} Returns the array of grouped elements.\n * @example\n *\n * var users = [\n * { 'user': 'barney', 'age': 36, 'active': false },\n * { 'user': 'fred', 'age': 40, 'active': true },\n * { 'user': 'pebbles', 'age': 1, 'active': false }\n * ];\n *\n * _.partition(users, function(o) { return o.active; });\n * // => objects for [['fred'], ['barney', 'pebbles']]\n *\n * // The `_.matches` iteratee shorthand.\n * _.partition(users, { 'age': 1, 'active': false });\n * // => objects for [['pebbles'], ['barney', 'fred']]\n *\n * // The `_.matchesProperty` iteratee shorthand.\n * _.partition(users, ['active', false]);\n * // => objects for [['barney', 'pebbles'], ['fred']]\n *\n * // The `_.property` iteratee shorthand.\n * _.partition(users, 'active');\n * // => objects for [['fred'], ['barney', 'pebbles']]\n */\n var partition = createAggregator(function(result, value, key) {\n result[key ? 0 : 1].push(value);\n }, function() { return [[], []]; });\n\n /**\n * Reduces `collection` to a value which is the accumulated result of running\n * each element in `collection` thru `iteratee`, where each successive\n * invocation is supplied the return value of the previous. If `accumulator`\n * is not given, the first element of `collection` is used as the initial\n * value. The iteratee is invoked with four arguments:\n * (accumulator, value, index|key, collection).\n *\n * Many lodash methods are guarded to work as iteratees for methods like\n * `_.reduce`, `_.reduceRight`, and `_.transform`.\n *\n * The guarded methods are:\n * `assign`, `defaults`, `defaultsDeep`, `includes`, `merge`, `orderBy`,\n * and `sortBy`\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Collection\n * @param {Array|Object} collection The collection to iterate over.\n * @param {Function} [iteratee=_.identity] The function invoked per iteration.\n * @param {*} [accumulator] The initial value.\n * @returns {*} Returns the accumulated value.\n * @see _.reduceRight\n * @example\n *\n * _.reduce([1, 2], function(sum, n) {\n * return sum + n;\n * }, 0);\n * // => 3\n *\n * _.reduce({ 'a': 1, 'b': 2, 'c': 1 }, function(result, value, key) {\n * (result[value] || (result[value] = [])).push(key);\n * return result;\n * }, {});\n * // => { '1': ['a', 'c'], '2': ['b'] } (iteration order is not guaranteed)\n */\n function reduce(collection, iteratee, accumulator) {\n var func = isArray(collection) ? arrayReduce : baseReduce,\n initAccum = arguments.length < 3;\n\n return func(collection, getIteratee(iteratee, 4), accumulator, initAccum, baseEach);\n }\n\n /**\n * This method is like `_.reduce` except that it iterates over elements of\n * `collection` from right to left.\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Collection\n * @param {Array|Object} collection The collection to iterate over.\n * @param {Function} [iteratee=_.identity] The function invoked per iteration.\n * @param {*} [accumulator] The initial value.\n * @returns {*} Returns the accumulated value.\n * @see _.reduce\n * @example\n *\n * var array = [[0, 1], [2, 3], [4, 5]];\n *\n * _.reduceRight(array, function(flattened, other) {\n * return flattened.concat(other);\n * }, []);\n * // => [4, 5, 2, 3, 0, 1]\n */\n function reduceRight(collection, iteratee, accumulator) {\n var func = isArray(collection) ? arrayReduceRight : baseReduce,\n initAccum = arguments.length < 3;\n\n return func(collection, getIteratee(iteratee, 4), accumulator, initAccum, baseEachRight);\n }\n\n /**\n * The opposite of `_.filter`; this method returns the elements of `collection`\n * that `predicate` does **not** return truthy for.\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Collection\n * @param {Array|Object} collection The collection to iterate over.\n * @param {Function} [predicate=_.identity] The function invoked per iteration.\n * @returns {Array} Returns the new filtered array.\n * @see _.filter\n * @example\n *\n * var users = [\n * { 'user': 'barney', 'age': 36, 'active': false },\n * { 'user': 'fred', 'age': 40, 'active': true }\n * ];\n *\n * _.reject(users, function(o) { return !o.active; });\n * // => objects for ['fred']\n *\n * // The `_.matches` iteratee shorthand.\n * _.reject(users, { 'age': 40, 'active': true });\n * // => objects for ['barney']\n *\n * // The `_.matchesProperty` iteratee shorthand.\n * _.reject(users, ['active', false]);\n * // => objects for ['fred']\n *\n * // The `_.property` iteratee shorthand.\n * _.reject(users, 'active');\n * // => objects for ['barney']\n */\n function reject(collection, predicate) {\n var func = isArray(collection) ? arrayFilter : baseFilter;\n return func(collection, negate(getIteratee(predicate, 3)));\n }\n\n /**\n * Gets a random element from `collection`.\n *\n * @static\n * @memberOf _\n * @since 2.0.0\n * @category Collection\n * @param {Array|Object} collection The collection to sample.\n * @returns {*} Returns the random element.\n * @example\n *\n * _.sample([1, 2, 3, 4]);\n * // => 2\n */\n function sample(collection) {\n var func = isArray(collection) ? arraySample : baseSample;\n return func(collection);\n }\n\n /**\n * Gets `n` random elements at unique keys from `collection` up to the\n * size of `collection`.\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Collection\n * @param {Array|Object} collection The collection to sample.\n * @param {number} [n=1] The number of elements to sample.\n * @param- {Object} [guard] Enables use as an iteratee for methods like `_.map`.\n * @returns {Array} Returns the random elements.\n * @example\n *\n * _.sampleSize([1, 2, 3], 2);\n * // => [3, 1]\n *\n * _.sampleSize([1, 2, 3], 4);\n * // => [2, 3, 1]\n */\n function sampleSize(collection, n, guard) {\n if ((guard ? isIterateeCall(collection, n, guard) : n === undefined)) {\n n = 1;\n } else {\n n = toInteger(n);\n }\n var func = isArray(collection) ? arraySampleSize : baseSampleSize;\n return func(collection, n);\n }\n\n /**\n * Creates an array of shuffled values, using a version of the\n * [Fisher-Yates shuffle](https://en.wikipedia.org/wiki/Fisher-Yates_shuffle).\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Collection\n * @param {Array|Object} collection The collection to shuffle.\n * @returns {Array} Returns the new shuffled array.\n * @example\n *\n * _.shuffle([1, 2, 3, 4]);\n * // => [4, 1, 3, 2]\n */\n function shuffle(collection) {\n var func = isArray(collection) ? arrayShuffle : baseShuffle;\n return func(collection);\n }\n\n /**\n * Gets the size of `collection` by returning its length for array-like\n * values or the number of own enumerable string keyed properties for objects.\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Collection\n * @param {Array|Object|string} collection The collection to inspect.\n * @returns {number} Returns the collection size.\n * @example\n *\n * _.size([1, 2, 3]);\n * // => 3\n *\n * _.size({ 'a': 1, 'b': 2 });\n * // => 2\n *\n * _.size('pebbles');\n * // => 7\n */\n function size(collection) {\n if (collection == null) {\n return 0;\n }\n if (isArrayLike(collection)) {\n return isString(collection) ? stringSize(collection) : collection.length;\n }\n var tag = getTag(collection);\n if (tag == mapTag || tag == setTag) {\n return collection.size;\n }\n return baseKeys(collection).length;\n }\n\n /**\n * Checks if `predicate` returns truthy for **any** element of `collection`.\n * Iteration is stopped once `predicate` returns truthy. The predicate is\n * invoked with three arguments: (value, index|key, collection).\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Collection\n * @param {Array|Object} collection The collection to iterate over.\n * @param {Function} [predicate=_.identity] The function invoked per iteration.\n * @param- {Object} [guard] Enables use as an iteratee for methods like `_.map`.\n * @returns {boolean} Returns `true` if any element passes the predicate check,\n * else `false`.\n * @example\n *\n * _.some([null, 0, 'yes', false], Boolean);\n * // => true\n *\n * var users = [\n * { 'user': 'barney', 'active': true },\n * { 'user': 'fred', 'active': false }\n * ];\n *\n * // The `_.matches` iteratee shorthand.\n * _.some(users, { 'user': 'barney', 'active': false });\n * // => false\n *\n * // The `_.matchesProperty` iteratee shorthand.\n * _.some(users, ['active', false]);\n * // => true\n *\n * // The `_.property` iteratee shorthand.\n * _.some(users, 'active');\n * // => true\n */\n function some(collection, predicate, guard) {\n var func = isArray(collection) ? arraySome : baseSome;\n if (guard && isIterateeCall(collection, predicate, guard)) {\n predicate = undefined;\n }\n return func(collection, getIteratee(predicate, 3));\n }\n\n /**\n * Creates an array of elements, sorted in ascending order by the results of\n * running each element in a collection thru each iteratee. This method\n * performs a stable sort, that is, it preserves the original sort order of\n * equal elements. The iteratees are invoked with one argument: (value).\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Collection\n * @param {Array|Object} collection The collection to iterate over.\n * @param {...(Function|Function[])} [iteratees=[_.identity]]\n * The iteratees to sort by.\n * @returns {Array} Returns the new sorted array.\n * @example\n *\n * var users = [\n * { 'user': 'fred', 'age': 48 },\n * { 'user': 'barney', 'age': 36 },\n * { 'user': 'fred', 'age': 30 },\n * { 'user': 'barney', 'age': 34 }\n * ];\n *\n * _.sortBy(users, [function(o) { return o.user; }]);\n * // => objects for [['barney', 36], ['barney', 34], ['fred', 48], ['fred', 30]]\n *\n * _.sortBy(users, ['user', 'age']);\n * // => objects for [['barney', 34], ['barney', 36], ['fred', 30], ['fred', 48]]\n */\n var sortBy = baseRest(function(collection, iteratees) {\n if (collection == null) {\n return [];\n }\n var length = iteratees.length;\n if (length > 1 && isIterateeCall(collection, iteratees[0], iteratees[1])) {\n iteratees = [];\n } else if (length > 2 && isIterateeCall(iteratees[0], iteratees[1], iteratees[2])) {\n iteratees = [iteratees[0]];\n }\n return baseOrderBy(collection, baseFlatten(iteratees, 1), []);\n });\n\n /*------------------------------------------------------------------------*/\n\n /**\n * Gets the timestamp of the number of milliseconds that have elapsed since\n * the Unix epoch (1 January 1970 00:00:00 UTC).\n *\n * @static\n * @memberOf _\n * @since 2.4.0\n * @category Date\n * @returns {number} Returns the timestamp.\n * @example\n *\n * _.defer(function(stamp) {\n * console.log(_.now() - stamp);\n * }, _.now());\n * // => Logs the number of milliseconds it took for the deferred invocation.\n */\n var now = ctxNow || function() {\n return root.Date.now();\n };\n\n /*------------------------------------------------------------------------*/\n\n /**\n * The opposite of `_.before`; this method creates a function that invokes\n * `func` once it's called `n` or more times.\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Function\n * @param {number} n The number of calls before `func` is invoked.\n * @param {Function} func The function to restrict.\n * @returns {Function} Returns the new restricted function.\n * @example\n *\n * var saves = ['profile', 'settings'];\n *\n * var done = _.after(saves.length, function() {\n * console.log('done saving!');\n * });\n *\n * _.forEach(saves, function(type) {\n * asyncSave({ 'type': type, 'complete': done });\n * });\n * // => Logs 'done saving!' after the two async saves have completed.\n */\n function after(n, func) {\n if (typeof func != 'function') {\n throw new TypeError(FUNC_ERROR_TEXT);\n }\n n = toInteger(n);\n return function() {\n if (--n < 1) {\n return func.apply(this, arguments);\n }\n };\n }\n\n /**\n * Creates a function that invokes `func`, with up to `n` arguments,\n * ignoring any additional arguments.\n *\n * @static\n * @memberOf _\n * @since 3.0.0\n * @category Function\n * @param {Function} func The function to cap arguments for.\n * @param {number} [n=func.length] The arity cap.\n * @param- {Object} [guard] Enables use as an iteratee for methods like `_.map`.\n * @returns {Function} Returns the new capped function.\n * @example\n *\n * _.map(['6', '8', '10'], _.ary(parseInt, 1));\n * // => [6, 8, 10]\n */\n function ary(func, n, guard) {\n n = guard ? undefined : n;\n n = (func && n == null) ? func.length : n;\n return createWrap(func, WRAP_ARY_FLAG, undefined, undefined, undefined, undefined, n);\n }\n\n /**\n * Creates a function that invokes `func`, with the `this` binding and arguments\n * of the created function, while it's called less than `n` times. Subsequent\n * calls to the created function return the result of the last `func` invocation.\n *\n * @static\n * @memberOf _\n * @since 3.0.0\n * @category Function\n * @param {number} n The number of calls at which `func` is no longer invoked.\n * @param {Function} func The function to restrict.\n * @returns {Function} Returns the new restricted function.\n * @example\n *\n * jQuery(element).on('click', _.before(5, addContactToList));\n * // => Allows adding up to 4 contacts to the list.\n */\n function before(n, func) {\n var result;\n if (typeof func != 'function') {\n throw new TypeError(FUNC_ERROR_TEXT);\n }\n n = toInteger(n);\n return function() {\n if (--n > 0) {\n result = func.apply(this, arguments);\n }\n if (n <= 1) {\n func = undefined;\n }\n return result;\n };\n }\n\n /**\n * Creates a function that invokes `func` with the `this` binding of `thisArg`\n * and `partials` prepended to the arguments it receives.\n *\n * The `_.bind.placeholder` value, which defaults to `_` in monolithic builds,\n * may be used as a placeholder for partially applied arguments.\n *\n * **Note:** Unlike native `Function#bind`, this method doesn't set the \"length\"\n * property of bound functions.\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Function\n * @param {Function} func The function to bind.\n * @param {*} thisArg The `this` binding of `func`.\n * @param {...*} [partials] The arguments to be partially applied.\n * @returns {Function} Returns the new bound function.\n * @example\n *\n * function greet(greeting, punctuation) {\n * return greeting + ' ' + this.user + punctuation;\n * }\n *\n * var object = { 'user': 'fred' };\n *\n * var bound = _.bind(greet, object, 'hi');\n * bound('!');\n * // => 'hi fred!'\n *\n * // Bound with placeholders.\n * var bound = _.bind(greet, object, _, '!');\n * bound('hi');\n * // => 'hi fred!'\n */\n var bind = baseRest(function(func, thisArg, partials) {\n var bitmask = WRAP_BIND_FLAG;\n if (partials.length) {\n var holders = replaceHolders(partials, getHolder(bind));\n bitmask |= WRAP_PARTIAL_FLAG;\n }\n return createWrap(func, bitmask, thisArg, partials, holders);\n });\n\n /**\n * Creates a function that invokes the method at `object[key]` with `partials`\n * prepended to the arguments it receives.\n *\n * This method differs from `_.bind` by allowing bound functions to reference\n * methods that may be redefined or don't yet exist. See\n * [Peter Michaux's article](http://peter.michaux.ca/articles/lazy-function-definition-pattern)\n * for more details.\n *\n * The `_.bindKey.placeholder` value, which defaults to `_` in monolithic\n * builds, may be used as a placeholder for partially applied arguments.\n *\n * @static\n * @memberOf _\n * @since 0.10.0\n * @category Function\n * @param {Object} object The object to invoke the method on.\n * @param {string} key The key of the method.\n * @param {...*} [partials] The arguments to be partially applied.\n * @returns {Function} Returns the new bound function.\n * @example\n *\n * var object = {\n * 'user': 'fred',\n * 'greet': function(greeting, punctuation) {\n * return greeting + ' ' + this.user + punctuation;\n * }\n * };\n *\n * var bound = _.bindKey(object, 'greet', 'hi');\n * bound('!');\n * // => 'hi fred!'\n *\n * object.greet = function(greeting, punctuation) {\n * return greeting + 'ya ' + this.user + punctuation;\n * };\n *\n * bound('!');\n * // => 'hiya fred!'\n *\n * // Bound with placeholders.\n * var bound = _.bindKey(object, 'greet', _, '!');\n * bound('hi');\n * // => 'hiya fred!'\n */\n var bindKey = baseRest(function(object, key, partials) {\n var bitmask = WRAP_BIND_FLAG | WRAP_BIND_KEY_FLAG;\n if (partials.length) {\n var holders = replaceHolders(partials, getHolder(bindKey));\n bitmask |= WRAP_PARTIAL_FLAG;\n }\n return createWrap(key, bitmask, object, partials, holders);\n });\n\n /**\n * Creates a function that accepts arguments of `func` and either invokes\n * `func` returning its result, if at least `arity` number of arguments have\n * been provided, or returns a function that accepts the remaining `func`\n * arguments, and so on. The arity of `func` may be specified if `func.length`\n * is not sufficient.\n *\n * The `_.curry.placeholder` value, which defaults to `_` in monolithic builds,\n * may be used as a placeholder for provided arguments.\n *\n * **Note:** This method doesn't set the \"length\" property of curried functions.\n *\n * @static\n * @memberOf _\n * @since 2.0.0\n * @category Function\n * @param {Function} func The function to curry.\n * @param {number} [arity=func.length] The arity of `func`.\n * @param- {Object} [guard] Enables use as an iteratee for methods like `_.map`.\n * @returns {Function} Returns the new curried function.\n * @example\n *\n * var abc = function(a, b, c) {\n * return [a, b, c];\n * };\n *\n * var curried = _.curry(abc);\n *\n * curried(1)(2)(3);\n * // => [1, 2, 3]\n *\n * curried(1, 2)(3);\n * // => [1, 2, 3]\n *\n * curried(1, 2, 3);\n * // => [1, 2, 3]\n *\n * // Curried with placeholders.\n * curried(1)(_, 3)(2);\n * // => [1, 2, 3]\n */\n function curry(func, arity, guard) {\n arity = guard ? undefined : arity;\n var result = createWrap(func, WRAP_CURRY_FLAG, undefined, undefined, undefined, undefined, undefined, arity);\n result.placeholder = curry.placeholder;\n return result;\n }\n\n /**\n * This method is like `_.curry` except that arguments are applied to `func`\n * in the manner of `_.partialRight` instead of `_.partial`.\n *\n * The `_.curryRight.placeholder` value, which defaults to `_` in monolithic\n * builds, may be used as a placeholder for provided arguments.\n *\n * **Note:** This method doesn't set the \"length\" property of curried functions.\n *\n * @static\n * @memberOf _\n * @since 3.0.0\n * @category Function\n * @param {Function} func The function to curry.\n * @param {number} [arity=func.length] The arity of `func`.\n * @param- {Object} [guard] Enables use as an iteratee for methods like `_.map`.\n * @returns {Function} Returns the new curried function.\n * @example\n *\n * var abc = function(a, b, c) {\n * return [a, b, c];\n * };\n *\n * var curried = _.curryRight(abc);\n *\n * curried(3)(2)(1);\n * // => [1, 2, 3]\n *\n * curried(2, 3)(1);\n * // => [1, 2, 3]\n *\n * curried(1, 2, 3);\n * // => [1, 2, 3]\n *\n * // Curried with placeholders.\n * curried(3)(1, _)(2);\n * // => [1, 2, 3]\n */\n function curryRight(func, arity, guard) {\n arity = guard ? undefined : arity;\n var result = createWrap(func, WRAP_CURRY_RIGHT_FLAG, undefined, undefined, undefined, undefined, undefined, arity);\n result.placeholder = curryRight.placeholder;\n return result;\n }\n\n /**\n * Creates a debounced function that delays invoking `func` until after `wait`\n * milliseconds have elapsed since the last time the debounced function was\n * invoked. The debounced function comes with a `cancel` method to cancel\n * delayed `func` invocations and a `flush` method to immediately invoke them.\n * Provide `options` to indicate whether `func` should be invoked on the\n * leading and/or trailing edge of the `wait` timeout. The `func` is invoked\n * with the last arguments provided to the debounced function. Subsequent\n * calls to the debounced function return the result of the last `func`\n * invocation.\n *\n * **Note:** If `leading` and `trailing` options are `true`, `func` is\n * invoked on the trailing edge of the timeout only if the debounced function\n * is invoked more than once during the `wait` timeout.\n *\n * If `wait` is `0` and `leading` is `false`, `func` invocation is deferred\n * until to the next tick, similar to `setTimeout` with a timeout of `0`.\n *\n * See [David Corbacho's article](https://css-tricks.com/debouncing-throttling-explained-examples/)\n * for details over the differences between `_.debounce` and `_.throttle`.\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Function\n * @param {Function} func The function to debounce.\n * @param {number} [wait=0] The number of milliseconds to delay.\n * @param {Object} [options={}] The options object.\n * @param {boolean} [options.leading=false]\n * Specify invoking on the leading edge of the timeout.\n * @param {number} [options.maxWait]\n * The maximum time `func` is allowed to be delayed before it's invoked.\n * @param {boolean} [options.trailing=true]\n * Specify invoking on the trailing edge of the timeout.\n * @returns {Function} Returns the new debounced function.\n * @example\n *\n * // Avoid costly calculations while the window size is in flux.\n * jQuery(window).on('resize', _.debounce(calculateLayout, 150));\n *\n * // Invoke `sendMail` when clicked, debouncing subsequent calls.\n * jQuery(element).on('click', _.debounce(sendMail, 300, {\n * 'leading': true,\n * 'trailing': false\n * }));\n *\n * // Ensure `batchLog` is invoked once after 1 second of debounced calls.\n * var debounced = _.debounce(batchLog, 250, { 'maxWait': 1000 });\n * var source = new EventSource('/stream');\n * jQuery(source).on('message', debounced);\n *\n * // Cancel the trailing debounced invocation.\n * jQuery(window).on('popstate', debounced.cancel);\n */\n function debounce(func, wait, options) {\n var lastArgs,\n lastThis,\n maxWait,\n result,\n timerId,\n lastCallTime,\n lastInvokeTime = 0,\n leading = false,\n maxing = false,\n trailing = true;\n\n if (typeof func != 'function') {\n throw new TypeError(FUNC_ERROR_TEXT);\n }\n wait = toNumber(wait) || 0;\n if (isObject(options)) {\n leading = !!options.leading;\n maxing = 'maxWait' in options;\n maxWait = maxing ? nativeMax(toNumber(options.maxWait) || 0, wait) : maxWait;\n trailing = 'trailing' in options ? !!options.trailing : trailing;\n }\n\n function invokeFunc(time) {\n var args = lastArgs,\n thisArg = lastThis;\n\n lastArgs = lastThis = undefined;\n lastInvokeTime = time;\n result = func.apply(thisArg, args);\n return result;\n }\n\n function leadingEdge(time) {\n // Reset any `maxWait` timer.\n lastInvokeTime = time;\n // Start the timer for the trailing edge.\n timerId = setTimeout(timerExpired, wait);\n // Invoke the leading edge.\n return leading ? invokeFunc(time) : result;\n }\n\n function remainingWait(time) {\n var timeSinceLastCall = time - lastCallTime,\n timeSinceLastInvoke = time - lastInvokeTime,\n timeWaiting = wait - timeSinceLastCall;\n\n return maxing\n ? nativeMin(timeWaiting, maxWait - timeSinceLastInvoke)\n : timeWaiting;\n }\n\n function shouldInvoke(time) {\n var timeSinceLastCall = time - lastCallTime,\n timeSinceLastInvoke = time - lastInvokeTime;\n\n // Either this is the first call, activity has stopped and we're at the\n // trailing edge, the system time has gone backwards and we're treating\n // it as the trailing edge, or we've hit the `maxWait` limit.\n return (lastCallTime === undefined || (timeSinceLastCall >= wait) ||\n (timeSinceLastCall < 0) || (maxing && timeSinceLastInvoke >= maxWait));\n }\n\n function timerExpired() {\n var time = now();\n if (shouldInvoke(time)) {\n return trailingEdge(time);\n }\n // Restart the timer.\n timerId = setTimeout(timerExpired, remainingWait(time));\n }\n\n function trailingEdge(time) {\n timerId = undefined;\n\n // Only invoke if we have `lastArgs` which means `func` has been\n // debounced at least once.\n if (trailing && lastArgs) {\n return invokeFunc(time);\n }\n lastArgs = lastThis = undefined;\n return result;\n }\n\n function cancel() {\n if (timerId !== undefined) {\n clearTimeout(timerId);\n }\n lastInvokeTime = 0;\n lastArgs = lastCallTime = lastThis = timerId = undefined;\n }\n\n function flush() {\n return timerId === undefined ? result : trailingEdge(now());\n }\n\n function debounced() {\n var time = now(),\n isInvoking = shouldInvoke(time);\n\n lastArgs = arguments;\n lastThis = this;\n lastCallTime = time;\n\n if (isInvoking) {\n if (timerId === undefined) {\n return leadingEdge(lastCallTime);\n }\n if (maxing) {\n // Handle invocations in a tight loop.\n clearTimeout(timerId);\n timerId = setTimeout(timerExpired, wait);\n return invokeFunc(lastCallTime);\n }\n }\n if (timerId === undefined) {\n timerId = setTimeout(timerExpired, wait);\n }\n return result;\n }\n debounced.cancel = cancel;\n debounced.flush = flush;\n return debounced;\n }\n\n /**\n * Defers invoking the `func` until the current call stack has cleared. Any\n * additional arguments are provided to `func` when it's invoked.\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Function\n * @param {Function} func The function to defer.\n * @param {...*} [args] The arguments to invoke `func` with.\n * @returns {number} Returns the timer id.\n * @example\n *\n * _.defer(function(text) {\n * console.log(text);\n * }, 'deferred');\n * // => Logs 'deferred' after one millisecond.\n */\n var defer = baseRest(function(func, args) {\n return baseDelay(func, 1, args);\n });\n\n /**\n * Invokes `func` after `wait` milliseconds. Any additional arguments are\n * provided to `func` when it's invoked.\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Function\n * @param {Function} func The function to delay.\n * @param {number} wait The number of milliseconds to delay invocation.\n * @param {...*} [args] The arguments to invoke `func` with.\n * @returns {number} Returns the timer id.\n * @example\n *\n * _.delay(function(text) {\n * console.log(text);\n * }, 1000, 'later');\n * // => Logs 'later' after one second.\n */\n var delay = baseRest(function(func, wait, args) {\n return baseDelay(func, toNumber(wait) || 0, args);\n });\n\n /**\n * Creates a function that invokes `func` with arguments reversed.\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Function\n * @param {Function} func The function to flip arguments for.\n * @returns {Function} Returns the new flipped function.\n * @example\n *\n * var flipped = _.flip(function() {\n * return _.toArray(arguments);\n * });\n *\n * flipped('a', 'b', 'c', 'd');\n * // => ['d', 'c', 'b', 'a']\n */\n function flip(func) {\n return createWrap(func, WRAP_FLIP_FLAG);\n }\n\n /**\n * Creates a function that memoizes the result of `func`. If `resolver` is\n * provided, it determines the cache key for storing the result based on the\n * arguments provided to the memoized function. By default, the first argument\n * provided to the memoized function is used as the map cache key. The `func`\n * is invoked with the `this` binding of the memoized function.\n *\n * **Note:** The cache is exposed as the `cache` property on the memoized\n * function. Its creation may be customized by replacing the `_.memoize.Cache`\n * constructor with one whose instances implement the\n * [`Map`](http://ecma-international.org/ecma-262/7.0/#sec-properties-of-the-map-prototype-object)\n * method interface of `clear`, `delete`, `get`, `has`, and `set`.\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Function\n * @param {Function} func The function to have its output memoized.\n * @param {Function} [resolver] The function to resolve the cache key.\n * @returns {Function} Returns the new memoized function.\n * @example\n *\n * var object = { 'a': 1, 'b': 2 };\n * var other = { 'c': 3, 'd': 4 };\n *\n * var values = _.memoize(_.values);\n * values(object);\n * // => [1, 2]\n *\n * values(other);\n * // => [3, 4]\n *\n * object.a = 2;\n * values(object);\n * // => [1, 2]\n *\n * // Modify the result cache.\n * values.cache.set(object, ['a', 'b']);\n * values(object);\n * // => ['a', 'b']\n *\n * // Replace `_.memoize.Cache`.\n * _.memoize.Cache = WeakMap;\n */\n function memoize(func, resolver) {\n if (typeof func != 'function' || (resolver != null && typeof resolver != 'function')) {\n throw new TypeError(FUNC_ERROR_TEXT);\n }\n var memoized = function() {\n var args = arguments,\n key = resolver ? resolver.apply(this, args) : args[0],\n cache = memoized.cache;\n\n if (cache.has(key)) {\n return cache.get(key);\n }\n var result = func.apply(this, args);\n memoized.cache = cache.set(key, result) || cache;\n return result;\n };\n memoized.cache = new (memoize.Cache || MapCache);\n return memoized;\n }\n\n // Expose `MapCache`.\n memoize.Cache = MapCache;\n\n /**\n * Creates a function that negates the result of the predicate `func`. The\n * `func` predicate is invoked with the `this` binding and arguments of the\n * created function.\n *\n * @static\n * @memberOf _\n * @since 3.0.0\n * @category Function\n * @param {Function} predicate The predicate to negate.\n * @returns {Function} Returns the new negated function.\n * @example\n *\n * function isEven(n) {\n * return n % 2 == 0;\n * }\n *\n * _.filter([1, 2, 3, 4, 5, 6], _.negate(isEven));\n * // => [1, 3, 5]\n */\n function negate(predicate) {\n if (typeof predicate != 'function') {\n throw new TypeError(FUNC_ERROR_TEXT);\n }\n return function() {\n var args = arguments;\n switch (args.length) {\n case 0: return !predicate.call(this);\n case 1: return !predicate.call(this, args[0]);\n case 2: return !predicate.call(this, args[0], args[1]);\n case 3: return !predicate.call(this, args[0], args[1], args[2]);\n }\n return !predicate.apply(this, args);\n };\n }\n\n /**\n * Creates a function that is restricted to invoking `func` once. Repeat calls\n * to the function return the value of the first invocation. The `func` is\n * invoked with the `this` binding and arguments of the created function.\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Function\n * @param {Function} func The function to restrict.\n * @returns {Function} Returns the new restricted function.\n * @example\n *\n * var initialize = _.once(createApplication);\n * initialize();\n * initialize();\n * // => `createApplication` is invoked once\n */\n function once(func) {\n return before(2, func);\n }\n\n /**\n * Creates a function that invokes `func` with its arguments transformed.\n *\n * @static\n * @since 4.0.0\n * @memberOf _\n * @category Function\n * @param {Function} func The function to wrap.\n * @param {...(Function|Function[])} [transforms=[_.identity]]\n * The argument transforms.\n * @returns {Function} Returns the new function.\n * @example\n *\n * function doubled(n) {\n * return n * 2;\n * }\n *\n * function square(n) {\n * return n * n;\n * }\n *\n * var func = _.overArgs(function(x, y) {\n * return [x, y];\n * }, [square, doubled]);\n *\n * func(9, 3);\n * // => [81, 6]\n *\n * func(10, 5);\n * // => [100, 10]\n */\n var overArgs = castRest(function(func, transforms) {\n transforms = (transforms.length == 1 && isArray(transforms[0]))\n ? arrayMap(transforms[0], baseUnary(getIteratee()))\n : arrayMap(baseFlatten(transforms, 1), baseUnary(getIteratee()));\n\n var funcsLength = transforms.length;\n return baseRest(function(args) {\n var index = -1,\n length = nativeMin(args.length, funcsLength);\n\n while (++index < length) {\n args[index] = transforms[index].call(this, args[index]);\n }\n return apply(func, this, args);\n });\n });\n\n /**\n * Creates a function that invokes `func` with `partials` prepended to the\n * arguments it receives. This method is like `_.bind` except it does **not**\n * alter the `this` binding.\n *\n * The `_.partial.placeholder` value, which defaults to `_` in monolithic\n * builds, may be used as a placeholder for partially applied arguments.\n *\n * **Note:** This method doesn't set the \"length\" property of partially\n * applied functions.\n *\n * @static\n * @memberOf _\n * @since 0.2.0\n * @category Function\n * @param {Function} func The function to partially apply arguments to.\n * @param {...*} [partials] The arguments to be partially applied.\n * @returns {Function} Returns the new partially applied function.\n * @example\n *\n * function greet(greeting, name) {\n * return greeting + ' ' + name;\n * }\n *\n * var sayHelloTo = _.partial(greet, 'hello');\n * sayHelloTo('fred');\n * // => 'hello fred'\n *\n * // Partially applied with placeholders.\n * var greetFred = _.partial(greet, _, 'fred');\n * greetFred('hi');\n * // => 'hi fred'\n */\n var partial = baseRest(function(func, partials) {\n var holders = replaceHolders(partials, getHolder(partial));\n return createWrap(func, WRAP_PARTIAL_FLAG, undefined, partials, holders);\n });\n\n /**\n * This method is like `_.partial` except that partially applied arguments\n * are appended to the arguments it receives.\n *\n * The `_.partialRight.placeholder` value, which defaults to `_` in monolithic\n * builds, may be used as a placeholder for partially applied arguments.\n *\n * **Note:** This method doesn't set the \"length\" property of partially\n * applied functions.\n *\n * @static\n * @memberOf _\n * @since 1.0.0\n * @category Function\n * @param {Function} func The function to partially apply arguments to.\n * @param {...*} [partials] The arguments to be partially applied.\n * @returns {Function} Returns the new partially applied function.\n * @example\n *\n * function greet(greeting, name) {\n * return greeting + ' ' + name;\n * }\n *\n * var greetFred = _.partialRight(greet, 'fred');\n * greetFred('hi');\n * // => 'hi fred'\n *\n * // Partially applied with placeholders.\n * var sayHelloTo = _.partialRight(greet, 'hello', _);\n * sayHelloTo('fred');\n * // => 'hello fred'\n */\n var partialRight = baseRest(function(func, partials) {\n var holders = replaceHolders(partials, getHolder(partialRight));\n return createWrap(func, WRAP_PARTIAL_RIGHT_FLAG, undefined, partials, holders);\n });\n\n /**\n * Creates a function that invokes `func` with arguments arranged according\n * to the specified `indexes` where the argument value at the first index is\n * provided as the first argument, the argument value at the second index is\n * provided as the second argument, and so on.\n *\n * @static\n * @memberOf _\n * @since 3.0.0\n * @category Function\n * @param {Function} func The function to rearrange arguments for.\n * @param {...(number|number[])} indexes The arranged argument indexes.\n * @returns {Function} Returns the new function.\n * @example\n *\n * var rearged = _.rearg(function(a, b, c) {\n * return [a, b, c];\n * }, [2, 0, 1]);\n *\n * rearged('b', 'c', 'a')\n * // => ['a', 'b', 'c']\n */\n var rearg = flatRest(function(func, indexes) {\n return createWrap(func, WRAP_REARG_FLAG, undefined, undefined, undefined, indexes);\n });\n\n /**\n * Creates a function that invokes `func` with the `this` binding of the\n * created function and arguments from `start` and beyond provided as\n * an array.\n *\n * **Note:** This method is based on the\n * [rest parameter](https://mdn.io/rest_parameters).\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Function\n * @param {Function} func The function to apply a rest parameter to.\n * @param {number} [start=func.length-1] The start position of the rest parameter.\n * @returns {Function} Returns the new function.\n * @example\n *\n * var say = _.rest(function(what, names) {\n * return what + ' ' + _.initial(names).join(', ') +\n * (_.size(names) > 1 ? ', & ' : '') + _.last(names);\n * });\n *\n * say('hello', 'fred', 'barney', 'pebbles');\n * // => 'hello fred, barney, & pebbles'\n */\n function rest(func, start) {\n if (typeof func != 'function') {\n throw new TypeError(FUNC_ERROR_TEXT);\n }\n start = start === undefined ? start : toInteger(start);\n return baseRest(func, start);\n }\n\n /**\n * Creates a function that invokes `func` with the `this` binding of the\n * create function and an array of arguments much like\n * [`Function#apply`](http://www.ecma-international.org/ecma-262/7.0/#sec-function.prototype.apply).\n *\n * **Note:** This method is based on the\n * [spread operator](https://mdn.io/spread_operator).\n *\n * @static\n * @memberOf _\n * @since 3.2.0\n * @category Function\n * @param {Function} func The function to spread arguments over.\n * @param {number} [start=0] The start position of the spread.\n * @returns {Function} Returns the new function.\n * @example\n *\n * var say = _.spread(function(who, what) {\n * return who + ' says ' + what;\n * });\n *\n * say(['fred', 'hello']);\n * // => 'fred says hello'\n *\n * var numbers = Promise.all([\n * Promise.resolve(40),\n * Promise.resolve(36)\n * ]);\n *\n * numbers.then(_.spread(function(x, y) {\n * return x + y;\n * }));\n * // => a Promise of 76\n */\n function spread(func, start) {\n if (typeof func != 'function') {\n throw new TypeError(FUNC_ERROR_TEXT);\n }\n start = start == null ? 0 : nativeMax(toInteger(start), 0);\n return baseRest(function(args) {\n var array = args[start],\n otherArgs = castSlice(args, 0, start);\n\n if (array) {\n arrayPush(otherArgs, array);\n }\n return apply(func, this, otherArgs);\n });\n }\n\n /**\n * Creates a throttled function that only invokes `func` at most once per\n * every `wait` milliseconds. The throttled function comes with a `cancel`\n * method to cancel delayed `func` invocations and a `flush` method to\n * immediately invoke them. Provide `options` to indicate whether `func`\n * should be invoked on the leading and/or trailing edge of the `wait`\n * timeout. The `func` is invoked with the last arguments provided to the\n * throttled function. Subsequent calls to the throttled function return the\n * result of the last `func` invocation.\n *\n * **Note:** If `leading` and `trailing` options are `true`, `func` is\n * invoked on the trailing edge of the timeout only if the throttled function\n * is invoked more than once during the `wait` timeout.\n *\n * If `wait` is `0` and `leading` is `false`, `func` invocation is deferred\n * until to the next tick, similar to `setTimeout` with a timeout of `0`.\n *\n * See [David Corbacho's article](https://css-tricks.com/debouncing-throttling-explained-examples/)\n * for details over the differences between `_.throttle` and `_.debounce`.\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Function\n * @param {Function} func The function to throttle.\n * @param {number} [wait=0] The number of milliseconds to throttle invocations to.\n * @param {Object} [options={}] The options object.\n * @param {boolean} [options.leading=true]\n * Specify invoking on the leading edge of the timeout.\n * @param {boolean} [options.trailing=true]\n * Specify invoking on the trailing edge of the timeout.\n * @returns {Function} Returns the new throttled function.\n * @example\n *\n * // Avoid excessively updating the position while scrolling.\n * jQuery(window).on('scroll', _.throttle(updatePosition, 100));\n *\n * // Invoke `renewToken` when the click event is fired, but not more than once every 5 minutes.\n * var throttled = _.throttle(renewToken, 300000, { 'trailing': false });\n * jQuery(element).on('click', throttled);\n *\n * // Cancel the trailing throttled invocation.\n * jQuery(window).on('popstate', throttled.cancel);\n */\n function throttle(func, wait, options) {\n var leading = true,\n trailing = true;\n\n if (typeof func != 'function') {\n throw new TypeError(FUNC_ERROR_TEXT);\n }\n if (isObject(options)) {\n leading = 'leading' in options ? !!options.leading : leading;\n trailing = 'trailing' in options ? !!options.trailing : trailing;\n }\n return debounce(func, wait, {\n 'leading': leading,\n 'maxWait': wait,\n 'trailing': trailing\n });\n }\n\n /**\n * Creates a function that accepts up to one argument, ignoring any\n * additional arguments.\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Function\n * @param {Function} func The function to cap arguments for.\n * @returns {Function} Returns the new capped function.\n * @example\n *\n * _.map(['6', '8', '10'], _.unary(parseInt));\n * // => [6, 8, 10]\n */\n function unary(func) {\n return ary(func, 1);\n }\n\n /**\n * Creates a function that provides `value` to `wrapper` as its first\n * argument. Any additional arguments provided to the function are appended\n * to those provided to the `wrapper`. The wrapper is invoked with the `this`\n * binding of the created function.\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Function\n * @param {*} value The value to wrap.\n * @param {Function} [wrapper=identity] The wrapper function.\n * @returns {Function} Returns the new function.\n * @example\n *\n * var p = _.wrap(_.escape, function(func, text) {\n * return '

' + func(text) + '

';\n * });\n *\n * p('fred, barney, & pebbles');\n * // => '

fred, barney, & pebbles

'\n */\n function wrap(value, wrapper) {\n return partial(castFunction(wrapper), value);\n }\n\n /*------------------------------------------------------------------------*/\n\n /**\n * Casts `value` as an array if it's not one.\n *\n * @static\n * @memberOf _\n * @since 4.4.0\n * @category Lang\n * @param {*} value The value to inspect.\n * @returns {Array} Returns the cast array.\n * @example\n *\n * _.castArray(1);\n * // => [1]\n *\n * _.castArray({ 'a': 1 });\n * // => [{ 'a': 1 }]\n *\n * _.castArray('abc');\n * // => ['abc']\n *\n * _.castArray(null);\n * // => [null]\n *\n * _.castArray(undefined);\n * // => [undefined]\n *\n * _.castArray();\n * // => []\n *\n * var array = [1, 2, 3];\n * console.log(_.castArray(array) === array);\n * // => true\n */\n function castArray() {\n if (!arguments.length) {\n return [];\n }\n var value = arguments[0];\n return isArray(value) ? value : [value];\n }\n\n /**\n * Creates a shallow clone of `value`.\n *\n * **Note:** This method is loosely based on the\n * [structured clone algorithm](https://mdn.io/Structured_clone_algorithm)\n * and supports cloning arrays, array buffers, booleans, date objects, maps,\n * numbers, `Object` objects, regexes, sets, strings, symbols, and typed\n * arrays. The own enumerable properties of `arguments` objects are cloned\n * as plain objects. An empty object is returned for uncloneable values such\n * as error objects, functions, DOM nodes, and WeakMaps.\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Lang\n * @param {*} value The value to clone.\n * @returns {*} Returns the cloned value.\n * @see _.cloneDeep\n * @example\n *\n * var objects = [{ 'a': 1 }, { 'b': 2 }];\n *\n * var shallow = _.clone(objects);\n * console.log(shallow[0] === objects[0]);\n * // => true\n */\n function clone(value) {\n return baseClone(value, CLONE_SYMBOLS_FLAG);\n }\n\n /**\n * This method is like `_.clone` except that it accepts `customizer` which\n * is invoked to produce the cloned value. If `customizer` returns `undefined`,\n * cloning is handled by the method instead. The `customizer` is invoked with\n * up to four arguments; (value [, index|key, object, stack]).\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Lang\n * @param {*} value The value to clone.\n * @param {Function} [customizer] The function to customize cloning.\n * @returns {*} Returns the cloned value.\n * @see _.cloneDeepWith\n * @example\n *\n * function customizer(value) {\n * if (_.isElement(value)) {\n * return value.cloneNode(false);\n * }\n * }\n *\n * var el = _.cloneWith(document.body, customizer);\n *\n * console.log(el === document.body);\n * // => false\n * console.log(el.nodeName);\n * // => 'BODY'\n * console.log(el.childNodes.length);\n * // => 0\n */\n function cloneWith(value, customizer) {\n customizer = typeof customizer == 'function' ? customizer : undefined;\n return baseClone(value, CLONE_SYMBOLS_FLAG, customizer);\n }\n\n /**\n * This method is like `_.clone` except that it recursively clones `value`.\n *\n * @static\n * @memberOf _\n * @since 1.0.0\n * @category Lang\n * @param {*} value The value to recursively clone.\n * @returns {*} Returns the deep cloned value.\n * @see _.clone\n * @example\n *\n * var objects = [{ 'a': 1 }, { 'b': 2 }];\n *\n * var deep = _.cloneDeep(objects);\n * console.log(deep[0] === objects[0]);\n * // => false\n */\n function cloneDeep(value) {\n return baseClone(value, CLONE_DEEP_FLAG | CLONE_SYMBOLS_FLAG);\n }\n\n /**\n * This method is like `_.cloneWith` except that it recursively clones `value`.\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Lang\n * @param {*} value The value to recursively clone.\n * @param {Function} [customizer] The function to customize cloning.\n * @returns {*} Returns the deep cloned value.\n * @see _.cloneWith\n * @example\n *\n * function customizer(value) {\n * if (_.isElement(value)) {\n * return value.cloneNode(true);\n * }\n * }\n *\n * var el = _.cloneDeepWith(document.body, customizer);\n *\n * console.log(el === document.body);\n * // => false\n * console.log(el.nodeName);\n * // => 'BODY'\n * console.log(el.childNodes.length);\n * // => 20\n */\n function cloneDeepWith(value, customizer) {\n customizer = typeof customizer == 'function' ? customizer : undefined;\n return baseClone(value, CLONE_DEEP_FLAG | CLONE_SYMBOLS_FLAG, customizer);\n }\n\n /**\n * Checks if `object` conforms to `source` by invoking the predicate\n * properties of `source` with the corresponding property values of `object`.\n *\n * **Note:** This method is equivalent to `_.conforms` when `source` is\n * partially applied.\n *\n * @static\n * @memberOf _\n * @since 4.14.0\n * @category Lang\n * @param {Object} object The object to inspect.\n * @param {Object} source The object of property predicates to conform to.\n * @returns {boolean} Returns `true` if `object` conforms, else `false`.\n * @example\n *\n * var object = { 'a': 1, 'b': 2 };\n *\n * _.conformsTo(object, { 'b': function(n) { return n > 1; } });\n * // => true\n *\n * _.conformsTo(object, { 'b': function(n) { return n > 2; } });\n * // => false\n */\n function conformsTo(object, source) {\n return source == null || baseConformsTo(object, source, keys(source));\n }\n\n /**\n * Performs a\n * [`SameValueZero`](http://ecma-international.org/ecma-262/7.0/#sec-samevaluezero)\n * comparison between two values to determine if they are equivalent.\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Lang\n * @param {*} value The value to compare.\n * @param {*} other The other value to compare.\n * @returns {boolean} Returns `true` if the values are equivalent, else `false`.\n * @example\n *\n * var object = { 'a': 1 };\n * var other = { 'a': 1 };\n *\n * _.eq(object, object);\n * // => true\n *\n * _.eq(object, other);\n * // => false\n *\n * _.eq('a', 'a');\n * // => true\n *\n * _.eq('a', Object('a'));\n * // => false\n *\n * _.eq(NaN, NaN);\n * // => true\n */\n function eq(value, other) {\n return value === other || (value !== value && other !== other);\n }\n\n /**\n * Checks if `value` is greater than `other`.\n *\n * @static\n * @memberOf _\n * @since 3.9.0\n * @category Lang\n * @param {*} value The value to compare.\n * @param {*} other The other value to compare.\n * @returns {boolean} Returns `true` if `value` is greater than `other`,\n * else `false`.\n * @see _.lt\n * @example\n *\n * _.gt(3, 1);\n * // => true\n *\n * _.gt(3, 3);\n * // => false\n *\n * _.gt(1, 3);\n * // => false\n */\n var gt = createRelationalOperation(baseGt);\n\n /**\n * Checks if `value` is greater than or equal to `other`.\n *\n * @static\n * @memberOf _\n * @since 3.9.0\n * @category Lang\n * @param {*} value The value to compare.\n * @param {*} other The other value to compare.\n * @returns {boolean} Returns `true` if `value` is greater than or equal to\n * `other`, else `false`.\n * @see _.lte\n * @example\n *\n * _.gte(3, 1);\n * // => true\n *\n * _.gte(3, 3);\n * // => true\n *\n * _.gte(1, 3);\n * // => false\n */\n var gte = createRelationalOperation(function(value, other) {\n return value >= other;\n });\n\n /**\n * Checks if `value` is likely an `arguments` object.\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Lang\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is an `arguments` object,\n * else `false`.\n * @example\n *\n * _.isArguments(function() { return arguments; }());\n * // => true\n *\n * _.isArguments([1, 2, 3]);\n * // => false\n */\n var isArguments = baseIsArguments(function() { return arguments; }()) ? baseIsArguments : function(value) {\n return isObjectLike(value) && hasOwnProperty.call(value, 'callee') &&\n !propertyIsEnumerable.call(value, 'callee');\n };\n\n /**\n * Checks if `value` is classified as an `Array` object.\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Lang\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is an array, else `false`.\n * @example\n *\n * _.isArray([1, 2, 3]);\n * // => true\n *\n * _.isArray(document.body.children);\n * // => false\n *\n * _.isArray('abc');\n * // => false\n *\n * _.isArray(_.noop);\n * // => false\n */\n var isArray = Array.isArray;\n\n /**\n * Checks if `value` is classified as an `ArrayBuffer` object.\n *\n * @static\n * @memberOf _\n * @since 4.3.0\n * @category Lang\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is an array buffer, else `false`.\n * @example\n *\n * _.isArrayBuffer(new ArrayBuffer(2));\n * // => true\n *\n * _.isArrayBuffer(new Array(2));\n * // => false\n */\n var isArrayBuffer = nodeIsArrayBuffer ? baseUnary(nodeIsArrayBuffer) : baseIsArrayBuffer;\n\n /**\n * Checks if `value` is array-like. A value is considered array-like if it's\n * not a function and has a `value.length` that's an integer greater than or\n * equal to `0` and less than or equal to `Number.MAX_SAFE_INTEGER`.\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Lang\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is array-like, else `false`.\n * @example\n *\n * _.isArrayLike([1, 2, 3]);\n * // => true\n *\n * _.isArrayLike(document.body.children);\n * // => true\n *\n * _.isArrayLike('abc');\n * // => true\n *\n * _.isArrayLike(_.noop);\n * // => false\n */\n function isArrayLike(value) {\n return value != null && isLength(value.length) && !isFunction(value);\n }\n\n /**\n * This method is like `_.isArrayLike` except that it also checks if `value`\n * is an object.\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Lang\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is an array-like object,\n * else `false`.\n * @example\n *\n * _.isArrayLikeObject([1, 2, 3]);\n * // => true\n *\n * _.isArrayLikeObject(document.body.children);\n * // => true\n *\n * _.isArrayLikeObject('abc');\n * // => false\n *\n * _.isArrayLikeObject(_.noop);\n * // => false\n */\n function isArrayLikeObject(value) {\n return isObjectLike(value) && isArrayLike(value);\n }\n\n /**\n * Checks if `value` is classified as a boolean primitive or object.\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Lang\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is a boolean, else `false`.\n * @example\n *\n * _.isBoolean(false);\n * // => true\n *\n * _.isBoolean(null);\n * // => false\n */\n function isBoolean(value) {\n return value === true || value === false ||\n (isObjectLike(value) && baseGetTag(value) == boolTag);\n }\n\n /**\n * Checks if `value` is a buffer.\n *\n * @static\n * @memberOf _\n * @since 4.3.0\n * @category Lang\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is a buffer, else `false`.\n * @example\n *\n * _.isBuffer(new Buffer(2));\n * // => true\n *\n * _.isBuffer(new Uint8Array(2));\n * // => false\n */\n var isBuffer = nativeIsBuffer || stubFalse;\n\n /**\n * Checks if `value` is classified as a `Date` object.\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Lang\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is a date object, else `false`.\n * @example\n *\n * _.isDate(new Date);\n * // => true\n *\n * _.isDate('Mon April 23 2012');\n * // => false\n */\n var isDate = nodeIsDate ? baseUnary(nodeIsDate) : baseIsDate;\n\n /**\n * Checks if `value` is likely a DOM element.\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Lang\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is a DOM element, else `false`.\n * @example\n *\n * _.isElement(document.body);\n * // => true\n *\n * _.isElement('');\n * // => false\n */\n function isElement(value) {\n return isObjectLike(value) && value.nodeType === 1 && !isPlainObject(value);\n }\n\n /**\n * Checks if `value` is an empty object, collection, map, or set.\n *\n * Objects are considered empty if they have no own enumerable string keyed\n * properties.\n *\n * Array-like values such as `arguments` objects, arrays, buffers, strings, or\n * jQuery-like collections are considered empty if they have a `length` of `0`.\n * Similarly, maps and sets are considered empty if they have a `size` of `0`.\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Lang\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is empty, else `false`.\n * @example\n *\n * _.isEmpty(null);\n * // => true\n *\n * _.isEmpty(true);\n * // => true\n *\n * _.isEmpty(1);\n * // => true\n *\n * _.isEmpty([1, 2, 3]);\n * // => false\n *\n * _.isEmpty({ 'a': 1 });\n * // => false\n */\n function isEmpty(value) {\n if (value == null) {\n return true;\n }\n if (isArrayLike(value) &&\n (isArray(value) || typeof value == 'string' || typeof value.splice == 'function' ||\n isBuffer(value) || isTypedArray(value) || isArguments(value))) {\n return !value.length;\n }\n var tag = getTag(value);\n if (tag == mapTag || tag == setTag) {\n return !value.size;\n }\n if (isPrototype(value)) {\n return !baseKeys(value).length;\n }\n for (var key in value) {\n if (hasOwnProperty.call(value, key)) {\n return false;\n }\n }\n return true;\n }\n\n /**\n * Performs a deep comparison between two values to determine if they are\n * equivalent.\n *\n * **Note:** This method supports comparing arrays, array buffers, booleans,\n * date objects, error objects, maps, numbers, `Object` objects, regexes,\n * sets, strings, symbols, and typed arrays. `Object` objects are compared\n * by their own, not inherited, enumerable properties. Functions and DOM\n * nodes are compared by strict equality, i.e. `===`.\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Lang\n * @param {*} value The value to compare.\n * @param {*} other The other value to compare.\n * @returns {boolean} Returns `true` if the values are equivalent, else `false`.\n * @example\n *\n * var object = { 'a': 1 };\n * var other = { 'a': 1 };\n *\n * _.isEqual(object, other);\n * // => true\n *\n * object === other;\n * // => false\n */\n function isEqual(value, other) {\n return baseIsEqual(value, other);\n }\n\n /**\n * This method is like `_.isEqual` except that it accepts `customizer` which\n * is invoked to compare values. If `customizer` returns `undefined`, comparisons\n * are handled by the method instead. The `customizer` is invoked with up to\n * six arguments: (objValue, othValue [, index|key, object, other, stack]).\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Lang\n * @param {*} value The value to compare.\n * @param {*} other The other value to compare.\n * @param {Function} [customizer] The function to customize comparisons.\n * @returns {boolean} Returns `true` if the values are equivalent, else `false`.\n * @example\n *\n * function isGreeting(value) {\n * return /^h(?:i|ello)$/.test(value);\n * }\n *\n * function customizer(objValue, othValue) {\n * if (isGreeting(objValue) && isGreeting(othValue)) {\n * return true;\n * }\n * }\n *\n * var array = ['hello', 'goodbye'];\n * var other = ['hi', 'goodbye'];\n *\n * _.isEqualWith(array, other, customizer);\n * // => true\n */\n function isEqualWith(value, other, customizer) {\n customizer = typeof customizer == 'function' ? customizer : undefined;\n var result = customizer ? customizer(value, other) : undefined;\n return result === undefined ? baseIsEqual(value, other, undefined, customizer) : !!result;\n }\n\n /**\n * Checks if `value` is an `Error`, `EvalError`, `RangeError`, `ReferenceError`,\n * `SyntaxError`, `TypeError`, or `URIError` object.\n *\n * @static\n * @memberOf _\n * @since 3.0.0\n * @category Lang\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is an error object, else `false`.\n * @example\n *\n * _.isError(new Error);\n * // => true\n *\n * _.isError(Error);\n * // => false\n */\n function isError(value) {\n if (!isObjectLike(value)) {\n return false;\n }\n var tag = baseGetTag(value);\n return tag == errorTag || tag == domExcTag ||\n (typeof value.message == 'string' && typeof value.name == 'string' && !isPlainObject(value));\n }\n\n /**\n * Checks if `value` is a finite primitive number.\n *\n * **Note:** This method is based on\n * [`Number.isFinite`](https://mdn.io/Number/isFinite).\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Lang\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is a finite number, else `false`.\n * @example\n *\n * _.isFinite(3);\n * // => true\n *\n * _.isFinite(Number.MIN_VALUE);\n * // => true\n *\n * _.isFinite(Infinity);\n * // => false\n *\n * _.isFinite('3');\n * // => false\n */\n function isFinite(value) {\n return typeof value == 'number' && nativeIsFinite(value);\n }\n\n /**\n * Checks if `value` is classified as a `Function` object.\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Lang\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is a function, else `false`.\n * @example\n *\n * _.isFunction(_);\n * // => true\n *\n * _.isFunction(/abc/);\n * // => false\n */\n function isFunction(value) {\n if (!isObject(value)) {\n return false;\n }\n // The use of `Object#toString` avoids issues with the `typeof` operator\n // in Safari 9 which returns 'object' for typed arrays and other constructors.\n var tag = baseGetTag(value);\n return tag == funcTag || tag == genTag || tag == asyncTag || tag == proxyTag;\n }\n\n /**\n * Checks if `value` is an integer.\n *\n * **Note:** This method is based on\n * [`Number.isInteger`](https://mdn.io/Number/isInteger).\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Lang\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is an integer, else `false`.\n * @example\n *\n * _.isInteger(3);\n * // => true\n *\n * _.isInteger(Number.MIN_VALUE);\n * // => false\n *\n * _.isInteger(Infinity);\n * // => false\n *\n * _.isInteger('3');\n * // => false\n */\n function isInteger(value) {\n return typeof value == 'number' && value == toInteger(value);\n }\n\n /**\n * Checks if `value` is a valid array-like length.\n *\n * **Note:** This method is loosely based on\n * [`ToLength`](http://ecma-international.org/ecma-262/7.0/#sec-tolength).\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Lang\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is a valid length, else `false`.\n * @example\n *\n * _.isLength(3);\n * // => true\n *\n * _.isLength(Number.MIN_VALUE);\n * // => false\n *\n * _.isLength(Infinity);\n * // => false\n *\n * _.isLength('3');\n * // => false\n */\n function isLength(value) {\n return typeof value == 'number' &&\n value > -1 && value % 1 == 0 && value <= MAX_SAFE_INTEGER;\n }\n\n /**\n * Checks if `value` is the\n * [language type](http://www.ecma-international.org/ecma-262/7.0/#sec-ecmascript-language-types)\n * of `Object`. (e.g. arrays, functions, objects, regexes, `new Number(0)`, and `new String('')`)\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Lang\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is an object, else `false`.\n * @example\n *\n * _.isObject({});\n * // => true\n *\n * _.isObject([1, 2, 3]);\n * // => true\n *\n * _.isObject(_.noop);\n * // => true\n *\n * _.isObject(null);\n * // => false\n */\n function isObject(value) {\n var type = typeof value;\n return value != null && (type == 'object' || type == 'function');\n }\n\n /**\n * Checks if `value` is object-like. A value is object-like if it's not `null`\n * and has a `typeof` result of \"object\".\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Lang\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is object-like, else `false`.\n * @example\n *\n * _.isObjectLike({});\n * // => true\n *\n * _.isObjectLike([1, 2, 3]);\n * // => true\n *\n * _.isObjectLike(_.noop);\n * // => false\n *\n * _.isObjectLike(null);\n * // => false\n */\n function isObjectLike(value) {\n return value != null && typeof value == 'object';\n }\n\n /**\n * Checks if `value` is classified as a `Map` object.\n *\n * @static\n * @memberOf _\n * @since 4.3.0\n * @category Lang\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is a map, else `false`.\n * @example\n *\n * _.isMap(new Map);\n * // => true\n *\n * _.isMap(new WeakMap);\n * // => false\n */\n var isMap = nodeIsMap ? baseUnary(nodeIsMap) : baseIsMap;\n\n /**\n * Performs a partial deep comparison between `object` and `source` to\n * determine if `object` contains equivalent property values.\n *\n * **Note:** This method is equivalent to `_.matches` when `source` is\n * partially applied.\n *\n * Partial comparisons will match empty array and empty object `source`\n * values against any array or object value, respectively. See `_.isEqual`\n * for a list of supported value comparisons.\n *\n * @static\n * @memberOf _\n * @since 3.0.0\n * @category Lang\n * @param {Object} object The object to inspect.\n * @param {Object} source The object of property values to match.\n * @returns {boolean} Returns `true` if `object` is a match, else `false`.\n * @example\n *\n * var object = { 'a': 1, 'b': 2 };\n *\n * _.isMatch(object, { 'b': 2 });\n * // => true\n *\n * _.isMatch(object, { 'b': 1 });\n * // => false\n */\n function isMatch(object, source) {\n return object === source || baseIsMatch(object, source, getMatchData(source));\n }\n\n /**\n * This method is like `_.isMatch` except that it accepts `customizer` which\n * is invoked to compare values. If `customizer` returns `undefined`, comparisons\n * are handled by the method instead. The `customizer` is invoked with five\n * arguments: (objValue, srcValue, index|key, object, source).\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Lang\n * @param {Object} object The object to inspect.\n * @param {Object} source The object of property values to match.\n * @param {Function} [customizer] The function to customize comparisons.\n * @returns {boolean} Returns `true` if `object` is a match, else `false`.\n * @example\n *\n * function isGreeting(value) {\n * return /^h(?:i|ello)$/.test(value);\n * }\n *\n * function customizer(objValue, srcValue) {\n * if (isGreeting(objValue) && isGreeting(srcValue)) {\n * return true;\n * }\n * }\n *\n * var object = { 'greeting': 'hello' };\n * var source = { 'greeting': 'hi' };\n *\n * _.isMatchWith(object, source, customizer);\n * // => true\n */\n function isMatchWith(object, source, customizer) {\n customizer = typeof customizer == 'function' ? customizer : undefined;\n return baseIsMatch(object, source, getMatchData(source), customizer);\n }\n\n /**\n * Checks if `value` is `NaN`.\n *\n * **Note:** This method is based on\n * [`Number.isNaN`](https://mdn.io/Number/isNaN) and is not the same as\n * global [`isNaN`](https://mdn.io/isNaN) which returns `true` for\n * `undefined` and other non-number values.\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Lang\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is `NaN`, else `false`.\n * @example\n *\n * _.isNaN(NaN);\n * // => true\n *\n * _.isNaN(new Number(NaN));\n * // => true\n *\n * isNaN(undefined);\n * // => true\n *\n * _.isNaN(undefined);\n * // => false\n */\n function isNaN(value) {\n // An `NaN` primitive is the only value that is not equal to itself.\n // Perform the `toStringTag` check first to avoid errors with some\n // ActiveX objects in IE.\n return isNumber(value) && value != +value;\n }\n\n /**\n * Checks if `value` is a pristine native function.\n *\n * **Note:** This method can't reliably detect native functions in the presence\n * of the core-js package because core-js circumvents this kind of detection.\n * Despite multiple requests, the core-js maintainer has made it clear: any\n * attempt to fix the detection will be obstructed. As a result, we're left\n * with little choice but to throw an error. Unfortunately, this also affects\n * packages, like [babel-polyfill](https://www.npmjs.com/package/babel-polyfill),\n * which rely on core-js.\n *\n * @static\n * @memberOf _\n * @since 3.0.0\n * @category Lang\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is a native function,\n * else `false`.\n * @example\n *\n * _.isNative(Array.prototype.push);\n * // => true\n *\n * _.isNative(_);\n * // => false\n */\n function isNative(value) {\n if (isMaskable(value)) {\n throw new Error(CORE_ERROR_TEXT);\n }\n return baseIsNative(value);\n }\n\n /**\n * Checks if `value` is `null`.\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Lang\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is `null`, else `false`.\n * @example\n *\n * _.isNull(null);\n * // => true\n *\n * _.isNull(void 0);\n * // => false\n */\n function isNull(value) {\n return value === null;\n }\n\n /**\n * Checks if `value` is `null` or `undefined`.\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Lang\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is nullish, else `false`.\n * @example\n *\n * _.isNil(null);\n * // => true\n *\n * _.isNil(void 0);\n * // => true\n *\n * _.isNil(NaN);\n * // => false\n */\n function isNil(value) {\n return value == null;\n }\n\n /**\n * Checks if `value` is classified as a `Number` primitive or object.\n *\n * **Note:** To exclude `Infinity`, `-Infinity`, and `NaN`, which are\n * classified as numbers, use the `_.isFinite` method.\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Lang\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is a number, else `false`.\n * @example\n *\n * _.isNumber(3);\n * // => true\n *\n * _.isNumber(Number.MIN_VALUE);\n * // => true\n *\n * _.isNumber(Infinity);\n * // => true\n *\n * _.isNumber('3');\n * // => false\n */\n function isNumber(value) {\n return typeof value == 'number' ||\n (isObjectLike(value) && baseGetTag(value) == numberTag);\n }\n\n /**\n * Checks if `value` is a plain object, that is, an object created by the\n * `Object` constructor or one with a `[[Prototype]]` of `null`.\n *\n * @static\n * @memberOf _\n * @since 0.8.0\n * @category Lang\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is a plain object, else `false`.\n * @example\n *\n * function Foo() {\n * this.a = 1;\n * }\n *\n * _.isPlainObject(new Foo);\n * // => false\n *\n * _.isPlainObject([1, 2, 3]);\n * // => false\n *\n * _.isPlainObject({ 'x': 0, 'y': 0 });\n * // => true\n *\n * _.isPlainObject(Object.create(null));\n * // => true\n */\n function isPlainObject(value) {\n if (!isObjectLike(value) || baseGetTag(value) != objectTag) {\n return false;\n }\n var proto = getPrototype(value);\n if (proto === null) {\n return true;\n }\n var Ctor = hasOwnProperty.call(proto, 'constructor') && proto.constructor;\n return typeof Ctor == 'function' && Ctor instanceof Ctor &&\n funcToString.call(Ctor) == objectCtorString;\n }\n\n /**\n * Checks if `value` is classified as a `RegExp` object.\n *\n * @static\n * @memberOf _\n * @since 0.1.0\n * @category Lang\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is a regexp, else `false`.\n * @example\n *\n * _.isRegExp(/abc/);\n * // => true\n *\n * _.isRegExp('/abc/');\n * // => false\n */\n var isRegExp = nodeIsRegExp ? baseUnary(nodeIsRegExp) : baseIsRegExp;\n\n /**\n * Checks if `value` is a safe integer. An integer is safe if it's an IEEE-754\n * double precision number which isn't the result of a rounded unsafe integer.\n *\n * **Note:** This method is based on\n * [`Number.isSafeInteger`](https://mdn.io/Number/isSafeInteger).\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Lang\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is a safe integer, else `false`.\n * @example\n *\n * _.isSafeInteger(3);\n * // => true\n *\n * _.isSafeInteger(Number.MIN_VALUE);\n * // => false\n *\n * _.isSafeInteger(Infinity);\n * // => false\n *\n * _.isSafeInteger('3');\n * // => false\n */\n function isSafeInteger(value) {\n return isInteger(value) && value >= -MAX_SAFE_INTEGER && value <= MAX_SAFE_INTEGER;\n }\n\n /**\n * Checks if `value` is classified as a `Set` object.\n *\n * @static\n * @memberOf _\n * @since 4.3.0\n * @category Lang\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is a set, else `false`.\n * @example\n *\n * _.isSet(new Set);\n * // => true\n *\n * _.isSet(new WeakSet);\n * // => false\n */\n var isSet = nodeIsSet ? baseUnary(nodeIsSet) : baseIsSet;\n\n /**\n * Checks if `value` is classified as a `String` primitive or object.\n *\n * @static\n * @since 0.1.0\n * @memberOf _\n * @category Lang\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is a string, else `false`.\n * @example\n *\n * _.isString('abc');\n * // => true\n *\n * _.isString(1);\n * // => false\n */\n function isString(value) {\n return typeof value == 'string' ||\n (!isArray(value) && isObjectLike(value) && baseGetTag(value) == stringTag);\n }\n\n /**\n * Checks if `value` is classified as a `Symbol` primitive or object.\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Lang\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is a symbol, else `false`.\n * @example\n *\n * _.isSymbol(Symbol.iterator);\n * // => true\n *\n * _.isSymbol('abc');\n * // => false\n */\n function isSymbol(value) {\n return typeof value == 'symbol' ||\n (isObjectLike(value) && baseGetTag(value) == symbolTag);\n }\n\n /**\n * Checks if `value` is classified as a typed array.\n *\n * @static\n * @memberOf _\n * @since 3.0.0\n * @category Lang\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is a typed array, else `false`.\n * @example\n *\n * _.isTypedArray(new Uint8Array);\n * // => true\n *\n * _.isTypedArray([]);\n * // => false\n */\n var isTypedArray = nodeIsTypedArray ? baseUnary(nodeIsTypedArray) : baseIsTypedArray;\n\n /**\n * Checks if `value` is `undefined`.\n *\n * @static\n * @since 0.1.0\n * @memberOf _\n * @category Lang\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is `undefined`, else `false`.\n * @example\n *\n * _.isUndefined(void 0);\n * // => true\n *\n * _.isUndefined(null);\n * // => false\n */\n function isUndefined(value) {\n return value === undefined;\n }\n\n /**\n * Checks if `value` is classified as a `WeakMap` object.\n *\n * @static\n * @memberOf _\n * @since 4.3.0\n * @category Lang\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is a weak map, else `false`.\n * @example\n *\n * _.isWeakMap(new WeakMap);\n * // => true\n *\n * _.isWeakMap(new Map);\n * // => false\n */\n function isWeakMap(value) {\n return isObjectLike(value) && getTag(value) == weakMapTag;\n }\n\n /**\n * Checks if `value` is classified as a `WeakSet` object.\n *\n * @static\n * @memberOf _\n * @since 4.3.0\n * @category Lang\n * @param {*} value The value to check.\n * @returns {boolean} Returns `true` if `value` is a weak set, else `false`.\n * @example\n *\n * _.isWeakSet(new WeakSet);\n * // => true\n *\n * _.isWeakSet(new Set);\n * // => false\n */\n function isWeakSet(value) {\n return isObjectLike(value) && baseGetTag(value) == weakSetTag;\n }\n\n /**\n * Checks if `value` is less than `other`.\n *\n * @static\n * @memberOf _\n * @since 3.9.0\n * @category Lang\n * @param {*} value The value to compare.\n * @param {*} other The other value to compare.\n * @returns {boolean} Returns `true` if `value` is less than `other`,\n * else `false`.\n * @see _.gt\n * @example\n *\n * _.lt(1, 3);\n * // => true\n *\n * _.lt(3, 3);\n * // => false\n *\n * _.lt(3, 1);\n * // => false\n */\n var lt = createRelationalOperation(baseLt);\n\n /**\n * Checks if `value` is less than or equal to `other`.\n *\n * @static\n * @memberOf _\n * @since 3.9.0\n * @category Lang\n * @param {*} value The value to compare.\n * @param {*} other The other value to compare.\n * @returns {boolean} Returns `true` if `value` is less than or equal to\n * `other`, else `false`.\n * @see _.gte\n * @example\n *\n * _.lte(1, 3);\n * // => true\n *\n * _.lte(3, 3);\n * // => true\n *\n * _.lte(3, 1);\n * // => false\n */\n var lte = createRelationalOperation(function(value, other) {\n return value <= other;\n });\n\n /**\n * Converts `value` to an array.\n *\n * @static\n * @since 0.1.0\n * @memberOf _\n * @category Lang\n * @param {*} value The value to convert.\n * @returns {Array} Returns the converted array.\n * @example\n *\n * _.toArray({ 'a': 1, 'b': 2 });\n * // => [1, 2]\n *\n * _.toArray('abc');\n * // => ['a', 'b', 'c']\n *\n * _.toArray(1);\n * // => []\n *\n * _.toArray(null);\n * // => []\n */\n function toArray(value) {\n if (!value) {\n return [];\n }\n if (isArrayLike(value)) {\n return isString(value) ? stringToArray(value) : copyArray(value);\n }\n if (symIterator && value[symIterator]) {\n return iteratorToArray(value[symIterator]());\n }\n var tag = getTag(value),\n func = tag == mapTag ? mapToArray : (tag == setTag ? setToArray : values);\n\n return func(value);\n }\n\n /**\n * Converts `value` to a finite number.\n *\n * @static\n * @memberOf _\n * @since 4.12.0\n * @category Lang\n * @param {*} value The value to convert.\n * @returns {number} Returns the converted number.\n * @example\n *\n * _.toFinite(3.2);\n * // => 3.2\n *\n * _.toFinite(Number.MIN_VALUE);\n * // => 5e-324\n *\n * _.toFinite(Infinity);\n * // => 1.7976931348623157e+308\n *\n * _.toFinite('3.2');\n * // => 3.2\n */\n function toFinite(value) {\n if (!value) {\n return value === 0 ? value : 0;\n }\n value = toNumber(value);\n if (value === INFINITY || value === -INFINITY) {\n var sign = (value < 0 ? -1 : 1);\n return sign * MAX_INTEGER;\n }\n return value === value ? value : 0;\n }\n\n /**\n * Converts `value` to an integer.\n *\n * **Note:** This method is loosely based on\n * [`ToInteger`](http://www.ecma-international.org/ecma-262/7.0/#sec-tointeger).\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Lang\n * @param {*} value The value to convert.\n * @returns {number} Returns the converted integer.\n * @example\n *\n * _.toInteger(3.2);\n * // => 3\n *\n * _.toInteger(Number.MIN_VALUE);\n * // => 0\n *\n * _.toInteger(Infinity);\n * // => 1.7976931348623157e+308\n *\n * _.toInteger('3.2');\n * // => 3\n */\n function toInteger(value) {\n var result = toFinite(value),\n remainder = result % 1;\n\n return result === result ? (remainder ? result - remainder : result) : 0;\n }\n\n /**\n * Converts `value` to an integer suitable for use as the length of an\n * array-like object.\n *\n * **Note:** This method is based on\n * [`ToLength`](http://ecma-international.org/ecma-262/7.0/#sec-tolength).\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Lang\n * @param {*} value The value to convert.\n * @returns {number} Returns the converted integer.\n * @example\n *\n * _.toLength(3.2);\n * // => 3\n *\n * _.toLength(Number.MIN_VALUE);\n * // => 0\n *\n * _.toLength(Infinity);\n * // => 4294967295\n *\n * _.toLength('3.2');\n * // => 3\n */\n function toLength(value) {\n return value ? baseClamp(toInteger(value), 0, MAX_ARRAY_LENGTH) : 0;\n }\n\n /**\n * Converts `value` to a number.\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Lang\n * @param {*} value The value to process.\n * @returns {number} Returns the number.\n * @example\n *\n * _.toNumber(3.2);\n * // => 3.2\n *\n * _.toNumber(Number.MIN_VALUE);\n * // => 5e-324\n *\n * _.toNumber(Infinity);\n * // => Infinity\n *\n * _.toNumber('3.2');\n * // => 3.2\n */\n function toNumber(value) {\n if (typeof value == 'number') {\n return value;\n }\n if (isSymbol(value)) {\n return NAN;\n }\n if (isObject(value)) {\n var other = typeof value.valueOf == 'function' ? value.valueOf() : value;\n value = isObject(other) ? (other + '') : other;\n }\n if (typeof value != 'string') {\n return value === 0 ? value : +value;\n }\n value = baseTrim(value);\n var isBinary = reIsBinary.test(value);\n return (isBinary || reIsOctal.test(value))\n ? freeParseInt(value.slice(2), isBinary ? 2 : 8)\n : (reIsBadHex.test(value) ? NAN : +value);\n }\n\n /**\n * Converts `value` to a plain object flattening inherited enumerable string\n * keyed properties of `value` to own properties of the plain object.\n *\n * @static\n * @memberOf _\n * @since 3.0.0\n * @category Lang\n * @param {*} value The value to convert.\n * @returns {Object} Returns the converted plain object.\n * @example\n *\n * function Foo() {\n * this.b = 2;\n * }\n *\n * Foo.prototype.c = 3;\n *\n * _.assign({ 'a': 1 }, new Foo);\n * // => { 'a': 1, 'b': 2 }\n *\n * _.assign({ 'a': 1 }, _.toPlainObject(new Foo));\n * // => { 'a': 1, 'b': 2, 'c': 3 }\n */\n function toPlainObject(value) {\n return copyObject(value, keysIn(value));\n }\n\n /**\n * Converts `value` to a safe integer. A safe integer can be compared and\n * represented correctly.\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Lang\n * @param {*} value The value to convert.\n * @returns {number} Returns the converted integer.\n * @example\n *\n * _.toSafeInteger(3.2);\n * // => 3\n *\n * _.toSafeInteger(Number.MIN_VALUE);\n * // => 0\n *\n * _.toSafeInteger(Infinity);\n * // => 9007199254740991\n *\n * _.toSafeInteger('3.2');\n * // => 3\n */\n function toSafeInteger(value) {\n return value\n ? baseClamp(toInteger(value), -MAX_SAFE_INTEGER, MAX_SAFE_INTEGER)\n : (value === 0 ? value : 0);\n }\n\n /**\n * Converts `value` to a string. An empty string is returned for `null`\n * and `undefined` values. The sign of `-0` is preserved.\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Lang\n * @param {*} value The value to convert.\n * @returns {string} Returns the converted string.\n * @example\n *\n * _.toString(null);\n * // => ''\n *\n * _.toString(-0);\n * // => '-0'\n *\n * _.toString([1, 2, 3]);\n * // => '1,2,3'\n */\n function toString(value) {\n return value == null ? '' : baseToString(value);\n }\n\n /*------------------------------------------------------------------------*/\n\n /**\n * Assigns own enumerable string keyed properties of source objects to the\n * destination object. Source objects are applied from left to right.\n * Subsequent sources overwrite property assignments of previous sources.\n *\n * **Note:** This method mutates `object` and is loosely based on\n * [`Object.assign`](https://mdn.io/Object/assign).\n *\n * @static\n * @memberOf _\n * @since 0.10.0\n * @category Object\n * @param {Object} object The destination object.\n * @param {...Object} [sources] The source objects.\n * @returns {Object} Returns `object`.\n * @see _.assignIn\n * @example\n *\n * function Foo() {\n * this.a = 1;\n * }\n *\n * function Bar() {\n * this.c = 3;\n * }\n *\n * Foo.prototype.b = 2;\n * Bar.prototype.d = 4;\n *\n * _.assign({ 'a': 0 }, new Foo, new Bar);\n * // => { 'a': 1, 'c': 3 }\n */\n var assign = createAssigner(function(object, source) {\n if (isPrototype(source) || isArrayLike(source)) {\n copyObject(source, keys(source), object);\n return;\n }\n for (var key in source) {\n if (hasOwnProperty.call(source, key)) {\n assignValue(object, key, source[key]);\n }\n }\n });\n\n /**\n * This method is like `_.assign` except that it iterates over own and\n * inherited source properties.\n *\n * **Note:** This method mutates `object`.\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @alias extend\n * @category Object\n * @param {Object} object The destination object.\n * @param {...Object} [sources] The source objects.\n * @returns {Object} Returns `object`.\n * @see _.assign\n * @example\n *\n * function Foo() {\n * this.a = 1;\n * }\n *\n * function Bar() {\n * this.c = 3;\n * }\n *\n * Foo.prototype.b = 2;\n * Bar.prototype.d = 4;\n *\n * _.assignIn({ 'a': 0 }, new Foo, new Bar);\n * // => { 'a': 1, 'b': 2, 'c': 3, 'd': 4 }\n */\n var assignIn = createAssigner(function(object, source) {\n copyObject(source, keysIn(source), object);\n });\n\n /**\n * This method is like `_.assignIn` except that it accepts `customizer`\n * which is invoked to produce the assigned values. If `customizer` returns\n * `undefined`, assignment is handled by the method instead. The `customizer`\n * is invoked with five arguments: (objValue, srcValue, key, object, source).\n *\n * **Note:** This method mutates `object`.\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @alias extendWith\n * @category Object\n * @param {Object} object The destination object.\n * @param {...Object} sources The source objects.\n * @param {Function} [customizer] The function to customize assigned values.\n * @returns {Object} Returns `object`.\n * @see _.assignWith\n * @example\n *\n * function customizer(objValue, srcValue) {\n * return _.isUndefined(objValue) ? srcValue : objValue;\n * }\n *\n * var defaults = _.partialRight(_.assignInWith, customizer);\n *\n * defaults({ 'a': 1 }, { 'b': 2 }, { 'a': 3 });\n * // => { 'a': 1, 'b': 2 }\n */\n var assignInWith = createAssigner(function(object, source, srcIndex, customizer) {\n copyObject(source, keysIn(source), object, customizer);\n });\n\n /**\n * This method is like `_.assign` except that it accepts `customizer`\n * which is invoked to produce the assigned values. If `customizer` returns\n * `undefined`, assignment is handled by the method instead. The `customizer`\n * is invoked with five arguments: (objValue, srcValue, key, object, source).\n *\n * **Note:** This method mutates `object`.\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Object\n * @param {Object} object The destination object.\n * @param {...Object} sources The source objects.\n * @param {Function} [customizer] The function to customize assigned values.\n * @returns {Object} Returns `object`.\n * @see _.assignInWith\n * @example\n *\n * function customizer(objValue, srcValue) {\n * return _.isUndefined(objValue) ? srcValue : objValue;\n * }\n *\n * var defaults = _.partialRight(_.assignWith, customizer);\n *\n * defaults({ 'a': 1 }, { 'b': 2 }, { 'a': 3 });\n * // => { 'a': 1, 'b': 2 }\n */\n var assignWith = createAssigner(function(object, source, srcIndex, customizer) {\n copyObject(source, keys(source), object, customizer);\n });\n\n /**\n * Creates an array of values corresponding to `paths` of `object`.\n *\n * @static\n * @memberOf _\n * @since 1.0.0\n * @category Object\n * @param {Object} object The object to iterate over.\n * @param {...(string|string[])} [paths] The property paths to pick.\n * @returns {Array} Returns the picked values.\n * @example\n *\n * var object = { 'a': [{ 'b': { 'c': 3 } }, 4] };\n *\n * _.at(object, ['a[0].b.c', 'a[1]']);\n * // => [3, 4]\n */\n var at = flatRest(baseAt);\n\n /**\n * Creates an object that inherits from the `prototype` object. If a\n * `properties` object is given, its own enumerable string keyed properties\n * are assigned to the created object.\n *\n * @static\n * @memberOf _\n * @since 2.3.0\n * @category Object\n * @param {Object} prototype The object to inherit from.\n * @param {Object} [properties] The properties to assign to the object.\n * @returns {Object} Returns the new object.\n * @example\n *\n * function Shape() {\n * this.x = 0;\n * this.y = 0;\n * }\n *\n * function Circle() {\n * Shape.call(this);\n * }\n *\n * Circle.prototype = _.create(Shape.prototype, {\n * 'constructor': Circle\n * });\n *\n * var circle = new Circle;\n * circle instanceof Circle;\n * // => true\n *\n * circle instanceof Shape;\n * // => true\n */\n function create(prototype, properties) {\n var result = baseCreate(prototype);\n return properties == null ? result : baseAssign(result, properties);\n }\n\n /**\n * Assigns own and inherited enumerable string keyed properties of source\n * objects to the destination object for all destination properties that\n * resolve to `undefined`. Source objects are applied from left to right.\n * Once a property is set, additional values of the same property are ignored.\n *\n * **Note:** This method mutates `object`.\n *\n * @static\n * @since 0.1.0\n * @memberOf _\n * @category Object\n * @param {Object} object The destination object.\n * @param {...Object} [sources] The source objects.\n * @returns {Object} Returns `object`.\n * @see _.defaultsDeep\n * @example\n *\n * _.defaults({ 'a': 1 }, { 'b': 2 }, { 'a': 3 });\n * // => { 'a': 1, 'b': 2 }\n */\n var defaults = baseRest(function(object, sources) {\n object = Object(object);\n\n var index = -1;\n var length = sources.length;\n var guard = length > 2 ? sources[2] : undefined;\n\n if (guard && isIterateeCall(sources[0], sources[1], guard)) {\n length = 1;\n }\n\n while (++index < length) {\n var source = sources[index];\n var props = keysIn(source);\n var propsIndex = -1;\n var propsLength = props.length;\n\n while (++propsIndex < propsLength) {\n var key = props[propsIndex];\n var value = object[key];\n\n if (value === undefined ||\n (eq(value, objectProto[key]) && !hasOwnProperty.call(object, key))) {\n object[key] = source[key];\n }\n }\n }\n\n return object;\n });\n\n /**\n * This method is like `_.defaults` except that it recursively assigns\n * default properties.\n *\n * **Note:** This method mutates `object`.\n *\n * @static\n * @memberOf _\n * @since 3.10.0\n * @category Object\n * @param {Object} object The destination object.\n * @param {...Object} [sources] The source objects.\n * @returns {Object} Returns `object`.\n * @see _.defaults\n * @example\n *\n * _.defaultsDeep({ 'a': { 'b': 2 } }, { 'a': { 'b': 1, 'c': 3 } });\n * // => { 'a': { 'b': 2, 'c': 3 } }\n */\n var defaultsDeep = baseRest(function(args) {\n args.push(undefined, customDefaultsMerge);\n return apply(mergeWith, undefined, args);\n });\n\n /**\n * This method is like `_.find` except that it returns the key of the first\n * element `predicate` returns truthy for instead of the element itself.\n *\n * @static\n * @memberOf _\n * @since 1.1.0\n * @category Object\n * @param {Object} object The object to inspect.\n * @param {Function} [predicate=_.identity] The function invoked per iteration.\n * @returns {string|undefined} Returns the key of the matched element,\n * else `undefined`.\n * @example\n *\n * var users = {\n * 'barney': { 'age': 36, 'active': true },\n * 'fred': { 'age': 40, 'active': false },\n * 'pebbles': { 'age': 1, 'active': true }\n * };\n *\n * _.findKey(users, function(o) { return o.age < 40; });\n * // => 'barney' (iteration order is not guaranteed)\n *\n * // The `_.matches` iteratee shorthand.\n * _.findKey(users, { 'age': 1, 'active': true });\n * // => 'pebbles'\n *\n * // The `_.matchesProperty` iteratee shorthand.\n * _.findKey(users, ['active', false]);\n * // => 'fred'\n *\n * // The `_.property` iteratee shorthand.\n * _.findKey(users, 'active');\n * // => 'barney'\n */\n function findKey(object, predicate) {\n return baseFindKey(object, getIteratee(predicate, 3), baseForOwn);\n }\n\n /**\n * This method is like `_.findKey` except that it iterates over elements of\n * a collection in the opposite order.\n *\n * @static\n * @memberOf _\n * @since 2.0.0\n * @category Object\n * @param {Object} object The object to inspect.\n * @param {Function} [predicate=_.identity] The function invoked per iteration.\n * @returns {string|undefined} Returns the key of the matched element,\n * else `undefined`.\n * @example\n *\n * var users = {\n * 'barney': { 'age': 36, 'active': true },\n * 'fred': { 'age': 40, 'active': false },\n * 'pebbles': { 'age': 1, 'active': true }\n * };\n *\n * _.findLastKey(users, function(o) { return o.age < 40; });\n * // => returns 'pebbles' assuming `_.findKey` returns 'barney'\n *\n * // The `_.matches` iteratee shorthand.\n * _.findLastKey(users, { 'age': 36, 'active': true });\n * // => 'barney'\n *\n * // The `_.matchesProperty` iteratee shorthand.\n * _.findLastKey(users, ['active', false]);\n * // => 'fred'\n *\n * // The `_.property` iteratee shorthand.\n * _.findLastKey(users, 'active');\n * // => 'pebbles'\n */\n function findLastKey(object, predicate) {\n return baseFindKey(object, getIteratee(predicate, 3), baseForOwnRight);\n }\n\n /**\n * Iterates over own and inherited enumerable string keyed properties of an\n * object and invokes `iteratee` for each property. The iteratee is invoked\n * with three arguments: (value, key, object). Iteratee functions may exit\n * iteration early by explicitly returning `false`.\n *\n * @static\n * @memberOf _\n * @since 0.3.0\n * @category Object\n * @param {Object} object The object to iterate over.\n * @param {Function} [iteratee=_.identity] The function invoked per iteration.\n * @returns {Object} Returns `object`.\n * @see _.forInRight\n * @example\n *\n * function Foo() {\n * this.a = 1;\n * this.b = 2;\n * }\n *\n * Foo.prototype.c = 3;\n *\n * _.forIn(new Foo, function(value, key) {\n * console.log(key);\n * });\n * // => Logs 'a', 'b', then 'c' (iteration order is not guaranteed).\n */\n function forIn(object, iteratee) {\n return object == null\n ? object\n : baseFor(object, getIteratee(iteratee, 3), keysIn);\n }\n\n /**\n * This method is like `_.forIn` except that it iterates over properties of\n * `object` in the opposite order.\n *\n * @static\n * @memberOf _\n * @since 2.0.0\n * @category Object\n * @param {Object} object The object to iterate over.\n * @param {Function} [iteratee=_.identity] The function invoked per iteration.\n * @returns {Object} Returns `object`.\n * @see _.forIn\n * @example\n *\n * function Foo() {\n * this.a = 1;\n * this.b = 2;\n * }\n *\n * Foo.prototype.c = 3;\n *\n * _.forInRight(new Foo, function(value, key) {\n * console.log(key);\n * });\n * // => Logs 'c', 'b', then 'a' assuming `_.forIn` logs 'a', 'b', then 'c'.\n */\n function forInRight(object, iteratee) {\n return object == null\n ? object\n : baseForRight(object, getIteratee(iteratee, 3), keysIn);\n }\n\n /**\n * Iterates over own enumerable string keyed properties of an object and\n * invokes `iteratee` for each property. The iteratee is invoked with three\n * arguments: (value, key, object). Iteratee functions may exit iteration\n * early by explicitly returning `false`.\n *\n * @static\n * @memberOf _\n * @since 0.3.0\n * @category Object\n * @param {Object} object The object to iterate over.\n * @param {Function} [iteratee=_.identity] The function invoked per iteration.\n * @returns {Object} Returns `object`.\n * @see _.forOwnRight\n * @example\n *\n * function Foo() {\n * this.a = 1;\n * this.b = 2;\n * }\n *\n * Foo.prototype.c = 3;\n *\n * _.forOwn(new Foo, function(value, key) {\n * console.log(key);\n * });\n * // => Logs 'a' then 'b' (iteration order is not guaranteed).\n */\n function forOwn(object, iteratee) {\n return object && baseForOwn(object, getIteratee(iteratee, 3));\n }\n\n /**\n * This method is like `_.forOwn` except that it iterates over properties of\n * `object` in the opposite order.\n *\n * @static\n * @memberOf _\n * @since 2.0.0\n * @category Object\n * @param {Object} object The object to iterate over.\n * @param {Function} [iteratee=_.identity] The function invoked per iteration.\n * @returns {Object} Returns `object`.\n * @see _.forOwn\n * @example\n *\n * function Foo() {\n * this.a = 1;\n * this.b = 2;\n * }\n *\n * Foo.prototype.c = 3;\n *\n * _.forOwnRight(new Foo, function(value, key) {\n * console.log(key);\n * });\n * // => Logs 'b' then 'a' assuming `_.forOwn` logs 'a' then 'b'.\n */\n function forOwnRight(object, iteratee) {\n return object && baseForOwnRight(object, getIteratee(iteratee, 3));\n }\n\n /**\n * Creates an array of function property names from own enumerable properties\n * of `object`.\n *\n * @static\n * @since 0.1.0\n * @memberOf _\n * @category Object\n * @param {Object} object The object to inspect.\n * @returns {Array} Returns the function names.\n * @see _.functionsIn\n * @example\n *\n * function Foo() {\n * this.a = _.constant('a');\n * this.b = _.constant('b');\n * }\n *\n * Foo.prototype.c = _.constant('c');\n *\n * _.functions(new Foo);\n * // => ['a', 'b']\n */\n function functions(object) {\n return object == null ? [] : baseFunctions(object, keys(object));\n }\n\n /**\n * Creates an array of function property names from own and inherited\n * enumerable properties of `object`.\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Object\n * @param {Object} object The object to inspect.\n * @returns {Array} Returns the function names.\n * @see _.functions\n * @example\n *\n * function Foo() {\n * this.a = _.constant('a');\n * this.b = _.constant('b');\n * }\n *\n * Foo.prototype.c = _.constant('c');\n *\n * _.functionsIn(new Foo);\n * // => ['a', 'b', 'c']\n */\n function functionsIn(object) {\n return object == null ? [] : baseFunctions(object, keysIn(object));\n }\n\n /**\n * Gets the value at `path` of `object`. If the resolved value is\n * `undefined`, the `defaultValue` is returned in its place.\n *\n * @static\n * @memberOf _\n * @since 3.7.0\n * @category Object\n * @param {Object} object The object to query.\n * @param {Array|string} path The path of the property to get.\n * @param {*} [defaultValue] The value returned for `undefined` resolved values.\n * @returns {*} Returns the resolved value.\n * @example\n *\n * var object = { 'a': [{ 'b': { 'c': 3 } }] };\n *\n * _.get(object, 'a[0].b.c');\n * // => 3\n *\n * _.get(object, ['a', '0', 'b', 'c']);\n * // => 3\n *\n * _.get(object, 'a.b.c', 'default');\n * // => 'default'\n */\n function get(object, path, defaultValue) {\n var result = object == null ? undefined : baseGet(object, path);\n return result === undefined ? defaultValue : result;\n }\n\n /**\n * Checks if `path` is a direct property of `object`.\n *\n * @static\n * @since 0.1.0\n * @memberOf _\n * @category Object\n * @param {Object} object The object to query.\n * @param {Array|string} path The path to check.\n * @returns {boolean} Returns `true` if `path` exists, else `false`.\n * @example\n *\n * var object = { 'a': { 'b': 2 } };\n * var other = _.create({ 'a': _.create({ 'b': 2 }) });\n *\n * _.has(object, 'a');\n * // => true\n *\n * _.has(object, 'a.b');\n * // => true\n *\n * _.has(object, ['a', 'b']);\n * // => true\n *\n * _.has(other, 'a');\n * // => false\n */\n function has(object, path) {\n return object != null && hasPath(object, path, baseHas);\n }\n\n /**\n * Checks if `path` is a direct or inherited property of `object`.\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Object\n * @param {Object} object The object to query.\n * @param {Array|string} path The path to check.\n * @returns {boolean} Returns `true` if `path` exists, else `false`.\n * @example\n *\n * var object = _.create({ 'a': _.create({ 'b': 2 }) });\n *\n * _.hasIn(object, 'a');\n * // => true\n *\n * _.hasIn(object, 'a.b');\n * // => true\n *\n * _.hasIn(object, ['a', 'b']);\n * // => true\n *\n * _.hasIn(object, 'b');\n * // => false\n */\n function hasIn(object, path) {\n return object != null && hasPath(object, path, baseHasIn);\n }\n\n /**\n * Creates an object composed of the inverted keys and values of `object`.\n * If `object` contains duplicate values, subsequent values overwrite\n * property assignments of previous values.\n *\n * @static\n * @memberOf _\n * @since 0.7.0\n * @category Object\n * @param {Object} object The object to invert.\n * @returns {Object} Returns the new inverted object.\n * @example\n *\n * var object = { 'a': 1, 'b': 2, 'c': 1 };\n *\n * _.invert(object);\n * // => { '1': 'c', '2': 'b' }\n */\n var invert = createInverter(function(result, value, key) {\n if (value != null &&\n typeof value.toString != 'function') {\n value = nativeObjectToString.call(value);\n }\n\n result[value] = key;\n }, constant(identity));\n\n /**\n * This method is like `_.invert` except that the inverted object is generated\n * from the results of running each element of `object` thru `iteratee`. The\n * corresponding inverted value of each inverted key is an array of keys\n * responsible for generating the inverted value. The iteratee is invoked\n * with one argument: (value).\n *\n * @static\n * @memberOf _\n * @since 4.1.0\n * @category Object\n * @param {Object} object The object to invert.\n * @param {Function} [iteratee=_.identity] The iteratee invoked per element.\n * @returns {Object} Returns the new inverted object.\n * @example\n *\n * var object = { 'a': 1, 'b': 2, 'c': 1 };\n *\n * _.invertBy(object);\n * // => { '1': ['a', 'c'], '2': ['b'] }\n *\n * _.invertBy(object, function(value) {\n * return 'group' + value;\n * });\n * // => { 'group1': ['a', 'c'], 'group2': ['b'] }\n */\n var invertBy = createInverter(function(result, value, key) {\n if (value != null &&\n typeof value.toString != 'function') {\n value = nativeObjectToString.call(value);\n }\n\n if (hasOwnProperty.call(result, value)) {\n result[value].push(key);\n } else {\n result[value] = [key];\n }\n }, getIteratee);\n\n /**\n * Invokes the method at `path` of `object`.\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Object\n * @param {Object} object The object to query.\n * @param {Array|string} path The path of the method to invoke.\n * @param {...*} [args] The arguments to invoke the method with.\n * @returns {*} Returns the result of the invoked method.\n * @example\n *\n * var object = { 'a': [{ 'b': { 'c': [1, 2, 3, 4] } }] };\n *\n * _.invoke(object, 'a[0].b.c.slice', 1, 3);\n * // => [2, 3]\n */\n var invoke = baseRest(baseInvoke);\n\n /**\n * Creates an array of the own enumerable property names of `object`.\n *\n * **Note:** Non-object values are coerced to objects. See the\n * [ES spec](http://ecma-international.org/ecma-262/7.0/#sec-object.keys)\n * for more details.\n *\n * @static\n * @since 0.1.0\n * @memberOf _\n * @category Object\n * @param {Object} object The object to query.\n * @returns {Array} Returns the array of property names.\n * @example\n *\n * function Foo() {\n * this.a = 1;\n * this.b = 2;\n * }\n *\n * Foo.prototype.c = 3;\n *\n * _.keys(new Foo);\n * // => ['a', 'b'] (iteration order is not guaranteed)\n *\n * _.keys('hi');\n * // => ['0', '1']\n */\n function keys(object) {\n return isArrayLike(object) ? arrayLikeKeys(object) : baseKeys(object);\n }\n\n /**\n * Creates an array of the own and inherited enumerable property names of `object`.\n *\n * **Note:** Non-object values are coerced to objects.\n *\n * @static\n * @memberOf _\n * @since 3.0.0\n * @category Object\n * @param {Object} object The object to query.\n * @returns {Array} Returns the array of property names.\n * @example\n *\n * function Foo() {\n * this.a = 1;\n * this.b = 2;\n * }\n *\n * Foo.prototype.c = 3;\n *\n * _.keysIn(new Foo);\n * // => ['a', 'b', 'c'] (iteration order is not guaranteed)\n */\n function keysIn(object) {\n return isArrayLike(object) ? arrayLikeKeys(object, true) : baseKeysIn(object);\n }\n\n /**\n * The opposite of `_.mapValues`; this method creates an object with the\n * same values as `object` and keys generated by running each own enumerable\n * string keyed property of `object` thru `iteratee`. The iteratee is invoked\n * with three arguments: (value, key, object).\n *\n * @static\n * @memberOf _\n * @since 3.8.0\n * @category Object\n * @param {Object} object The object to iterate over.\n * @param {Function} [iteratee=_.identity] The function invoked per iteration.\n * @returns {Object} Returns the new mapped object.\n * @see _.mapValues\n * @example\n *\n * _.mapKeys({ 'a': 1, 'b': 2 }, function(value, key) {\n * return key + value;\n * });\n * // => { 'a1': 1, 'b2': 2 }\n */\n function mapKeys(object, iteratee) {\n var result = {};\n iteratee = getIteratee(iteratee, 3);\n\n baseForOwn(object, function(value, key, object) {\n baseAssignValue(result, iteratee(value, key, object), value);\n });\n return result;\n }\n\n /**\n * Creates an object with the same keys as `object` and values generated\n * by running each own enumerable string keyed property of `object` thru\n * `iteratee`. The iteratee is invoked with three arguments:\n * (value, key, object).\n *\n * @static\n * @memberOf _\n * @since 2.4.0\n * @category Object\n * @param {Object} object The object to iterate over.\n * @param {Function} [iteratee=_.identity] The function invoked per iteration.\n * @returns {Object} Returns the new mapped object.\n * @see _.mapKeys\n * @example\n *\n * var users = {\n * 'fred': { 'user': 'fred', 'age': 40 },\n * 'pebbles': { 'user': 'pebbles', 'age': 1 }\n * };\n *\n * _.mapValues(users, function(o) { return o.age; });\n * // => { 'fred': 40, 'pebbles': 1 } (iteration order is not guaranteed)\n *\n * // The `_.property` iteratee shorthand.\n * _.mapValues(users, 'age');\n * // => { 'fred': 40, 'pebbles': 1 } (iteration order is not guaranteed)\n */\n function mapValues(object, iteratee) {\n var result = {};\n iteratee = getIteratee(iteratee, 3);\n\n baseForOwn(object, function(value, key, object) {\n baseAssignValue(result, key, iteratee(value, key, object));\n });\n return result;\n }\n\n /**\n * This method is like `_.assign` except that it recursively merges own and\n * inherited enumerable string keyed properties of source objects into the\n * destination object. Source properties that resolve to `undefined` are\n * skipped if a destination value exists. Array and plain object properties\n * are merged recursively. Other objects and value types are overridden by\n * assignment. Source objects are applied from left to right. Subsequent\n * sources overwrite property assignments of previous sources.\n *\n * **Note:** This method mutates `object`.\n *\n * @static\n * @memberOf _\n * @since 0.5.0\n * @category Object\n * @param {Object} object The destination object.\n * @param {...Object} [sources] The source objects.\n * @returns {Object} Returns `object`.\n * @example\n *\n * var object = {\n * 'a': [{ 'b': 2 }, { 'd': 4 }]\n * };\n *\n * var other = {\n * 'a': [{ 'c': 3 }, { 'e': 5 }]\n * };\n *\n * _.merge(object, other);\n * // => { 'a': [{ 'b': 2, 'c': 3 }, { 'd': 4, 'e': 5 }] }\n */\n var merge = createAssigner(function(object, source, srcIndex) {\n baseMerge(object, source, srcIndex);\n });\n\n /**\n * This method is like `_.merge` except that it accepts `customizer` which\n * is invoked to produce the merged values of the destination and source\n * properties. If `customizer` returns `undefined`, merging is handled by the\n * method instead. The `customizer` is invoked with six arguments:\n * (objValue, srcValue, key, object, source, stack).\n *\n * **Note:** This method mutates `object`.\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Object\n * @param {Object} object The destination object.\n * @param {...Object} sources The source objects.\n * @param {Function} customizer The function to customize assigned values.\n * @returns {Object} Returns `object`.\n * @example\n *\n * function customizer(objValue, srcValue) {\n * if (_.isArray(objValue)) {\n * return objValue.concat(srcValue);\n * }\n * }\n *\n * var object = { 'a': [1], 'b': [2] };\n * var other = { 'a': [3], 'b': [4] };\n *\n * _.mergeWith(object, other, customizer);\n * // => { 'a': [1, 3], 'b': [2, 4] }\n */\n var mergeWith = createAssigner(function(object, source, srcIndex, customizer) {\n baseMerge(object, source, srcIndex, customizer);\n });\n\n /**\n * The opposite of `_.pick`; this method creates an object composed of the\n * own and inherited enumerable property paths of `object` that are not omitted.\n *\n * **Note:** This method is considerably slower than `_.pick`.\n *\n * @static\n * @since 0.1.0\n * @memberOf _\n * @category Object\n * @param {Object} object The source object.\n * @param {...(string|string[])} [paths] The property paths to omit.\n * @returns {Object} Returns the new object.\n * @example\n *\n * var object = { 'a': 1, 'b': '2', 'c': 3 };\n *\n * _.omit(object, ['a', 'c']);\n * // => { 'b': '2' }\n */\n var omit = flatRest(function(object, paths) {\n var result = {};\n if (object == null) {\n return result;\n }\n var isDeep = false;\n paths = arrayMap(paths, function(path) {\n path = castPath(path, object);\n isDeep || (isDeep = path.length > 1);\n return path;\n });\n copyObject(object, getAllKeysIn(object), result);\n if (isDeep) {\n result = baseClone(result, CLONE_DEEP_FLAG | CLONE_FLAT_FLAG | CLONE_SYMBOLS_FLAG, customOmitClone);\n }\n var length = paths.length;\n while (length--) {\n baseUnset(result, paths[length]);\n }\n return result;\n });\n\n /**\n * The opposite of `_.pickBy`; this method creates an object composed of\n * the own and inherited enumerable string keyed properties of `object` that\n * `predicate` doesn't return truthy for. The predicate is invoked with two\n * arguments: (value, key).\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Object\n * @param {Object} object The source object.\n * @param {Function} [predicate=_.identity] The function invoked per property.\n * @returns {Object} Returns the new object.\n * @example\n *\n * var object = { 'a': 1, 'b': '2', 'c': 3 };\n *\n * _.omitBy(object, _.isNumber);\n * // => { 'b': '2' }\n */\n function omitBy(object, predicate) {\n return pickBy(object, negate(getIteratee(predicate)));\n }\n\n /**\n * Creates an object composed of the picked `object` properties.\n *\n * @static\n * @since 0.1.0\n * @memberOf _\n * @category Object\n * @param {Object} object The source object.\n * @param {...(string|string[])} [paths] The property paths to pick.\n * @returns {Object} Returns the new object.\n * @example\n *\n * var object = { 'a': 1, 'b': '2', 'c': 3 };\n *\n * _.pick(object, ['a', 'c']);\n * // => { 'a': 1, 'c': 3 }\n */\n var pick = flatRest(function(object, paths) {\n return object == null ? {} : basePick(object, paths);\n });\n\n /**\n * Creates an object composed of the `object` properties `predicate` returns\n * truthy for. The predicate is invoked with two arguments: (value, key).\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Object\n * @param {Object} object The source object.\n * @param {Function} [predicate=_.identity] The function invoked per property.\n * @returns {Object} Returns the new object.\n * @example\n *\n * var object = { 'a': 1, 'b': '2', 'c': 3 };\n *\n * _.pickBy(object, _.isNumber);\n * // => { 'a': 1, 'c': 3 }\n */\n function pickBy(object, predicate) {\n if (object == null) {\n return {};\n }\n var props = arrayMap(getAllKeysIn(object), function(prop) {\n return [prop];\n });\n predicate = getIteratee(predicate);\n return basePickBy(object, props, function(value, path) {\n return predicate(value, path[0]);\n });\n }\n\n /**\n * This method is like `_.get` except that if the resolved value is a\n * function it's invoked with the `this` binding of its parent object and\n * its result is returned.\n *\n * @static\n * @since 0.1.0\n * @memberOf _\n * @category Object\n * @param {Object} object The object to query.\n * @param {Array|string} path The path of the property to resolve.\n * @param {*} [defaultValue] The value returned for `undefined` resolved values.\n * @returns {*} Returns the resolved value.\n * @example\n *\n * var object = { 'a': [{ 'b': { 'c1': 3, 'c2': _.constant(4) } }] };\n *\n * _.result(object, 'a[0].b.c1');\n * // => 3\n *\n * _.result(object, 'a[0].b.c2');\n * // => 4\n *\n * _.result(object, 'a[0].b.c3', 'default');\n * // => 'default'\n *\n * _.result(object, 'a[0].b.c3', _.constant('default'));\n * // => 'default'\n */\n function result(object, path, defaultValue) {\n path = castPath(path, object);\n\n var index = -1,\n length = path.length;\n\n // Ensure the loop is entered when path is empty.\n if (!length) {\n length = 1;\n object = undefined;\n }\n while (++index < length) {\n var value = object == null ? undefined : object[toKey(path[index])];\n if (value === undefined) {\n index = length;\n value = defaultValue;\n }\n object = isFunction(value) ? value.call(object) : value;\n }\n return object;\n }\n\n /**\n * Sets the value at `path` of `object`. If a portion of `path` doesn't exist,\n * it's created. Arrays are created for missing index properties while objects\n * are created for all other missing properties. Use `_.setWith` to customize\n * `path` creation.\n *\n * **Note:** This method mutates `object`.\n *\n * @static\n * @memberOf _\n * @since 3.7.0\n * @category Object\n * @param {Object} object The object to modify.\n * @param {Array|string} path The path of the property to set.\n * @param {*} value The value to set.\n * @returns {Object} Returns `object`.\n * @example\n *\n * var object = { 'a': [{ 'b': { 'c': 3 } }] };\n *\n * _.set(object, 'a[0].b.c', 4);\n * console.log(object.a[0].b.c);\n * // => 4\n *\n * _.set(object, ['x', '0', 'y', 'z'], 5);\n * console.log(object.x[0].y.z);\n * // => 5\n */\n function set(object, path, value) {\n return object == null ? object : baseSet(object, path, value);\n }\n\n /**\n * This method is like `_.set` except that it accepts `customizer` which is\n * invoked to produce the objects of `path`. If `customizer` returns `undefined`\n * path creation is handled by the method instead. The `customizer` is invoked\n * with three arguments: (nsValue, key, nsObject).\n *\n * **Note:** This method mutates `object`.\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Object\n * @param {Object} object The object to modify.\n * @param {Array|string} path The path of the property to set.\n * @param {*} value The value to set.\n * @param {Function} [customizer] The function to customize assigned values.\n * @returns {Object} Returns `object`.\n * @example\n *\n * var object = {};\n *\n * _.setWith(object, '[0][1]', 'a', Object);\n * // => { '0': { '1': 'a' } }\n */\n function setWith(object, path, value, customizer) {\n customizer = typeof customizer == 'function' ? customizer : undefined;\n return object == null ? object : baseSet(object, path, value, customizer);\n }\n\n /**\n * Creates an array of own enumerable string keyed-value pairs for `object`\n * which can be consumed by `_.fromPairs`. If `object` is a map or set, its\n * entries are returned.\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @alias entries\n * @category Object\n * @param {Object} object The object to query.\n * @returns {Array} Returns the key-value pairs.\n * @example\n *\n * function Foo() {\n * this.a = 1;\n * this.b = 2;\n * }\n *\n * Foo.prototype.c = 3;\n *\n * _.toPairs(new Foo);\n * // => [['a', 1], ['b', 2]] (iteration order is not guaranteed)\n */\n var toPairs = createToPairs(keys);\n\n /**\n * Creates an array of own and inherited enumerable string keyed-value pairs\n * for `object` which can be consumed by `_.fromPairs`. If `object` is a map\n * or set, its entries are returned.\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @alias entriesIn\n * @category Object\n * @param {Object} object The object to query.\n * @returns {Array} Returns the key-value pairs.\n * @example\n *\n * function Foo() {\n * this.a = 1;\n * this.b = 2;\n * }\n *\n * Foo.prototype.c = 3;\n *\n * _.toPairsIn(new Foo);\n * // => [['a', 1], ['b', 2], ['c', 3]] (iteration order is not guaranteed)\n */\n var toPairsIn = createToPairs(keysIn);\n\n /**\n * An alternative to `_.reduce`; this method transforms `object` to a new\n * `accumulator` object which is the result of running each of its own\n * enumerable string keyed properties thru `iteratee`, with each invocation\n * potentially mutating the `accumulator` object. If `accumulator` is not\n * provided, a new object with the same `[[Prototype]]` will be used. The\n * iteratee is invoked with four arguments: (accumulator, value, key, object).\n * Iteratee functions may exit iteration early by explicitly returning `false`.\n *\n * @static\n * @memberOf _\n * @since 1.3.0\n * @category Object\n * @param {Object} object The object to iterate over.\n * @param {Function} [iteratee=_.identity] The function invoked per iteration.\n * @param {*} [accumulator] The custom accumulator value.\n * @returns {*} Returns the accumulated value.\n * @example\n *\n * _.transform([2, 3, 4], function(result, n) {\n * result.push(n *= n);\n * return n % 2 == 0;\n * }, []);\n * // => [4, 9]\n *\n * _.transform({ 'a': 1, 'b': 2, 'c': 1 }, function(result, value, key) {\n * (result[value] || (result[value] = [])).push(key);\n * }, {});\n * // => { '1': ['a', 'c'], '2': ['b'] }\n */\n function transform(object, iteratee, accumulator) {\n var isArr = isArray(object),\n isArrLike = isArr || isBuffer(object) || isTypedArray(object);\n\n iteratee = getIteratee(iteratee, 4);\n if (accumulator == null) {\n var Ctor = object && object.constructor;\n if (isArrLike) {\n accumulator = isArr ? new Ctor : [];\n }\n else if (isObject(object)) {\n accumulator = isFunction(Ctor) ? baseCreate(getPrototype(object)) : {};\n }\n else {\n accumulator = {};\n }\n }\n (isArrLike ? arrayEach : baseForOwn)(object, function(value, index, object) {\n return iteratee(accumulator, value, index, object);\n });\n return accumulator;\n }\n\n /**\n * Removes the property at `path` of `object`.\n *\n * **Note:** This method mutates `object`.\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Object\n * @param {Object} object The object to modify.\n * @param {Array|string} path The path of the property to unset.\n * @returns {boolean} Returns `true` if the property is deleted, else `false`.\n * @example\n *\n * var object = { 'a': [{ 'b': { 'c': 7 } }] };\n * _.unset(object, 'a[0].b.c');\n * // => true\n *\n * console.log(object);\n * // => { 'a': [{ 'b': {} }] };\n *\n * _.unset(object, ['a', '0', 'b', 'c']);\n * // => true\n *\n * console.log(object);\n * // => { 'a': [{ 'b': {} }] };\n */\n function unset(object, path) {\n return object == null ? true : baseUnset(object, path);\n }\n\n /**\n * This method is like `_.set` except that accepts `updater` to produce the\n * value to set. Use `_.updateWith` to customize `path` creation. The `updater`\n * is invoked with one argument: (value).\n *\n * **Note:** This method mutates `object`.\n *\n * @static\n * @memberOf _\n * @since 4.6.0\n * @category Object\n * @param {Object} object The object to modify.\n * @param {Array|string} path The path of the property to set.\n * @param {Function} updater The function to produce the updated value.\n * @returns {Object} Returns `object`.\n * @example\n *\n * var object = { 'a': [{ 'b': { 'c': 3 } }] };\n *\n * _.update(object, 'a[0].b.c', function(n) { return n * n; });\n * console.log(object.a[0].b.c);\n * // => 9\n *\n * _.update(object, 'x[0].y.z', function(n) { return n ? n + 1 : 0; });\n * console.log(object.x[0].y.z);\n * // => 0\n */\n function update(object, path, updater) {\n return object == null ? object : baseUpdate(object, path, castFunction(updater));\n }\n\n /**\n * This method is like `_.update` except that it accepts `customizer` which is\n * invoked to produce the objects of `path`. If `customizer` returns `undefined`\n * path creation is handled by the method instead. The `customizer` is invoked\n * with three arguments: (nsValue, key, nsObject).\n *\n * **Note:** This method mutates `object`.\n *\n * @static\n * @memberOf _\n * @since 4.6.0\n * @category Object\n * @param {Object} object The object to modify.\n * @param {Array|string} path The path of the property to set.\n * @param {Function} updater The function to produce the updated value.\n * @param {Function} [customizer] The function to customize assigned values.\n * @returns {Object} Returns `object`.\n * @example\n *\n * var object = {};\n *\n * _.updateWith(object, '[0][1]', _.constant('a'), Object);\n * // => { '0': { '1': 'a' } }\n */\n function updateWith(object, path, updater, customizer) {\n customizer = typeof customizer == 'function' ? customizer : undefined;\n return object == null ? object : baseUpdate(object, path, castFunction(updater), customizer);\n }\n\n /**\n * Creates an array of the own enumerable string keyed property values of `object`.\n *\n * **Note:** Non-object values are coerced to objects.\n *\n * @static\n * @since 0.1.0\n * @memberOf _\n * @category Object\n * @param {Object} object The object to query.\n * @returns {Array} Returns the array of property values.\n * @example\n *\n * function Foo() {\n * this.a = 1;\n * this.b = 2;\n * }\n *\n * Foo.prototype.c = 3;\n *\n * _.values(new Foo);\n * // => [1, 2] (iteration order is not guaranteed)\n *\n * _.values('hi');\n * // => ['h', 'i']\n */\n function values(object) {\n return object == null ? [] : baseValues(object, keys(object));\n }\n\n /**\n * Creates an array of the own and inherited enumerable string keyed property\n * values of `object`.\n *\n * **Note:** Non-object values are coerced to objects.\n *\n * @static\n * @memberOf _\n * @since 3.0.0\n * @category Object\n * @param {Object} object The object to query.\n * @returns {Array} Returns the array of property values.\n * @example\n *\n * function Foo() {\n * this.a = 1;\n * this.b = 2;\n * }\n *\n * Foo.prototype.c = 3;\n *\n * _.valuesIn(new Foo);\n * // => [1, 2, 3] (iteration order is not guaranteed)\n */\n function valuesIn(object) {\n return object == null ? [] : baseValues(object, keysIn(object));\n }\n\n /*------------------------------------------------------------------------*/\n\n /**\n * Clamps `number` within the inclusive `lower` and `upper` bounds.\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category Number\n * @param {number} number The number to clamp.\n * @param {number} [lower] The lower bound.\n * @param {number} upper The upper bound.\n * @returns {number} Returns the clamped number.\n * @example\n *\n * _.clamp(-10, -5, 5);\n * // => -5\n *\n * _.clamp(10, -5, 5);\n * // => 5\n */\n function clamp(number, lower, upper) {\n if (upper === undefined) {\n upper = lower;\n lower = undefined;\n }\n if (upper !== undefined) {\n upper = toNumber(upper);\n upper = upper === upper ? upper : 0;\n }\n if (lower !== undefined) {\n lower = toNumber(lower);\n lower = lower === lower ? lower : 0;\n }\n return baseClamp(toNumber(number), lower, upper);\n }\n\n /**\n * Checks if `n` is between `start` and up to, but not including, `end`. If\n * `end` is not specified, it's set to `start` with `start` then set to `0`.\n * If `start` is greater than `end` the params are swapped to support\n * negative ranges.\n *\n * @static\n * @memberOf _\n * @since 3.3.0\n * @category Number\n * @param {number} number The number to check.\n * @param {number} [start=0] The start of the range.\n * @param {number} end The end of the range.\n * @returns {boolean} Returns `true` if `number` is in the range, else `false`.\n * @see _.range, _.rangeRight\n * @example\n *\n * _.inRange(3, 2, 4);\n * // => true\n *\n * _.inRange(4, 8);\n * // => true\n *\n * _.inRange(4, 2);\n * // => false\n *\n * _.inRange(2, 2);\n * // => false\n *\n * _.inRange(1.2, 2);\n * // => true\n *\n * _.inRange(5.2, 4);\n * // => false\n *\n * _.inRange(-3, -2, -6);\n * // => true\n */\n function inRange(number, start, end) {\n start = toFinite(start);\n if (end === undefined) {\n end = start;\n start = 0;\n } else {\n end = toFinite(end);\n }\n number = toNumber(number);\n return baseInRange(number, start, end);\n }\n\n /**\n * Produces a random number between the inclusive `lower` and `upper` bounds.\n * If only one argument is provided a number between `0` and the given number\n * is returned. If `floating` is `true`, or either `lower` or `upper` are\n * floats, a floating-point number is returned instead of an integer.\n *\n * **Note:** JavaScript follows the IEEE-754 standard for resolving\n * floating-point values which can produce unexpected results.\n *\n * @static\n * @memberOf _\n * @since 0.7.0\n * @category Number\n * @param {number} [lower=0] The lower bound.\n * @param {number} [upper=1] The upper bound.\n * @param {boolean} [floating] Specify returning a floating-point number.\n * @returns {number} Returns the random number.\n * @example\n *\n * _.random(0, 5);\n * // => an integer between 0 and 5\n *\n * _.random(5);\n * // => also an integer between 0 and 5\n *\n * _.random(5, true);\n * // => a floating-point number between 0 and 5\n *\n * _.random(1.2, 5.2);\n * // => a floating-point number between 1.2 and 5.2\n */\n function random(lower, upper, floating) {\n if (floating && typeof floating != 'boolean' && isIterateeCall(lower, upper, floating)) {\n upper = floating = undefined;\n }\n if (floating === undefined) {\n if (typeof upper == 'boolean') {\n floating = upper;\n upper = undefined;\n }\n else if (typeof lower == 'boolean') {\n floating = lower;\n lower = undefined;\n }\n }\n if (lower === undefined && upper === undefined) {\n lower = 0;\n upper = 1;\n }\n else {\n lower = toFinite(lower);\n if (upper === undefined) {\n upper = lower;\n lower = 0;\n } else {\n upper = toFinite(upper);\n }\n }\n if (lower > upper) {\n var temp = lower;\n lower = upper;\n upper = temp;\n }\n if (floating || lower % 1 || upper % 1) {\n var rand = nativeRandom();\n return nativeMin(lower + (rand * (upper - lower + freeParseFloat('1e-' + ((rand + '').length - 1)))), upper);\n }\n return baseRandom(lower, upper);\n }\n\n /*------------------------------------------------------------------------*/\n\n /**\n * Converts `string` to [camel case](https://en.wikipedia.org/wiki/CamelCase).\n *\n * @static\n * @memberOf _\n * @since 3.0.0\n * @category String\n * @param {string} [string=''] The string to convert.\n * @returns {string} Returns the camel cased string.\n * @example\n *\n * _.camelCase('Foo Bar');\n * // => 'fooBar'\n *\n * _.camelCase('--foo-bar--');\n * // => 'fooBar'\n *\n * _.camelCase('__FOO_BAR__');\n * // => 'fooBar'\n */\n var camelCase = createCompounder(function(result, word, index) {\n word = word.toLowerCase();\n return result + (index ? capitalize(word) : word);\n });\n\n /**\n * Converts the first character of `string` to upper case and the remaining\n * to lower case.\n *\n * @static\n * @memberOf _\n * @since 3.0.0\n * @category String\n * @param {string} [string=''] The string to capitalize.\n * @returns {string} Returns the capitalized string.\n * @example\n *\n * _.capitalize('FRED');\n * // => 'Fred'\n */\n function capitalize(string) {\n return upperFirst(toString(string).toLowerCase());\n }\n\n /**\n * Deburrs `string` by converting\n * [Latin-1 Supplement](https://en.wikipedia.org/wiki/Latin-1_Supplement_(Unicode_block)#Character_table)\n * and [Latin Extended-A](https://en.wikipedia.org/wiki/Latin_Extended-A)\n * letters to basic Latin letters and removing\n * [combining diacritical marks](https://en.wikipedia.org/wiki/Combining_Diacritical_Marks).\n *\n * @static\n * @memberOf _\n * @since 3.0.0\n * @category String\n * @param {string} [string=''] The string to deburr.\n * @returns {string} Returns the deburred string.\n * @example\n *\n * _.deburr('déjà vu');\n * // => 'deja vu'\n */\n function deburr(string) {\n string = toString(string);\n return string && string.replace(reLatin, deburrLetter).replace(reComboMark, '');\n }\n\n /**\n * Checks if `string` ends with the given target string.\n *\n * @static\n * @memberOf _\n * @since 3.0.0\n * @category String\n * @param {string} [string=''] The string to inspect.\n * @param {string} [target] The string to search for.\n * @param {number} [position=string.length] The position to search up to.\n * @returns {boolean} Returns `true` if `string` ends with `target`,\n * else `false`.\n * @example\n *\n * _.endsWith('abc', 'c');\n * // => true\n *\n * _.endsWith('abc', 'b');\n * // => false\n *\n * _.endsWith('abc', 'b', 2);\n * // => true\n */\n function endsWith(string, target, position) {\n string = toString(string);\n target = baseToString(target);\n\n var length = string.length;\n position = position === undefined\n ? length\n : baseClamp(toInteger(position), 0, length);\n\n var end = position;\n position -= target.length;\n return position >= 0 && string.slice(position, end) == target;\n }\n\n /**\n * Converts the characters \"&\", \"<\", \">\", '\"', and \"'\" in `string` to their\n * corresponding HTML entities.\n *\n * **Note:** No other characters are escaped. To escape additional\n * characters use a third-party library like [_he_](https://mths.be/he).\n *\n * Though the \">\" character is escaped for symmetry, characters like\n * \">\" and \"/\" don't need escaping in HTML and have no special meaning\n * unless they're part of a tag or unquoted attribute value. See\n * [Mathias Bynens's article](https://mathiasbynens.be/notes/ambiguous-ampersands)\n * (under \"semi-related fun fact\") for more details.\n *\n * When working with HTML you should always\n * [quote attribute values](http://wonko.com/post/html-escaping) to reduce\n * XSS vectors.\n *\n * @static\n * @since 0.1.0\n * @memberOf _\n * @category String\n * @param {string} [string=''] The string to escape.\n * @returns {string} Returns the escaped string.\n * @example\n *\n * _.escape('fred, barney, & pebbles');\n * // => 'fred, barney, & pebbles'\n */\n function escape(string) {\n string = toString(string);\n return (string && reHasUnescapedHtml.test(string))\n ? string.replace(reUnescapedHtml, escapeHtmlChar)\n : string;\n }\n\n /**\n * Escapes the `RegExp` special characters \"^\", \"$\", \"\\\", \".\", \"*\", \"+\",\n * \"?\", \"(\", \")\", \"[\", \"]\", \"{\", \"}\", and \"|\" in `string`.\n *\n * @static\n * @memberOf _\n * @since 3.0.0\n * @category String\n * @param {string} [string=''] The string to escape.\n * @returns {string} Returns the escaped string.\n * @example\n *\n * _.escapeRegExp('[lodash](https://lodash.com/)');\n * // => '\\[lodash\\]\\(https://lodash\\.com/\\)'\n */\n function escapeRegExp(string) {\n string = toString(string);\n return (string && reHasRegExpChar.test(string))\n ? string.replace(reRegExpChar, '\\\\$&')\n : string;\n }\n\n /**\n * Converts `string` to\n * [kebab case](https://en.wikipedia.org/wiki/Letter_case#Special_case_styles).\n *\n * @static\n * @memberOf _\n * @since 3.0.0\n * @category String\n * @param {string} [string=''] The string to convert.\n * @returns {string} Returns the kebab cased string.\n * @example\n *\n * _.kebabCase('Foo Bar');\n * // => 'foo-bar'\n *\n * _.kebabCase('fooBar');\n * // => 'foo-bar'\n *\n * _.kebabCase('__FOO_BAR__');\n * // => 'foo-bar'\n */\n var kebabCase = createCompounder(function(result, word, index) {\n return result + (index ? '-' : '') + word.toLowerCase();\n });\n\n /**\n * Converts `string`, as space separated words, to lower case.\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category String\n * @param {string} [string=''] The string to convert.\n * @returns {string} Returns the lower cased string.\n * @example\n *\n * _.lowerCase('--Foo-Bar--');\n * // => 'foo bar'\n *\n * _.lowerCase('fooBar');\n * // => 'foo bar'\n *\n * _.lowerCase('__FOO_BAR__');\n * // => 'foo bar'\n */\n var lowerCase = createCompounder(function(result, word, index) {\n return result + (index ? ' ' : '') + word.toLowerCase();\n });\n\n /**\n * Converts the first character of `string` to lower case.\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category String\n * @param {string} [string=''] The string to convert.\n * @returns {string} Returns the converted string.\n * @example\n *\n * _.lowerFirst('Fred');\n * // => 'fred'\n *\n * _.lowerFirst('FRED');\n * // => 'fRED'\n */\n var lowerFirst = createCaseFirst('toLowerCase');\n\n /**\n * Pads `string` on the left and right sides if it's shorter than `length`.\n * Padding characters are truncated if they can't be evenly divided by `length`.\n *\n * @static\n * @memberOf _\n * @since 3.0.0\n * @category String\n * @param {string} [string=''] The string to pad.\n * @param {number} [length=0] The padding length.\n * @param {string} [chars=' '] The string used as padding.\n * @returns {string} Returns the padded string.\n * @example\n *\n * _.pad('abc', 8);\n * // => ' abc '\n *\n * _.pad('abc', 8, '_-');\n * // => '_-abc_-_'\n *\n * _.pad('abc', 3);\n * // => 'abc'\n */\n function pad(string, length, chars) {\n string = toString(string);\n length = toInteger(length);\n\n var strLength = length ? stringSize(string) : 0;\n if (!length || strLength >= length) {\n return string;\n }\n var mid = (length - strLength) / 2;\n return (\n createPadding(nativeFloor(mid), chars) +\n string +\n createPadding(nativeCeil(mid), chars)\n );\n }\n\n /**\n * Pads `string` on the right side if it's shorter than `length`. Padding\n * characters are truncated if they exceed `length`.\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category String\n * @param {string} [string=''] The string to pad.\n * @param {number} [length=0] The padding length.\n * @param {string} [chars=' '] The string used as padding.\n * @returns {string} Returns the padded string.\n * @example\n *\n * _.padEnd('abc', 6);\n * // => 'abc '\n *\n * _.padEnd('abc', 6, '_-');\n * // => 'abc_-_'\n *\n * _.padEnd('abc', 3);\n * // => 'abc'\n */\n function padEnd(string, length, chars) {\n string = toString(string);\n length = toInteger(length);\n\n var strLength = length ? stringSize(string) : 0;\n return (length && strLength < length)\n ? (string + createPadding(length - strLength, chars))\n : string;\n }\n\n /**\n * Pads `string` on the left side if it's shorter than `length`. Padding\n * characters are truncated if they exceed `length`.\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category String\n * @param {string} [string=''] The string to pad.\n * @param {number} [length=0] The padding length.\n * @param {string} [chars=' '] The string used as padding.\n * @returns {string} Returns the padded string.\n * @example\n *\n * _.padStart('abc', 6);\n * // => ' abc'\n *\n * _.padStart('abc', 6, '_-');\n * // => '_-_abc'\n *\n * _.padStart('abc', 3);\n * // => 'abc'\n */\n function padStart(string, length, chars) {\n string = toString(string);\n length = toInteger(length);\n\n var strLength = length ? stringSize(string) : 0;\n return (length && strLength < length)\n ? (createPadding(length - strLength, chars) + string)\n : string;\n }\n\n /**\n * Converts `string` to an integer of the specified radix. If `radix` is\n * `undefined` or `0`, a `radix` of `10` is used unless `value` is a\n * hexadecimal, in which case a `radix` of `16` is used.\n *\n * **Note:** This method aligns with the\n * [ES5 implementation](https://es5.github.io/#x15.1.2.2) of `parseInt`.\n *\n * @static\n * @memberOf _\n * @since 1.1.0\n * @category String\n * @param {string} string The string to convert.\n * @param {number} [radix=10] The radix to interpret `value` by.\n * @param- {Object} [guard] Enables use as an iteratee for methods like `_.map`.\n * @returns {number} Returns the converted integer.\n * @example\n *\n * _.parseInt('08');\n * // => 8\n *\n * _.map(['6', '08', '10'], _.parseInt);\n * // => [6, 8, 10]\n */\n function parseInt(string, radix, guard) {\n if (guard || radix == null) {\n radix = 0;\n } else if (radix) {\n radix = +radix;\n }\n return nativeParseInt(toString(string).replace(reTrimStart, ''), radix || 0);\n }\n\n /**\n * Repeats the given string `n` times.\n *\n * @static\n * @memberOf _\n * @since 3.0.0\n * @category String\n * @param {string} [string=''] The string to repeat.\n * @param {number} [n=1] The number of times to repeat the string.\n * @param- {Object} [guard] Enables use as an iteratee for methods like `_.map`.\n * @returns {string} Returns the repeated string.\n * @example\n *\n * _.repeat('*', 3);\n * // => '***'\n *\n * _.repeat('abc', 2);\n * // => 'abcabc'\n *\n * _.repeat('abc', 0);\n * // => ''\n */\n function repeat(string, n, guard) {\n if ((guard ? isIterateeCall(string, n, guard) : n === undefined)) {\n n = 1;\n } else {\n n = toInteger(n);\n }\n return baseRepeat(toString(string), n);\n }\n\n /**\n * Replaces matches for `pattern` in `string` with `replacement`.\n *\n * **Note:** This method is based on\n * [`String#replace`](https://mdn.io/String/replace).\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category String\n * @param {string} [string=''] The string to modify.\n * @param {RegExp|string} pattern The pattern to replace.\n * @param {Function|string} replacement The match replacement.\n * @returns {string} Returns the modified string.\n * @example\n *\n * _.replace('Hi Fred', 'Fred', 'Barney');\n * // => 'Hi Barney'\n */\n function replace() {\n var args = arguments,\n string = toString(args[0]);\n\n return args.length < 3 ? string : string.replace(args[1], args[2]);\n }\n\n /**\n * Converts `string` to\n * [snake case](https://en.wikipedia.org/wiki/Snake_case).\n *\n * @static\n * @memberOf _\n * @since 3.0.0\n * @category String\n * @param {string} [string=''] The string to convert.\n * @returns {string} Returns the snake cased string.\n * @example\n *\n * _.snakeCase('Foo Bar');\n * // => 'foo_bar'\n *\n * _.snakeCase('fooBar');\n * // => 'foo_bar'\n *\n * _.snakeCase('--FOO-BAR--');\n * // => 'foo_bar'\n */\n var snakeCase = createCompounder(function(result, word, index) {\n return result + (index ? '_' : '') + word.toLowerCase();\n });\n\n /**\n * Splits `string` by `separator`.\n *\n * **Note:** This method is based on\n * [`String#split`](https://mdn.io/String/split).\n *\n * @static\n * @memberOf _\n * @since 4.0.0\n * @category String\n * @param {string} [string=''] The string to split.\n * @param {RegExp|string} separator The separator pattern to split by.\n * @param {number} [limit] The length to truncate results to.\n * @returns {Array} Returns the string segments.\n * @example\n *\n * _.split('a-b-c', '-', 2);\n * // => ['a', 'b']\n */\n function split(string, separator, limit) {\n if (limit && typeof limit != 'number' && isIterateeCall(string, separator, limit)) {\n separator = limit = undefined;\n }\n limit = limit === undefined ? MAX_ARRAY_LENGTH : limit >>> 0;\n if (!limit) {\n return [];\n }\n string = toString(string);\n if (string && (\n typeof separator == 'string' ||\n (separator != null && !isRegExp(separator))\n )) {\n separator = baseToString(separator);\n if (!separator && hasUnicode(string)) {\n return castSlice(stringToArray(string), 0, limit);\n }\n }\n return string.split(separator, limit);\n }\n\n /**\n * Converts `string` to\n * [start case](https://en.wikipedia.org/wiki/Letter_case#Stylistic_or_specialised_usage).\n *\n * @static\n * @memberOf _\n * @since 3.1.0\n * @category String\n * @param {string} [string=''] The string to convert.\n * @returns {string} Returns the start cased string.\n * @example\n *\n * _.startCase('--foo-bar--');\n * // => 'Foo Bar'\n *\n * _.startCase('fooBar');\n * // => 'Foo Bar'\n *\n * _.startCase('__FOO_BAR__');\n * // => 'FOO BAR'\n */\n var startCase = createCompounder(function(result, word, index) {\n return result + (index ? ' ' : '') + upperFirst(word);\n });\n\n /**\n * Checks if `string` starts with the given target string.\n *\n * @static\n * @memberOf _\n * @since 3.0.0\n * @category String\n * @param {string} [string=''] The string to inspect.\n * @param {string} [target] The string to search for.\n * @param {number} [position=0] The position to search from.\n * @returns {boolean} Returns `true` if `string` starts with `target`,\n * else `false`.\n * @example\n *\n * _.startsWith('abc', 'a');\n * // => true\n *\n * _.startsWith('abc', 'b');\n * // => false\n *\n * _.startsWith('abc', 'b', 1);\n * // => true\n */\n function startsWith(string, target, position) {\n string = toString(string);\n position = position == null\n ? 0\n : baseClamp(toInteger(position), 0, string.length);\n\n target = baseToString(target);\n return string.slice(position, position + target.length) == target;\n }\n\n /**\n * Creates a compiled template function that can interpolate data properties\n * in \"interpolate\" delimiters, HTML-escape interpolated data properties in\n * \"escape\" delimiters, and execute JavaScript in \"evaluate\" delimiters. Data\n * properties may be accessed as free variables in the template. If a setting\n * object is given, it takes precedence over `_.templateSettings` values.\n *\n * **Note:** In the development build `_.template` utilizes\n * [sourceURLs](http://www.html5rocks.com/en/tutorials/developertools/sourcemaps/#toc-sourceurl)\n * for easier debugging.\n *\n * For more information on precompiling templates see\n * [lodash's custom builds documentation](https://lodash.com/custom-builds).\n *\n * For more information on Chrome extension sandboxes see\n * [Chrome's extensions documentation](https://developer.chrome.com/extensions/sandboxingEval).\n *\n * @static\n * @since 0.1.0\n * @memberOf _\n * @category String\n * @param {string} [string=''] The template string.\n * @param {Object} [options={}] The options object.\n * @param {RegExp} [options.escape=_.templateSettings.escape]\n * The HTML \"escape\" delimiter.\n * @param {RegExp} [options.evaluate=_.templateSettings.evaluate]\n * The \"evaluate\" delimiter.\n * @param {Object} [options.imports=_.templateSettings.imports]\n * An object to import into the template as free variables.\n * @param {RegExp} [options.interpolate=_.templateSettings.interpolate]\n * The \"interpolate\" delimiter.\n * @param {string} [options.sourceURL='lodash.templateSources[n]']\n * The sourceURL of the compiled template.\n * @param {string} [options.variable='obj']\n * The data object variable name.\n * @param- {Object} [guard] Enables use as an iteratee for methods like `_.map`.\n * @returns {Function} Returns the compiled template function.\n * @example\n *\n * // Use the \"interpolate\" delimiter to create a compiled template.\n * var compiled = _.template('hello <%= user %>!');\n * compiled({ 'user': 'fred' });\n * // => 'hello fred!'\n *\n * // Use the HTML \"escape\" delimiter to escape data property values.\n * var compiled = _.template('<%- value %>');\n * compiled({ 'value': '\n","\n\n","\n\n"," + {% endblock %} diff --git a/aircox/templates/aircox/episode_form.html b/aircox/templates/aircox/episode_form.html new file mode 100644 index 0000000..2138681 --- /dev/null +++ b/aircox/templates/aircox/episode_form.html @@ -0,0 +1,15 @@ +{% extends "./page_form.html" %} +{% load static i18n humanize honeypot aircox %} + +{% block page-form %} + + + +{% endblock %} diff --git a/aircox/templates/aircox/forms/form_field.html b/aircox/templates/aircox/forms/form_field.html new file mode 100644 index 0000000..8500c2a --- /dev/null +++ b/aircox/templates/aircox/forms/form_field.html @@ -0,0 +1,25 @@ +{% comment %} +Render a form field instance as field (to be used when no model instance is provided). Value is binded as vue, class to Bulma + +Context: +- name: field name +- field: form field +- value: input ":value" attribute +- vbind: if True, use ":value" instead of "value" +- hidden: if True, hidden field +{% endcomment %} +{% load aircox %} + +{% if field.widget.is_hidden or hidden %} + +{% elif field|is_checkbox %} + +{% elif field|is_select %} + +{% else %} + +{% endif %} diff --git a/aircox/templates/aircox/forms/formset.html b/aircox/templates/aircox/forms/formset.html new file mode 100644 index 0000000..47a807d --- /dev/null +++ b/aircox/templates/aircox/forms/formset.html @@ -0,0 +1,53 @@ +{% comment %} +Base template for list editor based on formsets (tracklist_editor, playlist_editor). + +Context: +- tag_id: id of parent component +- tag: vue component tag (a-playlist-editor, etc.) +- related_field: field name that target object +- object: related object +- formset: formset used to render the list editor +- formset_data: formset data +{% endcomment %} + +{% load aircox aircox_admin static i18n %} + +{% with formset.form.base_fields as fields %} +{% block outer %} +
+ {{ formset.non_form_errors }} + + + <{{ tag|default:"a-form-set" }} ref="formset" + {% block tag-attrs %} + :form-data="{{ formset_data|json }}" + :labels="window.aircox.labels" + :columns="[{% for n, f in fields.items %}{% if not f.widget.is_hidden %}'{{ n }}',{% endif %}{% endfor %} ]" + settings-url="{% url "api:user-settings" %}" + data-prefix="{{ formset.prefix }}-" + {% endblock %}> + {% block inner %} + + {% for name, field in fields.items %} + {% if not field.widget.is_hidden and not field.is_readonly %} + + {% endif %} + {% endfor %} + {% endblock %} + +
+{% endblock %} +{% endwith %} diff --git a/aircox/templates/aircox/forms/v_form_field.html b/aircox/templates/aircox/forms/v_form_field.html new file mode 100644 index 0000000..d2809a1 --- /dev/null +++ b/aircox/templates/aircox/forms/v_form_field.html @@ -0,0 +1,24 @@ +{% comment %} +Render a form field instance as field (to be used when no model instance is provided). Value is binded as vue, class to Bulma + +Context: +- name: field name +- field: form field +- value: input ":v-model" attribute +- hidden: if True, hidden field +{% endcomment %} +{% load aircox %} + +{% if field.widget.is_hidden or hidden %} + +{% elif field|is_checkbox %} + +{% elif field|is_select %} + +{% else %} + +{% endif %} diff --git a/aircox/templates/aircox/home.html b/aircox/templates/aircox/home.html index 337ea76..529ade5 100644 --- a/aircox/templates/aircox/home.html +++ b/aircox/templates/aircox/home.html @@ -1,85 +1,74 @@ -{% extends "aircox/page_list.html" %} -{% comment %}Home page{% endcomment %} -{% load i18n %} +{% extends "./public.html" %} +{% load i18n aircox %} + {% block head_title %}{{ station.name }}{% endblock %} -{% block title %} -{% if not page or not page.title %}{{ station.name }} -{% else %}{{ block.super }} -{% endif %} -{% endblock %} +{% block title %}{% if page %}{{ block.super }}{% endif %}{% endblock %} -{% block before_list %}{% endblock %} -{% block pages_list %} -{% if page and page.content %}
{% endif %} +{% block breadcrumbs-container %}{% endblock %} + +{% block content-container %} +{{ block.super }} + {% if next_diffs %} -
- {% with render_card=True %} - {% for object in next_diffs %} - {% with is_primary=object.is_now %} -
-

- {% if is_primary %} - - - {% else %} - {{ object.start|date:"H:i" }} - {% endif %} +
+

+ {% with station.name as station %} + {% blocktrans %}Today on {{ station }}{% endblocktrans %} + {% endwith %} +

- {% if object.episode.category %} - // {{ object.episode.category.title }} - {% endif %} -

- {% include object.item_template_name %} +
+ {% with next_diffs.0 as obj %} + {% page_widget "wide" obj.episode diffusion=obj timetable=True %} + {% endwith %}
- {% endwith %} - {% endfor %} - {% endwith %} -
-{% endif %} -{% if object_list %} -

{% translate "Today" %}

-
- {% include 'aircox/widgets/diffusion_list.html' %} +
+ + + {% for obj in next_diffs|slice:"1:" %} + {% if object != diffusion %} + {% page_widget "card" obj.episode diffusion=obj timetable=True %} + {% endif %} + {% endfor %} +
{% endif %} -{% endblock %} -{% block pagination %} - + + +{% endif %} + + +{% if podcasts %} +
+

{% translate "Last podcasts" %}

+ {% include "./widgets/carousel.html" with objects=podcasts url_name="podcast-list" url_label=_("All podcasts") %} +
+{% endif %} + +{% if publications %} +
+

{% translate "Last publications" %}

+ {% include "./widgets/carousel.html" with objects=publications url_name="page-list" url_label=_("All publications") %} +
+{% endif %} {% endblock %} - -{% block sidebar %} -
-

{% translate "Previously on air" %}

- {% with has_cover=False %} - {% with logs as object_list %} - {% include "aircox/widgets/log_list.html" %} - {% endwith %} - {% endwith %} -
- -
-

{% translate "Last publications" %}

- {% with hide_schedule=True %} - {% with has_headline=False %} - {% for object in last_publications %} - {% include object.item_template_name|default:'aircox/widgets/page_item.html' %} - {% endfor %} - {% endwith %} - {% endwith %} -
-{% endblock %} +{% block pages_list %}{% endblock %} diff --git a/aircox/templates/aircox/log_list.html b/aircox/templates/aircox/log_list.html deleted file mode 100644 index 1341000..0000000 --- a/aircox/templates/aircox/log_list.html +++ /dev/null @@ -1,29 +0,0 @@ -{% extends "aircox/page_list.html" %} -{% comment %}List of logs for a specific date{% endcomment %} -{% load i18n humanize aircox %} - -{% block title %} -{% if not page or not page.title %} -{% with station.name as station %} -{% blocktranslate %}That happened on {{ station }}{% endblocktranslate %} -{% endwith %} -{% else %} -{{ block.super }} -{% endif %} -{% endblock %} - - -{% block subtitle %}{{ date|date:"l d F Y" }}{% endblock %} - -{% block before_list %} -{% with "log-list" as url_name %} -{% include "aircox/widgets/dates_menu.html" %} -{% endwith %} -{% endblock %} - -{% block pages_list %} -
- {#

{{ date }}

#} - {% include "aircox/widgets/log_list.html" %} -
-{% endblock %} diff --git a/aircox/templates/aircox/page_detail.html b/aircox/templates/aircox/page_detail.html index 7a5cdf3..6ff1ee8 100644 --- a/aircox/templates/aircox/page_detail.html +++ b/aircox/templates/aircox/page_detail.html @@ -1,4 +1,4 @@ -{% extends "aircox/basepage_detail.html" %} +{% extends "aircox/public.html" %} {% load static i18n humanize honeypot aircox %} {% comment %} Base template used to display a Page @@ -6,83 +6,88 @@ Base template used to display a Page Context: - page: page - parent: parent page +- related_objects: list of object to display as related publications +- related_url: url to the full list of related_objects {% endcomment %} -{% block header_crumbs %} -{{ block.super }} -{% if page.category %} -{% if parent %} / {% endif %} {{ page.category.title }} +{% block breadcrumbs %} +{% if parent %} + {% include "./widgets/breadcrumbs.html" with page=parent %} + {% if page %} + + {{ page|verbose_name:True }} + + {% endif %} +{% elif page %} + {% include "./widgets/breadcrumbs.html" with page=page no_title=True %} {% endif %} {% endblock %} -{% block top-nav-tools %} -{% has_perm page "change" as can_edit %} -{% if can_edit %} - - - -   - {% translate "Edit" %} - -{% endif %} +{% block title-container %} +{{ block.super }} +{% block page-actions %} + {% include "aircox/widgets/page_actions.html" %} +{% endblock %} {% endblock %} {% block main %} {{ block.super }} +{% block related %} +{% if related_objects %} +
+ {% with models=object|verbose_name:True %} +

{% blocktranslate %}Related {{models}}{% endblocktranslate %}

+ + {% include "./widgets/carousel.html" with objects=related_objects url_name=object.list_url_name url_category=object.category %} + {% endwith %} +
+{% endif %} +{% endblock %} + {% block comments %} -{% if comments or comment_form %} -
-

{% translate "Comments" %}

+{% if comments %} +
+

{% translate "Comments" %}

- {% for comment in comments %} -
-
-

- {{ comment.nickname }} - -
- {{ comment.content }} -

-
-
+ {% for object in comments %} + {% page_widget "item" object %} {% endfor %} +
+{% endif %} - {% if comment_form %} +{% if comment_form %} +
+

{% translate "Post a comment" %}

-
{% translate "Post a comment" %}
{% csrf_token %} {% render_honeypot_field "website" %} - {% for field in comment_form %} -
-
- -
-
-
-

{{ field }}

- {% if field.errors %} -

{{ field.errors }}

- {% endif %} - {% if field.help_text %} -

{{ field.help_text|safe }}

- {% endif %} -
+
+
+ {{ comment_form.content }}
+ + {% for field in comment_form %} + {% if field.name != "content" %} +
+ +
{{ field }}
+
+ {% if field.errors %} +

{{ field.errors }}

+ {% endif %} + {% if field.help_text %} +

{{ field.help_text|safe }}

+ {% endif %} + {% endif %} {% endfor %} +
- - +
- {% endif %}
{% endif %} diff --git a/aircox/templates/aircox/page_form.html b/aircox/templates/aircox/page_form.html new file mode 100644 index 0000000..cc20310 --- /dev/null +++ b/aircox/templates/aircox/page_form.html @@ -0,0 +1,129 @@ +{% extends "./dashboard/base.html" %} +{% load static aircox aircox_admin i18n %} + +{% block init-scripts %} +aircox.labels = {% inline_labels %} +{{ block.super }} +{% endblock %} + +{% block header-cover %}{% endblock %} + +{% block title %} +{% if not object %} + {% with view.model|verbose_name as model %} + {% blocktranslate %}Create a {{model}}{% endblocktranslate %} + {% endwith %} +{% else %} + {{ block.super }} +{% endif %} +{% endblock %} + + +{% block content-container %} + + + + + +
+
+ {% block page-form %} + {% csrf_token %} +
+
+ {% for field in form %} + {% if field.name not in "cover,content" %} +
+ +
+ {% if field.name == "pub_date" %} + + {% else %} + {% include "aircox/forms/form_field.html" with field=field.field name=field.name value=field.initial %} + {% endif %} +
+

{{ field.help_text }}

+
+ {% endif %} + {% if field.errors %} +

{{ field.errors }}

+ {% endif %} + {% if field.help_text %} +

{{ field.help_text|safe }}

+ {% endif %} + {% endfor %} +
+
+ {% with form.cover as field %} + {% block page-form-cover %} + + {% spaceless %} +
+ + +
+ {% endspaceless %} + {% endblock %} + {% endwith %} +
+
+ + {% with form.content as field %} + {% block page-form-content %} +
+
+ +
+ +
+

{{ field.help_text }}

+
+ {% if field.errors %} +

{{ field.errors }}

+ {% endif %} + {% if field.help_text %} +

{{ field.help_text|safe }}

+ {% endif %} +
+ {% endblock %} + {% endwith %} + + {% endblock %} +
+
+
{% block page-form-actions %}{% endblock %}
+
+ +
+ +
+{% endblock %} diff --git a/aircox/templates/aircox/page_list.html b/aircox/templates/aircox/page_list.html index c2de74f..aed42dc 100644 --- a/aircox/templates/aircox/page_list.html +++ b/aircox/templates/aircox/page_list.html @@ -2,61 +2,60 @@ {% comment %}Display a list of Pages{% endcomment %} {% load i18n aircox %} -{% block before_list %} -{{ block.super }} - -{% if view.has_filters and object_list %} -
-
- {% block filters %} -
-
- -
-
-
-
- - - - -
-
-
-
-
-
- -
-
-
-
- {% for label, value in categories %} - - {% endfor %} -
-
-
-
- {% endblock %} +{% block secondary-nav %} +{% if not parent and categories %} + {% endif %} {% endblock %} + +{% block title %} +{% if parent %}{{ parent.title }} +{% else %}{{ block.super }} +{% endif %} +{% endblock %} + +{% block header %} +{% if page and not object %} + {% with page as object %} + {{ block.super }} + {% endwith %} +{% else %} + {{ block.super }} +{% endif %} +{% endblock %} + +{% block breadcrumbs %} +{% if parent and model.list_url_name %} + {% include "./widgets/breadcrumbs.html" with page=parent %} + {{ model|verbose_name:True }} +{% elif page and model.list_url_name %} + {{ page.title }} + {% if category %} + + {{ category.title }} + + {% endif %} +{% else %} + {{ model|verbose_name:True }} + {% if category %} + + {{ category.title }} + + {% endif %} +{% endif %} +{% endblock %} + +{% block content-container %}{% endblock %} diff --git a/aircox/templates/aircox/program_detail.html b/aircox/templates/aircox/program_detail.html index 267067b..8cae53e 100644 --- a/aircox/templates/aircox/program_detail.html +++ b/aircox/templates/aircox/program_detail.html @@ -1,67 +1,53 @@ {% extends "aircox/page_detail.html" %} {% comment %}Detail page of a show{% endcomment %} -{% load i18n %} +{% load i18n aircox %} -{% include "aircox/program_sidebar.html" %} - - -{% block header_nav %} -{% endblock %} - - -{% block content %} -{{ block.super }} -
-{% with has_headline=False %} -{% if articles %} -
-

{% translate "Articles" %}

- - {% for object in articles %} - {% include "aircox/widgets/page_item.html" %} +{% block content-container %} +{% with schedules=object.schedule_set.all %} +{% if schedules %} +
+ {% for schedule in schedules %} +
+
+ {{ schedule.get_frequency_display }} + {% with schedule.start|date:"H:i" as start %} + {% with schedule.end|date:"H:i" as end %} + + — + + {% endwith %} + {% endwith %} + + {% if schedule.is_rerun %} + {% with schedule.initial.date as date %} + + ({% translate "Rerun" %}) + + {% endwith %} + {% endif %} + +
+
{% endfor %} - -
- -
+ {% endif %} {% endwith %} -{% endblock %} - -{% block sidebar %} -
-

{% translate "Diffusions" %}

- {% for schedule in program.schedule_set.all %} - {{ schedule.get_frequency_display }} - {% with schedule.start|date:"H:i" as start %} - {% with schedule.end|date:"H:i" as end %} - - — - - {% endwith %} - {% endwith %} - - {% if schedule.initial %} - {% with schedule.initial.date as date %} - - ({% translate "Rerun" %}) - - {% endwith %} - {% endif %} - -
- {% endfor %} -
{{ block.super }} + +{% if episodes %} +
+

{% translate "Last Episodes" %}

+ {% include "./widgets/carousel.html" with objects=episodes url_name="episode-list" url_parent=object url_label=_("All episodes") %} +
+{% endif %} + + +{% if articles %} +
+

{% translate "Last Articles" %}

+ {% include "./widgets/carousel.html" with objects=articles url_name="article-list" url_parent=object url_label=_("All articles") %} +
+{% endif %} + {% endblock %} diff --git a/aircox/templates/aircox/program_form.html b/aircox/templates/aircox/program_form.html new file mode 100644 index 0000000..bcb0c00 --- /dev/null +++ b/aircox/templates/aircox/program_form.html @@ -0,0 +1,26 @@ +{% extends "./page_form.html" %} +{% load static i18n humanize honeypot aircox %} + + +{% block head_extra %} + {{ form.media }} +{% endblock %} + +{% block page-form-actions %} +{% if object and object.pk and request.user.is_superuser %} + + +{{ block.super }} +{% endif %} +{% endblock %} + +{% block main %} +{{ block.super }} + +{% if object and object.pk and request.user.is_superuser %} +{% include "./dashboard/widgets/group_users.html" %} +{% endif %} + +{% endblock %} diff --git a/aircox/templates/aircox/program_sidebar.html b/aircox/templates/aircox/program_sidebar.html deleted file mode 100644 index fe7ea6c..0000000 --- a/aircox/templates/aircox/program_sidebar.html +++ /dev/null @@ -1,6 +0,0 @@ - -{% block sidebar_title %} -{% with program.title as program %} -{% blocktranslate %}Recently on {{ program }}{% endblocktranslate %} -{% endwith %} -{% endblock %} diff --git a/aircox/templates/aircox/public.html b/aircox/templates/aircox/public.html new file mode 100644 index 0000000..e21e923 --- /dev/null +++ b/aircox/templates/aircox/public.html @@ -0,0 +1,18 @@ +{% extends "./base.html" %} + + +{% comment %} +Override is a trick here: it allows to change title at two different different +places inside the page: inside `` tag, and inside the page +content. +{% endcomment %} + +{% block head-title %} + {% block title %} + {% if page and page.title %}{{ page.title }}{% endif %} + {% endblock %} + {% if page and page.title %}—{% endif %} + {{ station.name }} +{% endblock %} + +{% block header %}{% if page %}{{ block.super }}{% endif %}{% endblock %} diff --git a/aircox/templates/aircox/timetable_list.html b/aircox/templates/aircox/timetable_list.html new file mode 100644 index 0000000..77c55b9 --- /dev/null +++ b/aircox/templates/aircox/timetable_list.html @@ -0,0 +1,28 @@ +{% extends "aircox/page_list.html" %} +{% comment %}List of diffusions as a timetable{% endcomment %} +{% load i18n aircox humanize %} + +{% block subtitle %}{{ date|date:"l d F Y" }}{% endblock %} + +{% block secondary-nav %} +<nav class="nav secondary"> + {% include "./widgets/dates_menu.html" with url_name=view.redirect_date_url %} +</nav> +{% endblock %} + + +{% block breadcrumbs %} +{{ block.super }} +<a href="{% url "timetable-list" date=date %}">{{ date|date:"l d F Y" }}</a> +{% endblock %} + + +{% block list-container %} +{% with list_class="grid" %} +{{ block.super }} +{% endwith %} +{% endblock %} + +{% block list %} +{% include "./widgets/logs.html" with object_list=object_list timetable=True %} +{% endblock %} diff --git a/aircox/templates/aircox/widgets/article.html b/aircox/templates/aircox/widgets/article.html new file mode 100644 index 0000000..2ee4809 --- /dev/null +++ b/aircox/templates/aircox/widgets/article.html @@ -0,0 +1,4 @@ +{% extends "./page.html" %} +{% load humanize %} + +{% block subtitle %}{{ object.pub_date.date }}{% endblock %} diff --git a/aircox/templates/aircox/widgets/autocomplete.html b/aircox/templates/aircox/widgets/autocomplete.html new file mode 100644 index 0000000..d4b8ffa --- /dev/null +++ b/aircox/templates/aircox/widgets/autocomplete.html @@ -0,0 +1,4 @@ +<a-autocomplete + url="{{url}}" + {% if ":name" not in widget.attrs %}name="{{ name|default:widget.name }}"{% endif %}{% if widget.value != None %} model-value="{{ widget.value|stringformat:'s' }}"{% endif %} + {% include "django/forms/widgets/attrs.html" %} {{ extra|default:"" }}/> diff --git a/aircox/templates/aircox/widgets/basepage_item.html b/aircox/templates/aircox/widgets/basepage_item.html index a9ee4d3..87fe3fb 100644 --- a/aircox/templates/aircox/widgets/basepage_item.html +++ b/aircox/templates/aircox/widgets/basepage_item.html @@ -11,63 +11,48 @@ Context variables: - is_thin (=False): if True, smaller cover and display less info {% endcomment %} -{% if render_card %} -<article class="card {% if is_primary %}is-primary{% endif %}"> - <header class="card-image"> - <a href="{{ object.get_absolute_url }}"> - <figure class="image is-4by3"> - <img src="{% thumbnail object.cover|default:station.default_cover 480x480 %}"> - </figure> - </a> +{% block outer %} +<article class="preview preview-item{% if is_primary %}is-primary{% endif %}{% block card_class %}{% endblock %}"> + {% block inner %} + <header class="headings" + style="background-image: url({{ object.cover.url }})"> + {% block headings %} + <div> + <span class="heading subtitle">{% block subtitle %}{% endblock %}</span> + </div> + {% endblock %} </header> - <div class="card-header"> - <h4 class="title"> - <a href="{{ object.get_absolute_url }}"> - {% block card_title %}{{ object.title }}{% endblock %} - </a> - </h4> - </div> -</article> + <div class=""> + <div> + <h2 class="heading title">{% block title %}{% endblock %}</h2> + </div> -{% else %} -<article class="media item {% block css %}{% endblock%}"> - {% if has_cover|default_if_none:True %} - <div class="media-left"> - {% if is_thin %} - <img src="{% thumbnail object.cover|default:station.default_cover 64x64 crop=scale %}" - class="cover is-tiny"> - {% else %} - <img src="{% thumbnail object.cover|default:station.default_cover 128x128 crop=scale %}" - class="cover is-small"> - {% endif %} - </div> - {% endif %} - <div class="media-content"> - <h5 class="title is-5 has-text-weight-normal"> - {% block title %} - {% if object.is_published %} - <a href="{{ object.get_absolute_url }}">{{ object.title }}</a> - {% else %} - {{ object.title }} + <summary class="heading-container"> + {% block content %} + {% if content and with_content %} + {% autoescape off %} + {{ content|striptags|truncatewords:64|linebreaks }} + {% endautoescape %} {% endif %} {% endblock %} - </h5> - <div class="subtitle is-6 has-text-weight-light"> - {% block subtitle %} - {% if object.category %}{{ object.category.title }}{% endif %} - {% endblock %} - </div> + </summary> - {% if has_headline|default_if_none:True %} - <div class="headline"> - {% block headline %}{{ object.headline }}{% endblock %} + <div class="actions"> + {% block actions %} + <a class="button float-right" href="{{ object.get_absolute_url|escape }}"> + <span class="icon"> + <i class="fas fa-external-link"></i> + </span> + <label>{% translate "More infos" %}</label> + </a> + {% endblock %} </div> - {% endif %} </div> + {% endblock %} - {% if not no_actions %} - {% block actions %}{% endblock %} + {% if with_container %} + </div> {% endif %} </article> -{% endif %} +{% endblock %} diff --git a/aircox/templates/aircox/widgets/breadcrumbs.html b/aircox/templates/aircox/widgets/breadcrumbs.html new file mode 100644 index 0000000..6684317 --- /dev/null +++ b/aircox/templates/aircox/widgets/breadcrumbs.html @@ -0,0 +1,15 @@ +{% load aircox %} + +<a href="{% url page.list_url_name %}"> + {{ page|verbose_name:True }} +</a> +{% if page.category and not no_cat %} +<a href="{% url page.list_url_name category_slug=page.category.slug %}"> + {{ page.category.title }} +</a> +{% endif %} +{% if not no_title %} +<a href="{{ page.get_absolute_url }}"> + {{ page.title|truncatechars:24 }} +</a> +{% endif %} diff --git a/aircox/templates/aircox/widgets/card.html b/aircox/templates/aircox/widgets/card.html new file mode 100644 index 0000000..b4f0b5a --- /dev/null +++ b/aircox/templates/aircox/widgets/card.html @@ -0,0 +1,23 @@ +{% extends "./preview.html" %} +{% load i18n %} + +{% block tag-class %}{{ block.super }} preview-card{% endblock %} + +{% block inner %} + <div class="card-content"> + {% if cover %} + {% if url %}<a href="{{ url }}">{% endif %} + <figure style="background-image: url({{ cover }});" class="preview-cover"> + <img src="{{ cover }}" class="hide"> + </figure> + {% if url %}</a>{% endif %} + {% endif %} + + <footer class="actions"> + {% block actions %}{{ block.super }}{% endblock %} + </footer> + </div> + + {% block headings-container %}{{ block.super }}{% endblock %} + +{% endblock %} diff --git a/aircox/templates/aircox/widgets/carousel.html b/aircox/templates/aircox/widgets/carousel.html new file mode 100644 index 0000000..a25e18a --- /dev/null +++ b/aircox/templates/aircox/widgets/carousel.html @@ -0,0 +1,28 @@ +{% load aircox %} +{% comment %} +Context: +- objects: list of objects to display +- url_name: url name to show the full list +- url_parent: parent page for the full list +- url_label: label of url button +{% endcomment %} + +<a-carousel> + {% for object in objects %} + {% page_widget "card" object %} + {% endfor %} +</a-carousel> + +{% if url_name %} +<nav class="nav-urls"> + {% if url_parent %} + <a href="{% url url_name parent_slug=url_parent.slug %}"> + {% elif url_category %} + <a href="{% url url_name category_slug=url_category.slug %}"> + {% else %} + <a href="{% url url_name %}"> + {% endif %} + {{ url_label|default:_("Show all") }} + </a> +</nav> +{% endif %} diff --git a/aircox/templates/aircox/widgets/comment.html b/aircox/templates/aircox/widgets/comment.html new file mode 100644 index 0000000..3289b77 --- /dev/null +++ b/aircox/templates/aircox/widgets/comment.html @@ -0,0 +1,55 @@ +{% extends "./page.html" %} +{% load i18n humanize aircox %} + +{% block tag-class %}{{ block.super }} comment{% endblock %} + +{% block outer %} +{% with url=object.get_absolute_url %} +{% if with_title %} + {{ block.super }} + {{ block.super }} +{% else %} + {{ block.super }} + {{ block.super }} +{% endif %} +{% endwith %} +{% endblock %} + + +{% block title %} + {{ object.nickname }} — {{ object.date }} +{% endblock %} + +{% block subtitle %} +{% if with_title %} + {{ object.parent.title }} +{% endif %} +{% endblock %} + + +{% block content %}{{ object.content }}{% endblock %} + +{% block actions %} +{{ block.super }} + +{% if admin %} +{% if user.is_staff %} +<a href="{% url "admin:aircox_comment_change" object.pk %}" class="button" + title="{% trans "Edit comment" %}" + aria-label="{% trans "Edit comment" %}"> + <span class="fa fa-edit"></span> +</a> +{% endif %} +<a-action-button class="button is-danger" + title="{% trans "Delete comment" %}" + aria-label="{% trans "Delete comment" %}" + url="{% url "api:comment-detail" object.pk %}" + icon="fa fa-trash-alt" + method="delete" + confirm="{% translate "Delete comment?" %}" + @done="deleteElements('#{{ object|object_id }}')" + /> + +{# <a href="mailto:{{ object.email }}">{{ object.nickname }}</a> #} +{% endif %} +{% endblock %} diff --git a/aircox/templates/aircox/widgets/dates_menu.html b/aircox/templates/aircox/widgets/dates_menu.html index d5400eb..18eeec3 100644 --- a/aircox/templates/aircox/widgets/dates_menu.html +++ b/aircox/templates/aircox/widgets/dates_menu.html @@ -11,36 +11,33 @@ An empty date results to a title or a separator {% endcomment %} {% load i18n %} -<div class="media" role="menu" - aria-label="{% translate "pick a date" %}"> - <div class="media-content"> - <div class="tabs is-toggle"> - <ul> - {% for day in dates %} - <li class="{% if day == date %}is-active{% endif %}"> - <a href="{% url url_name date=day %}"> - {{ day|date:"D. d" }} - </a> - </li> - {% endfor %} - </ul> - </div> - </div> +<a-switch class="button burger" + el=".nav-dates" icon="far fa-calendar" group="nav" + aria-label="{% translate "Dates" %}"> +</a-switch> - <div class="media-right"> - <form action="{% url url_name %}" method="GET" class="navbar-body" - aria-label="{% translate "Jump to date" %}"> - <div class="field has-addons"> - <div class="control has-icons-left"> - <span class="icon is-small is-left"><span class="far fa-calendar"></span></span> - <input type="{{ date_input|default:"date" }}" class="input date" - name="date" value="{{ date|date:"Y-m-d" }}"> - </div> - <div class="control"> - {% comment %}Translators: form button to select a date{% endcomment %} - <button class="button is-primary">{% translate "Go" %}</button> +<div class="nav-menu nav-dates"> + {% for day in dates %} + <a href="{% url url_name date=day %}" class="nav-item {% if day == date %}active{% endif %}"> + {{ day|date:"l d" }} + </a> + {% endfor %} + + <a-dropdown class="nav-item align-right flex-grow-0 dropdown is-right" + content-class="dropdown-menu" + button-tag="span" button-class="dropdown-trigger" + button-icon-open="fa-solid fa-plus" button-icon-close="fa-solid fa-minus"> + <template #default> + <div class="dropdown-content"> + <div class="dropdown-item"> + <h4>{% translate "Pick a date" %}</h4> + <v-calendar mode="date" borderless + :initial-page="{month: {{date.month}}, year: {{date.year}}}" + @dayclick="(event) => window.aircox.pickDate({% url url_name %}, event)" + color="yellow" + /> </div> </div> - </form> - </div> + </template> + </a-dropdown> </div> diff --git a/aircox/templates/aircox/widgets/diffusion_list.html b/aircox/templates/aircox/widgets/diffusion_list.html index 5e44b7a..38f38b4 100644 --- a/aircox/templates/aircox/widgets/diffusion_list.html +++ b/aircox/templates/aircox/widgets/diffusion_list.html @@ -3,19 +3,4 @@ Context: - object_list: object list - date: date for list {% endcomment %} -<table id="timetable{% if date %}-{{ date|date:"Y-m-d" }}{% endif %}" class="timetable"> - {% for diffusion in object_list %} - <tr class="{% if diffusion.is_now %}has-background-primary{% endif %}"> - <td class="pr-2 pb-2"> - <time datetime="{{ diffusion.start|date:"c" }}"> - {{ diffusion.start|date:"H:i" }} - {{ diffusion.end|date:"H:i" }} - </time> - </td> - <td class="pb-2"> - {% with diffusion.episode as object %} - {% include "aircox/widgets/episode_item.html" %} - {% endwith %} - </td> - </tr> - {% endfor %} -</table> +{% load aircox %} diff --git a/aircox/templates/aircox/widgets/diffusion_tags.html b/aircox/templates/aircox/widgets/diffusion_tags.html new file mode 100644 index 0000000..da34efe --- /dev/null +++ b/aircox/templates/aircox/widgets/diffusion_tags.html @@ -0,0 +1,26 @@ +{% comment %} +Context: +- object: diffusion +{% endcomment %} +{% load i18n %} +{% if object.type == object.TYPE_ON_AIR %} +<span class="tag is-info"> + <span class="icon is-small"> + {% if object.is_live %} + <i class="fa fa-microphone" + title="{% translate "Live diffusion" %}"></i> + {% else %} + <i class="fa fa-music" + title="{% translate "Differed diffusion" %}"></i> + {% endif %} + </span> +   + {{ object.get_type_display }} +</span> +{% elif object.type == object.TYPE_CANCEL %} +<span class="tag is-danger"> + {{ object.get_type_display }}</span> +{% elif object.type == object.TYPE_UNCONFIRMED %} +<span class="tag is-warning"> + {{ object.get_type_display }}</span> +{% endif %} diff --git a/aircox/templates/aircox/widgets/episode.html b/aircox/templates/aircox/widgets/episode.html new file mode 100644 index 0000000..d5df54e --- /dev/null +++ b/aircox/templates/aircox/widgets/episode.html @@ -0,0 +1,71 @@ +{% extends "./page.html" %} +{% load i18n humanize aircox %} + +{% block outer %} +{% with diffusion.is_now as is_active %} + {{ block.super }} +{% endwith %} +{% endblock %} + +{% block subtitle %} +{% if diffusion %} + {% if timetable %} + {{ diffusion.start|date:"H:i" }} + — + {{ diffusion.end|date:"H:i" }} + {% else %} + {{ diffusion.start|naturalday }}, + {{ diffusion.start|date:"H:i" }} + {% endif %} +{% else %} + {{ block.super }} +{% endif %} +{% endblock %} + + +{% block actions-container %} +{% if admin and diffusion %} +<div class="flex-row"> + <div class="flex-grow-1"> + {% if diffusion.type == diffusion.TYPE_ON_AIR %} + <span class="tag is-info"> + <span class="icon is-small"> + {% if diffusion.is_live %} + <i class="fa fa-microphone" + title="{% translate "Live diffusion" %}"></i> + {% else %} + <i class="fa fa-music" + title="{% translate "Differed diffusion" %}"></i> + {% endif %} + </span> +   + {{ diffusion.get_type_display }} + </span> + {% elif diffusion.type == diffusion.TYPE_CANCEL %} + <span class="tag is-danger"> + {{ diffusion.get_type_display }}</span> + {% elif diffusion.type == diffusion.TYPE_UNCONFIRMED %} + <span class="tag is-warning"> + {{ diffusion.get_type_display }}</span> + {% endif %} + </div> + {{ block.super }} +</div> +{% else %} +{{ block.super }} +{% endif %} +{% endblock %} + +{% block actions %} +{{ block.super }} +{% if object.sound_set.count %} +<button class="button action" @click="player.playButtonClick($event)" + data-sounds="{{ object.podcasts|json }}"> + <span class="icon is-small"> + <span class="fas fa-play"></span> + </span> + <label>{% translate "Listen" %}</label> +</button> +{% endif %} + +{% endblock %} diff --git a/aircox/templates/aircox/widgets/episode_item.html b/aircox/templates/aircox/widgets/episode_item.html index 2355018..57648b4 100644 --- a/aircox/templates/aircox/widgets/episode_item.html +++ b/aircox/templates/aircox/widgets/episode_item.html @@ -1,58 +1,36 @@ -{% extends "aircox/widgets/page_item.html" %} -{% comment %} -List item for an episode. - -Context variables: -- object: episode -- diffusion: episode's diffusion -- hide_schedule: if True, do not display start time -{% endcomment %} - -{% load i18n easy_thumbnails_tags aircox %} +{% extends "./basepage_item.html" %} +{% load i18n humanize %} {% block title %} {% if not object.is_published and object.program.is_published %} -<a href="{{ object.program.get_absolute_url }}"> - {{ object.program.title }} - {% if diffusion %} - — - {{ diffusion.start|date:"d F" }} - {% endif %} -</a> + <a href="{{ object.program.get_absolute_url }}"> + {{ object.program.title }} + </a> {% else %} -{{ block.super }} + {{ block.super }} {% endif %} {% endblock %} +{% block class %} +{% if object.is_now %}is-active{% endif %} +{% endblock %} + {% block subtitle %} -{{ block.super }} - {% if diffusion %} - {% if not hide_schedule %} - {% if object.category %}—{% endif %} - <time datetime="{{ diffusion.start|date:"c" }}" title="{{ diffusion.start }}"> - {{ diffusion.start|date:"d M, H:i" }} - </time> - {% endif %} + {{ diffusion.start|naturalday }}, + {{ diffusion.start|date:"g:i" }} +{% else %} + {{ block.super }} +{% endif %} +{% endblock %} - {% if diffusion.initial %} - {% with diffusion.initial.date as date %} - <span title="{% blocktranslate %}Rerun of {{ date }}{% endblocktranslate %}"> - {% translate "(rerun)" %} - </span> + +{% block content %} +{% if not object.content %} + {% with object.parent.content as content %} + {{ block.super }} {% endwith %} - {% endif %} -{% endif %} -{% endblock %} - - -{% block actions %} -{% if object.sound_set.public.count %} -<button class="button" @click="player.playButtonClick($event)" - data-sounds="{{ object.podcasts|json }}"> - <span class="icon is-small"> - <span class="fas fa-play"></span> - </span> -</button> +{% else %} + {{ block.super }} {% endif %} {% endblock %} diff --git a/aircox/templates/aircox/widgets/item.html b/aircox/templates/aircox/widgets/item.html new file mode 100644 index 0000000..8522b18 --- /dev/null +++ b/aircox/templates/aircox/widgets/item.html @@ -0,0 +1,34 @@ +{% extends "./preview.html" %} +{% load i18n aircox %} + +{% block tag-class %}{{ block.super }} list-item is-fullwidth{% endblock %} + +{% block headings %} +<a href="{{ url|escape }}" class="heading title {% block title-class %}{% endblock %}"> + {% block title %}{{ title|default:"" }}{% endblock %} +</a> +<span class="heading subtitle {% block subtitle-class %}{% endblock %}"> + {% block subtitle %}{{ subtitle|default:"" }}{% endblock %} +</span> +{% endblock %} + +{% block inner %} +{% block headings-container %}{{ block.super }}{% endblock %} +{% block content-container %} +<div class="media"> + {% if object.cover and not no_cover %} + <a href="{{ object.get_absolute_url }}" + class="media-left preview-cover small" + style="background-image: url({{ object.cover.url }})"> + </a> + {% endif %} + <div class="media-content flex-column"> + <section class="content flex-grow-1"> + {% block content %}{{ block.super }}{% endblock %} + </section> + {% block actions-container %}{{ block.super }}{% endblock %} + </div> +</div> +{% endblock %} + +{% endblock %} diff --git a/aircox/templates/aircox/widgets/list_pagination.html b/aircox/templates/aircox/widgets/list_pagination.html new file mode 100644 index 0000000..355d934 --- /dev/null +++ b/aircox/templates/aircox/widgets/list_pagination.html @@ -0,0 +1,39 @@ +{% comment %} +Context: +- is_paginated: if True, page is paginated +- page_obj: page object from list view; +{% endcomment %} +{% load i18n aircox %} + +{% if is_paginated %} +<hr/> +{% update_query request.GET.copy page=None as GET %} +{% with GET.urlencode as GET %} +<nav class="nav-urls is-centered" role="pagination" aria-label="{% translate "pagination" %}"> + <ul class="urls"> + {% if page_obj.has_previous %} + {% comment %}Translators: Bottom of the list, "previous page"{% endcomment %} + <a href="?{{ GET }}&page={{ page_obj.previous_page_number }}" class="left" + title="{% translate "Previous" %}" + aria-label="{% translate "Previous" %}"> + <span class="icon"><i class="fa fa-chevron-left"></i></span> + </a> + {% endif %} + + <span> + {{ page_obj.number }} / {{ page_obj.paginator.num_pages }} + </span> + + {% if page_obj.has_next %} + {% comment %}Translators: Bottom of the list, "Nextpage"{% endcomment %} + <a href="?{{ GET }}&page={{ page_obj.next_page_number }}" class="right" + title="{% translate "Next" %}" + aria-label="{% translate "Next" %}"> + <span class="icon"><i class="fa fa-chevron-right"></i></span> + </a> + {% endif %} + </ul> + +</nav> +{% endwith %} +{% endif %} diff --git a/aircox/templates/aircox/widgets/log.html b/aircox/templates/aircox/widgets/log.html new file mode 100644 index 0000000..baffa1b --- /dev/null +++ b/aircox/templates/aircox/widgets/log.html @@ -0,0 +1,23 @@ +{% load i18n aircox %} +{% comment %} +List item for a log, either for a logged track or diffusion (as diffusion). + +Context objects: +- object: object to render +- hide_schedule: if true, hide the schedule + +In case of modification, you might want to check on `assets/vue/player.vue` +for design review. +{% endcomment %} + +{% block outer %} +{% if object|is_diffusion %} + {% page_widget widget object.episode diffusion=object timetable=timetable|default:False %} +{% elif object|is_log %} + {% include "./track_item.html" with object=object.track log=object timetable=timetable|default:False %} +{% else %} + {% for obj in object %} + {% include "./track_item.html" with object=obj.track log=obj timetable=timetable|default:False %} + {% endfor %} +{% endif %} +{% endblock %} diff --git a/aircox/templates/aircox/widgets/log_item.html b/aircox/templates/aircox/widgets/log_item.html deleted file mode 100644 index f0e8ba0..0000000 --- a/aircox/templates/aircox/widgets/log_item.html +++ /dev/null @@ -1,22 +0,0 @@ -{% load i18n aircox %} -{% comment %} -List item for a log, either for a logged track or diffusion (as diffusion). - -Context objects: -- object: object to render -- hide_schedule: if true, hide the schedule - -In case of modification, you might want to check on `assets/vue/player.vue` -for design review. - -{% endcomment %} - -{% if object|is_diffusion %} - {% with object as diffusion %} - {% include "aircox/widgets/diffusion_item.html" %} - {% endwith %} -{% else %} - {% with object.track as object %} - {% include "aircox/widgets/track_item.html" %} - {% endwith %} -{% endif %} diff --git a/aircox/templates/aircox/widgets/log_list.html b/aircox/templates/aircox/widgets/log_list.html deleted file mode 100644 index e3e5b81..0000000 --- a/aircox/templates/aircox/widgets/log_list.html +++ /dev/null @@ -1,30 +0,0 @@ -{% comment %} -Render list of logs (as widget). - -Context: -- object_list: list of logs to display -- is_thin: if True, hide some information in order to fit in a thin container -{% endcomment %} -{% load aircox %} - -{% with True as hide_schedule %} -<table class="table is-striped is-hoverable is-fullwidth" role="list"> - {% for object in object_list %} - <tr {% if object|is_diffusion and object.is_now %}class="is-selected"{% endif %}> - <td> - {% if object|is_diffusion %} - <time datetime="{{ object.start }}" title="{{ object.start }}"> - {{ object.start|date:"H:i" }} - {% if not is_thin %} - {{ object.end|date:"H:i" }}{% endif %} - </time> - {% else %} - <time datetime="{{ object.date }}" title="{{ object.date }}"> - {{ object.date|date:"H:i" }} - </time> - {% endif %} - </td> - <td>{% include "aircox/widgets/log_item.html" %}</td> - </tr> - {% endfor %} -</table> -{% endwith %} diff --git a/aircox/templates/aircox/widgets/logs.html b/aircox/templates/aircox/widgets/logs.html new file mode 100644 index 0000000..632bf64 --- /dev/null +++ b/aircox/templates/aircox/widgets/logs.html @@ -0,0 +1,35 @@ +{% comment %} +Context: +- object_list: list of logs +- timetable: defaults to False +- widget: defaults to "item" +{% endcomment %} +{% load aircox %} + +{% with timetable|default:False as timetable %} +{% with widget|default:"item" as widget %} + {% for object in object_list %} + {% if object.episode %} + {% page_widget widget object.episode diffusion=object timetable=True %} + {% elif object|is_log %} + {% include "./track_item.html" with object=object.track log=object timetable=True %} + {% else %} + <div class="preview list-item logs"> + <header class="headings"> + <span class="heading title"> + <span class="icon pr-2"> + <i class="fas fa-music"></i> + </span> + {{ station.music_stream_title }} + </span> + </header> + <div class="media d-block content"> + {% for obj in object %} + {% include "./track_item.html" with object=obj.track log=obj timetable=True %} + {% endfor %} + </div> + </div> + {% endif %} + {% endfor %} +{% endwith %} +{% endwith %} diff --git a/aircox/templates/aircox/widgets/nav.html b/aircox/templates/aircox/widgets/nav.html new file mode 100644 index 0000000..a6c7ae5 --- /dev/null +++ b/aircox/templates/aircox/widgets/nav.html @@ -0,0 +1,50 @@ +{% load aircox i18n %} +<div class="dropdown is-hoverable is-right"> + <div class="dropdown-trigger"> + <button class="button square" aria-haspopup="true" aria-controls="dropdown-menu" type="button"> + <span class="icon"> + <i class="fa fa-user" aria-hidden="true"></i> + </span> + </button> + </div> + <div class="dropdown-menu" id="dropdown-menu" role="menu" style="z-index:200"> + <div class="dropdown-content"> + {% block user-menu %} + <a class="dropdown-item" href="{% url "dashboard" %}" data-force-reload="1"> + {% translate "Dashboard" %} + </a> + {% if user|has_perm:"list_user" %} + <a class="dropdown-item" href="{% url "user-list" %}" data-force-reload="1"> + {% translate "Users" %} + </a> + {% endif %} + {% endblock %} + + + {% comment %} + {% block edit-menu %} + {% if request.user|has_perm:"aircox.create_program" %} + <a class="dropdown-item" href="{% url "program-create" %}"> + {% translate "Create Program" %} + </a> + {% endif %} + {% endblock %} + {% endcomment %} + {% if user.is_superuser %} + <hr class="dropdown-divider" /> + {% block admin-menu %} + <a class="dropdown-item" href="{% url "admin:index" %}" target="new"> + {% translate "Admin" %} + </a> + <a class="dropdown-item" href="{% url "dashboard-statistics" %}"> + {% translate "Statistics" %} + </a> + {% endblock %} + <hr class="dropdown-divider" /> + {% endif %} + <a class="dropdown-item" href="{% url "logout" %}" data-force-reload="1"> + {% translate "Disconnect" %} + </a> + </div> + </div> +</div> diff --git a/aircox/templates/aircox/widgets/page.html b/aircox/templates/aircox/widgets/page.html new file mode 100644 index 0000000..02217ec --- /dev/null +++ b/aircox/templates/aircox/widgets/page.html @@ -0,0 +1,38 @@ +{% extends widget_template %} +{% load i18n aircox %} + + +{% block outer %} +{% with cover|default:object.cover_url as cover %} +{% with url|default:object.get_absolute_url as url %} +{{ block.super }} +{% endwith %} +{% endwith %} +{% endblock %} + + +{% block title %} +{% if title %} + {{ block.super }} +{% elif object %} + {{ object.display_title }} +{% endif %} +{% endblock %} + + +{% block content %} +{% if not content and object %} + {% with object.display_headline as content %} + {{ block.super }} + {% endwith %} +{% else %} + {{ block.super }} +{% endif %} +{% endblock %} + +{% block actions %} +{% if url and "card" not in widget_template %} +<a href="{{ url }}">{% translate "Show" %}</a> +{% endif %} +{{ block.super }} +{% endblock %} diff --git a/aircox/templates/aircox/widgets/page_actions.html b/aircox/templates/aircox/widgets/page_actions.html new file mode 100644 index 0000000..6b5a22e --- /dev/null +++ b/aircox/templates/aircox/widgets/page_actions.html @@ -0,0 +1,33 @@ +{% load aircox i18n %} + +{% block user-actions-container %} +{% if user.is_authenticated %} +{{ object.get_status_display }} + +{% if object.pub_date %} + ({{ object.pub_date|date:"d/m/Y H:i" }}) +{% endif %} +{% endif %} + +{% if user.is_authenticated and can_edit %} +{% with request.resolver_match.view_name as view_name %} +   + {% if "-edit" in view_name %} + <a href="{% url view_name|detail_view page.slug %}" target="_self" title="{% translate 'View' %} {{ page }}"> + <span class="icon"> + <i class="fa-regular fa-eye"></i> + </span> + <span>{% translate 'View' %} </span> + </a> + {% else %} + <a href="{% url view_name|edit_view page.pk %}" target="_self" title="{% translate 'Edit' %} {{ page }}"> + <span class="icon"> + <i class="fa-solid fa-pencil"></i> + </span> + <span>{% translate 'Edit' %} </span> + </a> + {% endif %} +{% endwith %} +{% endif %} + +{% endblock %} diff --git a/aircox/templates/aircox/widgets/page_card.html b/aircox/templates/aircox/widgets/page_card.html new file mode 100644 index 0000000..f2d2760 --- /dev/null +++ b/aircox/templates/aircox/widgets/page_card.html @@ -0,0 +1,13 @@ +{% extends widget|default:"./card.html" %} + +{% block outer %} +{% if object %} + {% with content=object.get_display_excerpt() %} + {% with title=object.get_display_title() %} + {{ block.super }} + {% endwith %} + {% endwith %} +{% else %} + {{ block.super }} +{% endif %} +{% endblock %} diff --git a/aircox/templates/aircox/widgets/page_item.html b/aircox/templates/aircox/widgets/page_item.html index f28b44a..dff083f 100644 --- a/aircox/templates/aircox/widgets/page_item.html +++ b/aircox/templates/aircox/widgets/page_item.html @@ -3,3 +3,11 @@ {% block card_title %} {% block title %}{{ block.super }}{% endblock %} {% endblock %} + +{% block card_subtitle %} +{% block subtitle %}{{ block.super }}{% endblock %} +{% endblock %} + +{% block card_class %} +{% block class %}{{ block.super }}{% endblock %} +{% endblock %} diff --git a/aircox/templates/aircox/widgets/page_list.html b/aircox/templates/aircox/widgets/page_list.html index fc63445..5fdc0bc 100644 --- a/aircox/templates/aircox/widgets/page_list.html +++ b/aircox/templates/aircox/widgets/page_list.html @@ -5,10 +5,10 @@ Context: - object_list: object list - list_url: url to complete list page {% endcomment %} -{% load i18n %} +{% load i18n aircox %} {% for object in object_list %} -{% include object.item_template_name %} +{% page_widget "item" object %} {% endfor %} {% if list_url %} diff --git a/aircox/templates/aircox/widgets/player.html b/aircox/templates/aircox/widgets/player.html index 20e1b1f..3f24850 100644 --- a/aircox/templates/aircox/widgets/player.html +++ b/aircox/templates/aircox/widgets/player.html @@ -5,7 +5,7 @@ The audio player <br> -<div class="box is-fullwidth is-fixed-bottom is-paddingless player" +<div class="is-fullwidth is-fixed-bottom is-paddingless player-container" role="{% translate "player" %}" aria-description="{% translate "Audio player used to listen to the radio and podcasts" %}"> <noscript> @@ -20,26 +20,32 @@ The audio player <a-player ref="player" :live-args="{% player_live_attr %}" + :playlists="{pin: ['{% translate "Bookmarks" %}', 'fa fa-star'], queue: ['{% translate 'Playlist' %}', 'fa fa-list']}" button-title="{% translate "Play or pause audio" %}"> <template v-slot:content="{ loaded, live, current }"> - <h4 v-if="loaded" class="title is-4"> - [[ loaded.name ]] + <h4 v-if="loaded" class="title"> + <a v-if="current?.data?.page_url" :href="current.data.page_url"> + [[ loaded.name ]] + </a> + <template v-else>[[ loaded.name ]]</template> </h4> <h4 v-else-if="current && current.data.type == 'track'" - class="title is-4" aria-description="{% translate "Track currently on air" %}"> - <span class="has-text-info is-size-3">♬</span> + class="title" aria-description="{% translate "Track currently on air" %}"> + <span class="icon secondary-color mr-3"> + <i class="fas fa-music"></i> + </span> <span>[[ current.data.title ]]</span> <span class="has-text-grey-dark has-text-weight-light"> — [[ current.data.artist ]] <i v-if="current.data.info">([[ current.data.info ]])</i> </span> </h4> - <div v-else-if="live && current && current.data.type == 'diffusion'"> - <h4 class="title is-4" aria-description="{% translate "Diffusion currently on air" %}"> - <a :href="current.data.url">[[ current.data.title ]]</a> + <h4 v-else-if="live && current && current.data.type == 'diffusion'" + class="title" + aria-description="{% translate "Diffusion currently on air" %}"> + <a :href="current.data.url" v-if="current.data.url">[[ current.data.title ]]</a> + <template v-else>[[ current.data.title ]]</template> </h4> - <div class="">[[ current.data.info ]]</div> - </div> <h4 v-else class="title is-4" aria-description="{% translate "Currently playing" %}"> {{ request.station.name }} </h4> diff --git a/aircox/templates/aircox/widgets/preview.html b/aircox/templates/aircox/widgets/preview.html new file mode 100644 index 0000000..55533ef --- /dev/null +++ b/aircox/templates/aircox/widgets/preview.html @@ -0,0 +1,70 @@ +{% load i18n %} +{% comment %} +Content related context: +- object: object to display +- cover: cover +- title: title +- subtitle: subtitle +- content: content to display + +Components: +- no_cover: don't show cover +- no_content: don't show content + +Styling related context: +- is_active: add "active" css class +- is_small: add "small" css class +- is_tiny: add "tiny" css class +- tag +- tag_class: css class to set to main tag +- tag_extra: extra tag attributes + +{% endcomment %} +{% load aircox %} + +{% block outer %} +<{{ tag|default:"article" }} id="{{ object|object_id }}" class="preview {% if not cover %}no-cover {% endif %}{% if is_active %}active {% endif %}{% if is_tiny %}tiny{% elif is_small %}small{% endif %}{% block tag-class %}{{ tag_class|default:"" }} {% endblock %}" {% block tag-extra %}{% endblock %}> +{% block inner %} + {% block headings-container %} + <header class="headings{% block headings-class %}{% endblock %}"{% block headings-tag-extra %}{% endblock %}> + {% block headings %} + {% block title-container %} + <a href="{{ url|escape }}" class="heading title {% block title-class %}{% endblock %}"{% if title %} title="{{ title|escape }}"{% endif %}> + {% block title %}{{ title|default:"" }}{% endblock %} + </a> + {% endblock %} + {% block subtitle-container %} + <span class="heading subtitle {% block subtitle-class %}{% endblock %}"> + {% block subtitle %}{{ subtitle|default:"" }}{% endblock %} + </span> + {% endblock %} + {% endblock %} + </header> + {% endblock %} + + {% block content-container %} + <section class="content headings-container"> + {% block content %} + {% if content and not no_content %} + {% autoescape off %} + {{ content|striptags|linebreaks }} + {% endautoescape %} + {% endif %} + {% endblock %} + </section> + {% endblock %} + + {% block actions-container %} + {% spaceless %} + <div class="actions"> + {% block actions %} + {% if admin and object.edit_url_name %} + <a href="{% url object.edit_url_name pk=object.pk %}">{% translate "Edit" %}</a> + {% endif %} + {% endblock %} + </div> + {% endspaceless %} + {% endblock %} +{% endblock %} +</{{ tag|default:"article" }}> +{% endblock %} diff --git a/aircox/templates/aircox/widgets/track_item.html b/aircox/templates/aircox/widgets/track_item.html index c94b8fb..f6b0429 100644 --- a/aircox/templates/aircox/widgets/track_item.html +++ b/aircox/templates/aircox/widgets/track_item.html @@ -5,9 +5,20 @@ Context: - object: track to render {% endcomment %} -<span class="has-text-info is-size-5">♬</span> -<span>{{ object.title }}</span> -<span class="has-text-grey-dark has-text-weight-light"> -— {{ object.artist }} -{% if object.info %}(<i>{{ object.info }}</i>){% endif %} +<span class="track"> + <span class="icon secondary-color"> + <i class="fas fa-music"></i> + </span> + <label> + {% if log %} + <span>{{ log.date|date:"H:i" }} — </span> + {% endif %} + <span class="has-text-weight-boldk">{{ object.title }}</span> + {% if object.artist and object.artist != object.title %} + <span> + — {{ object.artist }} + {% if object.info %}(<i>{{ object.info }}</i>){% endif %} + </span> + {% endif %} + </label> </span> diff --git a/aircox/templates/aircox/widgets/wide.html b/aircox/templates/aircox/widgets/wide.html new file mode 100644 index 0000000..1b56f43 --- /dev/null +++ b/aircox/templates/aircox/widgets/wide.html @@ -0,0 +1,43 @@ +{% extends "./preview.html" %} +{% load i18n aircox %} + +{% block tag-class %}{{ block.super }} list-item wide is-fullwidth{% endblock %} + +{% block headings %} +<a href="{{ url|escape }}" class="heading title {% block title-class %}{% endblock %}"> + {% block title %}{{ title|default:"" }}{% endblock %} +</a> +<span class="heading subtitle {% block subtitle-class %}{% endblock %}"> + {% block subtitle %}{{ subtitle|default:"" }}{% endblock %} +</span> +{% endblock %} + +{% block inner %} +{% block content-container %} +<div class="media"> + {% if object.cover %} + <a href="{{ object.get_absolute_url }}" + class="media-left preview-cover" + style="background-image: url({{ object.cover.url }})"> + </a> + {% endif %} + <div class="media-content"> + {% block headings-container %}{{ block.super }}{% endblock %} + + <section class="content"> + {% block content %} + {% if content and with_content %} + {% autoescape off %} + {{ content|striptags|linebreaks }} + {% endautoescape %} + {% endif %} + {% endblock %} + </section> + + {% block actions-container %} + {{ block.super }} + {% endblock %} + </div> +{% endblock %} + +{% endblock %} diff --git a/aircox/templates/registration/login.html b/aircox/templates/registration/login.html new file mode 100644 index 0000000..ef4a6a0 --- /dev/null +++ b/aircox/templates/registration/login.html @@ -0,0 +1,18 @@ +{% extends "aircox/base.html" %} +{% load i18n aircox %} + +{% block content-container %} +<div class="container content page-content"> +<h2>{% trans "Log in" %}</h2> +<br/> +<form method="post" action="{% url 'login' %}"> + {% csrf_token %} + <table> + {{ form.as_table }} + </table> + <br/> + <button class="button" type="submit">{% trans "Log in" %}</button> + <input type="hidden" name="next" value="{{ next }}"> +</form> +</div> +{% endblock %} diff --git a/aircox/templatetags/aircox.py b/aircox/templatetags/aircox.py index 285d451..ac9dc57 100644 --- a/aircox/templatetags/aircox.py +++ b/aircox/templatetags/aircox.py @@ -1,16 +1,62 @@ import json import random -from django import template +from django import template, forms +from django.db import models from django.contrib.admin.templatetags.admin_urls import admin_urlname +from django.template.loader import render_to_string from django.urls import reverse from aircox.models import Diffusion, Log + random.seed() register = template.Library() +@register.simple_tag(name="form_field") +def form_field(field, name=None, value=None, **kwargs): + name = name or field.name + return field.widget.render(name=name, value=value, **kwargs) + + +@register.filter(name="admin_url") +def admin_url(obj, action): + meta = obj._meta + return reverse(f"admin:{meta.app_label}_{meta.model_name}_{action}", args=[obj.id]) + + +@register.filter(name="model_label") +def model_label(obj): + if isinstance(obj, models.Model): + obj = type(obj) + return obj._meta.label_lower.replace(".", "-") + + +@register.filter(name="object_id") +def object_id(obj): + return f"{model_label(obj)}-{obj.pk}" + + +@register.simple_tag(name="page_widget", takes_context=True) +def do_page_widget(context, widget, object, dir="aircox/widgets", **ctx): + """Render widget for the provided page and context.""" + ctx["request"] = context["request"] + ctx["object"] = object + ctx["widget"] = widget + if object.pk and not ctx.get("tag_id"): + model = type(object)._meta.model_name + ctx["tag_id"] = f"{widget}_{model}_{object.pk}" + ctx["widget_template"] = f"{dir}/{widget}.html" + return render_to_string(object.get_template_name(widget), ctx) + + +@register.filter(name="page_template") +def do_page_template(self, page, component): + """For a provided page object and component name, return template name.""" + return page.get_template(component) + + @register.filter(name="admin_url") def do_admin_url(obj, arg, pass_id=True): """Reverse admin url for object.""" @@ -29,11 +75,18 @@ def do_get_tracks(obj): return obj.track_set.all() -@register.simple_tag(name="has_perm", takes_context=True) -def do_has_perm(context, obj, perm, user=None): +@register.filter(name="has_perm") +def do_has_perm(user, perm): + """Return True if user has permission.""" + return user.has_perm(perm) + + +@register.simple_tag(name="has_obj_perm", takes_context=True) +def do_has_obj_perm(context, obj, perm, user=None): """Return True if ``user.has_perm('[APP].[perm]_[MODEL]')``""" - if user is None: - user = context["request"].user + user = user or context["request"].user + if not obj: + return False return user.has_perm("{}.{}_{}".format(obj._meta.app_label, perm, obj._meta.model_name)) @@ -43,6 +96,12 @@ def do_is_diffusion(obj): return isinstance(obj, Diffusion) +@register.filter(name="is_log") +def do_is_log(obj): + """Return True if object is a Diffusion.""" + return isinstance(obj, Log) + + @register.filter(name="json") def do_json(obj, fields=""): """Return object as json.""" @@ -66,13 +125,25 @@ def do_player_live_attr(context): @register.simple_tag(name="nav_items", takes_context=True) def do_nav_items(context, menu, **kwargs): """Render navigation items for the provided menu name.""" + if not getattr(context["request"], "station"): + return [] station, request = context["station"], context["request"] return [(item, item.render(request, **kwargs)) for item in station.navitem_set.filter(menu=menu)] +@register.filter(name="nav_active") +def do_nav_active(obj, request): + if request.path.startswith(obj.get_url()): + return True + return False + + @register.simple_tag(name="update_query") def do_update_query(obj, **kwargs): - """Replace provided querydict's values with **kwargs.""" + """Replace provided querydict's values with **kwargs. + + Values set to ``None`` will be dropped. + """ for k, v in kwargs.items(): if v is not None: obj[k] = list(v) if hasattr(v, "__iter__") else [v] @@ -85,4 +156,28 @@ def do_update_query(obj, **kwargs): def do_verbose_name(obj, plural=False): """Return model's verbose name (singular or plural) or `obj` if it is a string (can act for default values).""" - return obj if isinstance(obj, str) else obj._meta.verbose_name_plural if plural else obj._meta.verbose_name + if isinstance(obj, str): + return obj + return obj._meta.verbose_name_plural if plural else obj._meta.verbose_name + + +@register.filter(name="edit_view") +def do_edit_view(obj): + return "%s-edit" % obj.split("-")[0] + + +@register.filter(name="detail_view") +def do_detail_view(obj): + return "%s-detail" % obj.split("-")[0] + + +@register.filter(name="is_checkbox") +def is_checkbox(field): + """Return True if field is a checkbox.""" + return isinstance(field.widget, forms.CheckboxInput) + + +@register.filter(name="is_select") +def is_select(field): + """Return True if field is a select.""" + return isinstance(field.widget, forms.Select) diff --git a/aircox/templatetags/aircox_admin.py b/aircox/templatetags/aircox_admin.py index 32f08d0..55986c5 100644 --- a/aircox/templatetags/aircox_admin.py +++ b/aircox/templatetags/aircox_admin.py @@ -3,10 +3,11 @@ import json from django import template from django.contrib import admin from django.utils.translation import gettext_lazy as _ +from django.utils.safestring import mark_safe from aircox.serializers.admin import UserSettingsSerializer -__all__ = ("register", "do_get_admin_tools", "do_track_inline_data") +__all__ = ("register", "do_get_admin_tools", "do_formset_inline_data", "do_inline_labels") register = template.Library() @@ -17,26 +18,44 @@ def do_get_admin_tools(): return admin.site.get_tools() -@register.simple_tag(name="track_inline_data", takes_context=True) -def do_track_inline_data(context, formset): - """Return initial data for playlist editor as dict. Keys are: +@register.simple_tag(name="formset_inline_data", takes_context=True) +def do_formset_inline_data(context, formset): + """Return initial data of formset as dict (used by TrackListEditor and + PlaylistEditor). Keys are: - ``items``: list of items. Extra keys: - ``__error__``: dict of form fields errors - ``settings``: user's settings + - ``fields``: dict of field name and label """ + + # --- get fields labels + model = formset.form.Meta.model + fields = {} + for field_name in formset.form.Meta.fields: + field = model._meta.get_field(field_name) + fields[field_name] = str(field.verbose_name).capitalize() + + # --- get items items = [] for form in formset.forms: item = {name: form[name].value() for name in form.fields.keys()} item["__errors__"] = form.errors + # hack for sound list + if duration := item.get("duration"): + item["duration"] = duration.strftime("%H:%M") + if sound := getattr(form.instance, "sound", None): + item["name"] = sound.name + fields["name"] = str(_("Sound")).capitalize() + # hack for playlist editor tags = item.get("tags") if tags and not isinstance(tags, str): item["tags"] = ", ".join(tag.name for tag in tags) items.append(item) - data = {"items": items} + data = {"items": items, "fields": fields, "initial": formset.initial and formset.initial[0]} user = context["request"].user settings = getattr(user, "aircox_settings", None) data["settings"] = settings and UserSettingsSerializer(settings).data @@ -44,22 +63,32 @@ def do_track_inline_data(context, formset): return source -track_inline_labels_ = { - "artist": _("Artist"), - "album": _("Album"), - "title": _("Title"), - "tags": _("Tags"), - "year": _("Year"), +inline_labels_ = { + # list editor + "add_item": _("Add an item"), + "remove_item": _("Remove"), + "settings": _("Settings"), "save_settings": _("Save Settings"), "discard_changes": _("Discard changes"), + "submit": _("Submit"), + "delete": _("Delete"), + # select file + "upload": _("Upload"), + "list": _("List"), + "confirm_delete": _("Are you sure to remove this element from the server?"), + "show_next": _("Show next"), + "show_previous": _("Show previous"), + "select_file": _("Select a file"), + # track list + "text": _("Text"), "columns": _("Columns"), - "add_track": _("Add a track"), - "remove_track": _("Remove"), "timestamp": _("Timestamp"), + # sound list + "add_sound": _("Add a sound"), } -@register.simple_tag(name="track_inline_labels") -def do_track_inline_labels(): +@register.simple_tag(name="inline_labels") +def do_inline_labels(): """Return labels for columns in playlist editor as dict.""" - return json.dumps({k: str(v) for k, v in track_inline_labels_.items()}) + return mark_safe(json.dumps({k: str(v) for k, v in inline_labels_.items()})) diff --git a/aircox/tests/_test_permissions.py b/aircox/tests/_test_permissions.py new file mode 100644 index 0000000..4a3c954 --- /dev/null +++ b/aircox/tests/_test_permissions.py @@ -0,0 +1,46 @@ +import pytest +from django.contrib.auth.models import User, Group +from django.urls import reverse + + +@pytest.mark.django_db() +def test_no_admin(user, client): + client.force_login(user) + response = client.get("/admin/") + assert response.status_code != 200 + + +@pytest.mark.django_db() +def test_user_cannot_change_program_or_episode(user, client, program): + assert not user.has_perm("aircox.change_program") + assert not user.has_perm("aircox.change_episode") + + +@pytest.mark.django_db() +def test_group_can_change_program(user, client, program): + assert program.editors in Group.objects.all() + assert not user.has_perm("aircox.%s" % program.change_permission_codename) + user.groups.add(program.editors) + user = User.objects.get(pk=user.pk) # reload user in order to have permissions set + assert program.editors in user.groups.all() + assert user.has_perm("aircox.%s" % program.change_permission_codename) + + +@pytest.mark.django_db() +def test_group_change_program(user, client, program): + client.force_login(user) + response = client.get(reverse("program-edit", kwargs={"pk": program.pk})) + assert response.status_code == 403 + user.groups.add(program.editors) + response = client.get(reverse("program-edit", kwargs={"pk": program.pk})) + assert response.status_code == 200 + + +@pytest.mark.django_db() +def test_group_change_episode(user, client, program, episode): + client.force_login(user) + response = client.get(reverse("episode-edit", kwargs={"pk": episode.pk})) + assert response.status_code == 403 + user.groups.add(program.editors) + response = client.get(reverse("episode-edit", kwargs={"pk": episode.pk})) + assert response.status_code == 200 diff --git a/aircox/tests/conftest.py b/aircox/tests/conftest.py index caf5564..e5afcaf 100644 --- a/aircox/tests/conftest.py +++ b/aircox/tests/conftest.py @@ -1,6 +1,7 @@ from datetime import time, timedelta import itertools import logging +import os from django.conf import settings from django.contrib.auth.models import User @@ -130,25 +131,32 @@ def episode(episodes): @pytest.fixture -def podcasts(episodes): - items = [] - for episode in episodes: - sounds = baker.prepare( - models.Sound, - episode=episode, - program=episode.program, - is_public=True, - _quantity=2, - ) - for i, sound in enumerate(sounds): - sound.file = f"test_sound_{episode.pk}_{i}.mp3" - items += sounds - return items +def sound(program): + return baker.make(models.Sound, file="tmp/test.wav", program=program) @pytest.fixture -def sound(program): - return baker.make(models.Sound, file="tmp/test.wav", program=program) +def sounds(program): + objs = [ + models.Sound(program=program, file=f"tmp/test-{i}.wav", broadcast=(i == 0), is_downloadable=(i == 1)) + for i in range(0, 3) + ] + models.Sound.objects.bulk_create(objs) + return objs + + +@pytest.fixture +def podcasts(episode, sounds): + objs = [ + models.EpisodeSound( + episode=episode, + sound=sound, + broadcast=True, + ) + for sound in sounds + ] + models.EpisodeSound.objects.bulk_create(objs) + return objs @pytest.fixture @@ -157,3 +165,15 @@ def tracks(episode, sound): items += [baker.prepare(models.Track, sound=sound, position=i, timestamp=i * 60) for i in range(0, 3)] models.Track.objects.bulk_create(items) return items + + +@pytest.fixture +def user(): + return User.objects.create_user(username="user1", password="bar") + + +@pytest.fixture +def png_content(): + image_file = "{}/image.png".format(os.path.dirname(__file__)) + with open(image_file, "rb") as fh: + return fh.read() diff --git a/aircox/tests/controllers/test_sound_file.py b/aircox/tests/controllers/test_sound_file.py index 9e5bcc8..cb9b033 100644 --- a/aircox/tests/controllers/test_sound_file.py +++ b/aircox/tests/controllers/test_sound_file.py @@ -1,14 +1,12 @@ import pytest -from datetime import timedelta from django.conf import settings as conf -from django.utils import timezone as tz -from aircox import models from aircox.controllers.sound_file import SoundFile +# FIXME: use from tests.models.sound @pytest.fixture def path_infos(): return { @@ -27,6 +25,7 @@ def path_infos(): "day": 2, "hour": 10, "minute": 13, + "n": None, "name": "Sample 2", }, "test/20220103_1_sample_3.mp3": { @@ -56,42 +55,25 @@ def sound_files(path_infos): return {k: r for k, r in ((path, SoundFile(conf.MEDIA_ROOT + "/" + path)) for path in path_infos.keys())} +@pytest.fixture +def sound_file(sound_files): + return next(sound_files.items()) + + def test_sound_path(sound_files): for path, sound_file in sound_files.items(): assert path == sound_file.sound_path -def test_read_path(path_infos, sound_files): - for path, sound_file in sound_files.items(): - expected = path_infos[path] - result = sound_file.read_path(path) - # remove None values - result = {k: v for k, v in result.items() if v is not None} - assert expected == result, "path: {}".format(path) +class TestSoundFile: + def sound_path(self, sound_file): + assert sound_file[0] == sound_file[1].sound_path + def sync(self): + raise NotImplementedError("test is not implemented") -def _setup_diff(program, info): - episode = models.Episode(program=program, title="test-episode") - at = tz.datetime(**{k: info[k] for k in ("year", "month", "day", "hour", "minute") if info.get(k)}) - at = tz.make_aware(at) - diff = models.Diffusion(episode=episode, start=at, end=at + timedelta(hours=1)) - episode.save() - diff.save() - return diff + def create_episode_sound(self): + raise NotImplementedError("test is not implemented") - -@pytest.mark.django_db(transaction=True) -def test_find_episode(sound_files): - station = models.Station(name="test-station") - program = models.Program(station=station, title="test") - station.save() - program.save() - - for path, sound_file in sound_files.items(): - infos = sound_file.read_path(path) - diff = _setup_diff(program, infos) - sound = models.Sound(program=diff.program, file=path) - result = sound_file.find_episode(sound, infos) - assert diff.episode == result - - # TODO: find_playlist, sync + def _on_delete(self): + raise NotImplementedError("test is not implemented") diff --git a/aircox/tests/controllers/test_sound_monitor.py b/aircox/tests/controllers/test_sound_monitor.py index 0913a63..d1c5765 100644 --- a/aircox/tests/controllers/test_sound_monitor.py +++ b/aircox/tests/controllers/test_sound_monitor.py @@ -223,22 +223,19 @@ class TestSoundMonitor: [ (("scan all programs...",), {}), ] - + [ - ((f"#{program.id} {program.title}",), {}) - for program in programs - ] + + [((f"#{program.id} {program.title}",), {}) for program in programs] ) assert dirs == [program.abspath for program in programs] traces = tuple( [ [ ( - (program, settings.SOUND_ARCHIVES_SUBDIR), - {"logger": logger, "type": Sound.TYPE_ARCHIVE}, + (program, settings.SOUND_BROADCASTS_SUBDIR), + {"logger": logger, "broadcast": True}, ), ( (program, settings.SOUND_EXCERPTS_SUBDIR), - {"logger": logger, "type": Sound.TYPE_EXCERPT}, + {"logger": logger, "broadcast": False}, ), ] for program in programs @@ -247,6 +244,7 @@ class TestSoundMonitor: traces_flat = tuple([item for sublist in traces for item in sublist]) assert interface._traces("scan_for_program") == traces_flat + # TODO / FIXME def broken_test_monitor(self, monitor, monitor_interfaces, logger): def sleep(*args, **kwargs): monitor.stop() @@ -260,6 +258,7 @@ class TestSoundMonitor: assert observer schedules = observer._traces("schedule") for (handler, *_), kwargs in schedules: + breakpoint() assert isinstance(handler, sound_monitor.MonitorHandler) assert isinstance(handler.pool, futures.ThreadPoolExecutor) assert (handler.subdir, handler.type) in ( diff --git a/aircox/tests/image.png b/aircox/tests/image.png new file mode 100644 index 0000000000000000000000000000000000000000..e9704248d0ba0f11d2663ff82aad0cba348af222 GIT binary patch literal 69 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1SBVv2j2ryJf1F&Ar*6y6Mmd$U|?cmWT@tB RtN@BLc)I$ztaD0e0syL!4q^ZR literal 0 HcmV?d00001 diff --git a/aircox/tests/models/test_sound.py b/aircox/tests/models/test_sound.py new file mode 100644 index 0000000..01052f0 --- /dev/null +++ b/aircox/tests/models/test_sound.py @@ -0,0 +1,122 @@ +from datetime import timedelta +import os +import pytest + +from django.conf import settings +from django.utils import timezone as tz + +from aircox import models + + +@pytest.fixture +def path_infos(): + return { + "test/20220101_10h13_1_sample_1.mp3": { + "year": 2022, + "month": 1, + "day": 1, + "hour": 10, + "minute": 13, + "n": 1, + "name": "Sample 1", + }, + "test/20220102_10h13_sample_2.mp3": { + "year": 2022, + "month": 1, + "day": 2, + "hour": 10, + "minute": 13, + "n": None, + "name": "Sample 2", + }, + "test/20220103_1_sample_3.mp3": { + "year": 2022, + "month": 1, + "day": 3, + "hour": None, + "minute": None, + "n": 1, + "name": "Sample 3", + }, + "test/20220104_sample_4.mp3": { + "year": 2022, + "month": 1, + "day": 4, + "hour": None, + "minute": None, + "n": None, + "name": "Sample 4", + }, + "test/20220105.mp3": { + "year": 2022, + "month": 1, + "day": 5, + "hour": None, + "minute": None, + "n": None, + "name": "20220105", + }, + } + + +class TestSoundQuerySet: + @pytest.mark.django_db + def test_downloadable(self, sounds): + query = models.Sound.objects.downloadable().values_list("is_downloadable", flat=True) + assert set(query) == {True} + + @pytest.mark.django_db + def test_broadcast(self, sounds): + query = models.Sound.objects.broadcast().values_list("broadcast", flat=True) + assert set(query) == {True} + + @pytest.mark.django_db + def test_playlist(self, sounds): + expected = [os.path.join(settings.MEDIA_ROOT, s.file.path) for s in sounds] + assert models.Sound.objects.all().playlist() == expected + + +class TestSound: + @pytest.mark.django_db + def test_read_path(self, path_infos): + for path, expected in path_infos.items(): + result = models.Sound.read_path(path) + assert expected == result + + @pytest.mark.django_db + def test__as_name(self): + name = "some_1_file" + assert models.Sound._as_name(name) == "Some 1 File" + + def _setup_diff(self, program, info): + episode = models.Episode(program=program, title="test-episode") + at = tz.datetime(**{k: info[k] for k in ("year", "month", "day", "hour", "minute") if info.get(k)}) + at = tz.make_aware(at) + diff = models.Diffusion(episode=episode, start=at, end=at + timedelta(hours=1)) + episode.save() + diff.save() + return diff + + @pytest.mark.django_db(transaction=True) + def test_find_episode(self, program, path_infos): + for path, infos in path_infos.items(): + diff = self._setup_diff(program, infos) + sound = models.Sound(program=diff.program, file=path) + result = sound.find_episode(infos) + assert diff.episode == result + + @pytest.mark.django_db + def test_find_playlist(self): + raise NotImplementedError("test is not implemented") + + @pytest.mark.django_db + def test_get_upload_dir(self): + raise NotImplementedError("test is not implemented") + + @pytest.mark.django_db + def test_sync_fs(self): + raise NotImplementedError("test is not implemented") + + @pytest.mark.django_db + def test_read_metadata(self): + raise NotImplementedError("test is not implemented") diff --git a/aircox/tests/test_admin_site.py b/aircox/tests/test_admin_site.py deleted file mode 100644 index 62234c9..0000000 --- a/aircox/tests/test_admin_site.py +++ /dev/null @@ -1,45 +0,0 @@ -from django.urls import path, reverse -from django.utils.translation import gettext_lazy as _ - -import pytest - -from aircox import admin_site, urls as _urls -from .conftest import req_factory - - -# Just for code quality: urls module is required because we need some -# url resolvers to be registered in order to run tests. -_urls - - -@pytest.fixture -def site(): - return admin_site.AdminSite() - - -class TestAdminSite: - @pytest.mark.django_db - def test_each_context(self, site, staff_user): - req = req_factory.get("admin/test") - req.user = staff_user - context = site.each_context(req) - assert "programs" in context - assert "diffusions" in context - assert "comments" in context - - def test_get_urls(self, site): - extra_url = path("test/path", lambda *_, **kw: _) - site.extra_urls.append(extra_url) - urls = site.get_urls() - assert extra_url in urls - - def test_get_tools(self, site): - tools = site.get_tools() - tools = dict(tools) - assert tools == { - _("Statistics"): reverse("admin:tools-stats"), - } - - def test_route_view(self, site): - # TODO - pass diff --git a/aircox/tests/views/conftest.py b/aircox/tests/views/conftest.py index 44825ac..efb51ac 100644 --- a/aircox/tests/views/conftest.py +++ b/aircox/tests/views/conftest.py @@ -11,6 +11,9 @@ class FakeView: def ___init__(self): self.kwargs = {} + def dispatch(self, *args, **kwargs): + pass + def get(self, *args, **kwargs): pass diff --git a/aircox/tests/views/test_base.py b/aircox/tests/views/test_base.py index 4be01a7..501ed2b 100644 --- a/aircox/tests/views/test_base.py +++ b/aircox/tests/views/test_base.py @@ -1,6 +1,5 @@ import pytest -from django.urls import reverse from aircox import models from aircox.test import Interface @@ -37,31 +36,15 @@ class TestBaseView: def test_station(self, base_view, station): assert base_view.station == station - @pytest.mark.django_db - def test_get_sidebar_queryset(self, base_view, pages, published_pages): - query = base_view.get_sidebar_queryset().values_list("id", flat=True) - page_ids = {r.id for r in published_pages} - assert set(query) == page_ids - - @pytest.mark.django_db - def test_get_sidebar_url(self, base_view): - assert base_view.get_sidebar_url() == reverse("page-list") - @pytest.mark.django_db def test_get_context_data(self, base_view, station, published_pages): - base_view.has_sidebar = True - base_view.get_sidebar_queryset = lambda: published_pages context = base_view.get_context_data() assert context == { "view": base_view, "station": station, "page": None, # get_page() returns None - "has_sidebar": base_view.has_sidebar, - "has_filters": False, - "sidebar_object_list": published_pages[: base_view.list_count], - "sidebar_list_url": base_view.get_sidebar_url(), - "audio_streams": station.streams, "model": base_view.model, + "nav_menu": [], } diff --git a/aircox/tests/views/test_mixins.py b/aircox/tests/views/test_mixins.py index ad9d40d..c460780 100644 --- a/aircox/tests/views/test_mixins.py +++ b/aircox/tests/views/test_mixins.py @@ -40,7 +40,7 @@ def parent_mixin(): @pytest.fixture def attach_mixin(): class Mixin(mixins.AttachedToMixin, FakeView): - attach_to_value = models.StaticPage.ATTACH_TO_HOME + attach_to_value = models.StaticPage.Target.HOME return Mixin() @@ -80,7 +80,7 @@ class TestGetDateMixin: ) def test_get_calls_get_date(self, date_mixin): - date_mixin.get_date = lambda: today + date_mixin.get_date = lambda *_: today date_mixin.get() assert date_mixin.date == today @@ -105,10 +105,10 @@ class TestParentMixin: def test_get_parent_not_parent_url_kwargs(self, parent_mixin): assert parent_mixin.get_parent(self.req) is None - def test_get_calls_parent(self, parent_mixin): + def test_dispatch_calls_parent(self, parent_mixin): parent = "parent object" parent_mixin.get_parent = lambda *_, **kw: parent - parent_mixin.get(self.req) + parent_mixin.dispatch(self.req) assert parent_mixin.parent == parent @pytest.mark.django_db @@ -120,7 +120,7 @@ class TestParentMixin: assert set(query) == episodes_id def test_get_context_data_with_parent(self, parent_mixin): - parent_mixin.parent = Interface(cover="parent-cover") + parent_mixin.parent = Interface(cover=Interface(url="parent-cover")) context = parent_mixin.get_context_data() assert context["cover"] == "parent-cover" diff --git a/aircox/urls.py b/aircox/urls.py index 0ad7df6..172401e 100755 --- a/aircox/urls.py +++ b/aircox/urls.py @@ -21,12 +21,18 @@ register_converter(WeekConverter, "week") router = DefaultRouter() +router.register("user", viewsets.UserViewSet, basename="user") +router.register("group", viewsets.GroupViewSet, basename="group") +router.register("usergroup", viewsets.UserGroupViewSet, basename="usergroup") + +router.register("images", viewsets.ImageViewSet, basename="image") router.register("sound", viewsets.SoundViewSet, basename="sound") router.register("track", viewsets.TrackROViewSet, basename="track") +router.register("comment", viewsets.CommentViewSet, basename="comment") api = [ - path("logs/", views.LogListAPIView.as_view(), name="live"), + path("logs/", views.log.LogListAPIView.as_view(), name="live"), path( "user/settings/", viewsets.UserSettingsViewSet.as_view({"get": "retrieve", "post": "update", "put": "update"}), @@ -36,47 +42,42 @@ api = [ urls = [ - path("", views.HomeView.as_view(), name="home"), + path("", views.home.HomeView.as_view(), name="home"), path("api/", include((api, "aircox"), namespace="api")), - # path('', views.PageDetailView.as_view(model=models.Article), - # name='home'), + # ---- ---- objects views + # ---- articles + path( + _("articles/<slug:slug>/"), + views.article.ArticleDetailView.as_view(), + name="article-detail", + ), path( _("articles/"), - views.ArticleListView.as_view(model=models.Article), + views.article.ArticleListView.as_view(model=models.article.Article), name="article-list", ), path( - _("articles/<slug:slug>/"), - views.ArticleDetailView.as_view(), - name="article-detail", + _("articles/c/<slug:category_slug>/"), + views.article.ArticleListView.as_view(model=models.article.Article), + name="article-list", ), - path(_("episodes/"), views.EpisodeListView.as_view(), name="episode-list"), + # ---- timetable + path(_("timetable/"), views.diffusion.TimeTableView.as_view(), name="timetable-list"), path( - _("episodes/<slug:slug>/"), - views.EpisodeDetailView.as_view(), - name="episode-detail", + _("timetable/<date:date>/"), + views.diffusion.TimeTableView.as_view(), + name="timetable-list", ), - path(_("week/"), views.DiffusionListView.as_view(), name="diffusion-list"), - path( - _("week/<date:date>/"), - views.DiffusionListView.as_view(), - name="diffusion-list", - ), - path(_("logs/"), views.LogListView.as_view(), name="log-list"), - path(_("logs/<date:date>/"), views.LogListView.as_view(), name="log-list"), - # path('<page_path:path>', views.route_page, name='page'), + # ---- pages path( _("publications/"), - views.PageListView.as_view(model=models.Page), + views.PageListView.as_view(model=models.Page, attach_to_value=models.StaticPage.Target.PAGES), name="page-list", ), path( - _("pages/"), - views.BasePageListView.as_view( - model=models.StaticPage, - queryset=models.StaticPage.objects.filter(attach_to__isnull=True), - ), - name="static-page-list", + _("publications/c/<slug:category_slug>/"), + views.PageListView.as_view(model=models.Page, attach_to_value=models.StaticPage.Target.PAGES), + name="page-list", ), path( _("pages/<slug:slug>/"), @@ -86,30 +87,50 @@ urls = [ ), name="static-page-detail", ), - path(_("programs/"), views.ProgramListView.as_view(), name="program-list"), + path( + _("pages/"), + views.BasePageListView.as_view( + model=models.StaticPage, + queryset=models.StaticPage.objects.filter(attach_to__isnull=True), + ), + name="static-page-list", + ), + # ---- programs + path(_("programs/"), views.program.ProgramListView.as_view(), name="program-list"), + path(_("programs/c/<slug:category_slug>/"), views.program.ProgramListView.as_view(), name="program-list"), path( _("programs/<slug:slug>/"), - views.ProgramDetailView.as_view(), + views.program.ProgramDetailView.as_view(), name="program-detail", ), + path(_("programs/<slug:parent_slug>/articles/"), views.article.ArticleListView.as_view(), name="article-list"), + path(_("programs/<slug:parent_slug>/podcasts/"), views.episode.PodcastListView.as_view(), name="podcast-list"), + path(_("programs/<slug:parent_slug>/episodes/"), views.episode.EpisodeListView.as_view(), name="episode-list"), path( - _("programs/<slug:parent_slug>/episodes/"), - views.EpisodeListView.as_view(), - name="episode-list", - ), - path( - _("programs/<slug:parent_slug>/articles/"), - views.ArticleListView.as_view(), - name="article-list", + _("programs/<slug:parent_slug>/diffusions/"), views.diffusion.DiffusionListView.as_view(), name="diffusion-list" ), path( _("programs/<slug:parent_slug>/publications/"), - views.ProgramPageListView.as_view(), - name="program-page-list", + views.PageListView.as_view(model=models.Page, attach_to_value=models.StaticPage.Target.PAGES), + name="page-list", ), + # ---- episodes + path(_("programs/episodes/"), views.episode.EpisodeListView.as_view(), name="episode-list"), + path(_("programs/episodes/c/<slug:category_slug>/"), views.episode.EpisodeListView.as_view(), name="episode-list"), path( - "errors/no-station", - views.errors.NoStationErrorView.as_view(), - name="errors-no-station", + _("programs/episodes/<slug:slug>/"), + views.episode.EpisodeDetailView.as_view(), + name="episode-detail", ), + path(_("podcasts/"), views.episode.PodcastListView.as_view(), name="podcast-list"), + path(_("podcasts/c/<slug:category_slug>/"), views.episode.PodcastListView.as_view(), name="podcast-list"), + # ---- dashboard + path(_("dashboard/"), views.dashboard.DashboardView.as_view(), name="dashboard"), + path(_("dashboard/program/<pk>/"), views.program.ProgramUpdateView.as_view(), name="program-edit"), + path(_("dashboard/episodes/<pk>/"), views.episode.EpisodeUpdateView.as_view(), name="episode-edit"), + path(_("dashboard/statistics/"), views.dashboard.StatisticsView.as_view(), name="dashboard-statistics"), + path(_("dashboard/statistics/<date:date>/"), views.dashboard.StatisticsView.as_view(), name="dashboard-statistics"), + path(_("dashboard/users/"), views.auth.UserListView.as_view(), name="user-list"), + # ---- others + path(_("errors/no-station/"), views.errors.NoStationErrorView.as_view(), name="errors-no-station"), ] diff --git a/aircox/views/__init__.py b/aircox/views/__init__.py index e4d9d4a..a8e536f 100644 --- a/aircox/views/__init__.py +++ b/aircox/views/__init__.py @@ -1,42 +1,32 @@ -from . import admin, errors -from .article import ArticleDetailView, ArticleListView +from . import admin, dashboard, errors, auth +from . import article, program, episode, diffusion, log +from . import home from .base import BaseAPIView, BaseView -from .diffusion import DiffusionListView -from .episode import EpisodeDetailView, EpisodeListView -from .home import HomeView -from .log import LogListAPIView, LogListView from .page import ( BasePageDetailView, BasePageListView, PageDetailView, PageListView, + PageUpdateView, ) -from .program import ( - ProgramDetailView, - ProgramListView, - ProgramPageDetailView, - ProgramPageListView, -) + __all__ = ( "admin", "errors", - "ArticleDetailView", - "ArticleListView", + "dashboard", + "auth", + "article", + "program", + "episode", + "diffusion", + "log", + "home", "BaseAPIView", "BaseView", - "DiffusionListView", - "EpisodeDetailView", - "EpisodeListView", - "HomeView", - "LogListAPIView", - "LogListView", "BasePageDetailView", "BasePageListView", "PageDetailView", + "PageUpdateView", "PageListView", - "ProgramDetailView", - "ProgramListView", - "ProgramPageDetailView", - "ProgramPageListView", ) diff --git a/aircox/views/admin.py b/aircox/views/admin.py index a76ddae..d3120f7 100644 --- a/aircox/views/admin.py +++ b/aircox/views/admin.py @@ -19,7 +19,7 @@ class AdminMixin(LoginRequiredMixin, UserPassesTestMixin): return self.request.station def test_func(self): - return self.request.user.is_staff + return self.request.user.is_admin def get_context_data(self, **kwargs): kwargs.update(admin.site.each_context(self.request)) @@ -31,7 +31,7 @@ class AdminMixin(LoginRequiredMixin, UserPassesTestMixin): class StatisticsView(AdminMixin, LogListView, ListView): template_name = "admin/aircox/statistics.html" - redirect_date_url = "admin:tools-stats" + # redirect_date_url = "admin:tools-stats" title = _("Statistics") date = None diff --git a/aircox/views/article.py b/aircox/views/article.py index f7a03a9..529f2c6 100644 --- a/aircox/views/article.py +++ b/aircox/views/article.py @@ -1,20 +1,15 @@ from ..models import Article, Program, StaticPage -from .page import PageDetailView, PageListView +from . import page __all__ = ["ArticleDetailView", "ArticleListView"] -class ArticleDetailView(PageDetailView): - has_sidebar = True +class ArticleDetailView(page.PageDetailView): model = Article - def get_sidebar_queryset(self): - qs = Article.objects.published().select_related("cover").order_by("-pub_date") - return qs - -class ArticleListView(PageListView): +@page.attach +class ArticleListView(page.PageListView): model = Article - has_headline = True parent_model = Program - attach_to_value = StaticPage.ATTACH_TO_ARTICLES + attach_to_value = StaticPage.Target.ARTICLES diff --git a/aircox/views/auth.py b/aircox/views/auth.py new file mode 100644 index 0000000..23fe980 --- /dev/null +++ b/aircox/views/auth.py @@ -0,0 +1,28 @@ +from django.contrib.auth.models import User +from django.views.generic import ListView + +from aircox.models import Program + + +class UserListView(ListView): + model = User + queryset = User.objects.all().order_by("first_name").prefetch_related("groups") + paginate_by = 100 + permission_required = [ + "auth.list_user", + ] + template_name = "aircox/dashboard/user_list.html" + + def get_users_programs(self, users): + groups = {g for u in users for g in u.groups.all()} + programs = Program.objects.filter(editors_group__in=groups) + programs = {p.editors_group_id: p for p in programs} + + for user in users: + user.programs = [programs[g.id] for g in user.groups.all() if g.id in programs] + + return programs + + def get_context_data(self, **kwargs): + kwargs["programs"] = self.get_users_programs(self.object_list) + return super().get_context_data(**kwargs) diff --git a/aircox/views/base.py b/aircox/views/base.py index 6e6d597..56aa736 100644 --- a/aircox/views/base.py +++ b/aircox/views/base.py @@ -2,18 +2,14 @@ from django.http import HttpResponseRedirect from django.urls import reverse from django.views.generic.base import ContextMixin, TemplateResponseMixin -from ..models import Page __all__ = ("BaseView", "BaseAPIView") class BaseView(TemplateResponseMixin, ContextMixin): - has_sidebar = True - """Show side navigation.""" - has_filters = False - """Show filters nav.""" - list_count = 5 - """Item count for small lists displayed on page.""" + related_count = 4 + related_carousel_count = 8 + title = "" @property def station(self): @@ -22,12 +18,33 @@ class BaseView(TemplateResponseMixin, ContextMixin): # def get_queryset(self): # return super().get_queryset().station(self.station) - def get_sidebar_queryset(self): - """Return a queryset of items to render on the side nav.""" - return Page.objects.select_subclasses().published().order_by("-pub_date") + def get_nav_menu(self): + menu = [] + for item in self.station.navitem_set.all(): + try: + if item.page: + view = item.page.get_related_view() + secondary = view and view.get_secondary_nav() + else: + secondary = None + menu.append((item, secondary)) + except: + import traceback - def get_sidebar_url(self): - return reverse("page-list") + traceback.print_exc() + raise + return menu + + def get_secondary_nav(self): + return None + + def get_related_queryset(self): + """Return a queryset of related pages or None.""" + return None + + def get_related_url(self): + """Return an url to the list of related pages.""" + return None def get_page(self): return None @@ -35,22 +52,20 @@ class BaseView(TemplateResponseMixin, ContextMixin): def get_context_data(self, **kwargs): kwargs.setdefault("station", self.station) kwargs.setdefault("page", self.get_page()) - kwargs.setdefault("has_filters", self.has_filters) - - has_sidebar = kwargs.setdefault("has_sidebar", self.has_sidebar) - if has_sidebar and "sidebar_object_list" not in kwargs: - sidebar_object_list = self.get_sidebar_queryset() - if sidebar_object_list is not None: - kwargs["sidebar_object_list"] = sidebar_object_list[: self.list_count] - kwargs["sidebar_list_url"] = self.get_sidebar_url() - - if "audio_streams" not in kwargs: - kwargs["audio_streams"] = self.station.streams if "model" not in kwargs: model = getattr(self, "model", None) or hasattr(self, "object") and type(self.object) kwargs["model"] = model + page = kwargs.get("page") + if page: + kwargs.setdefault("title", page.display_title) + kwargs.setdefault("cover", page.cover and page.cover.url) + elif self.title: + kwargs.setdefault("title", self.title) + + if "nav_menu" not in kwargs: + kwargs["nav_menu"] = self.get_nav_menu() return super().get_context_data(**kwargs) def dispatch(self, *args, **kwargs): diff --git a/aircox/views/dashboard.py b/aircox/views/dashboard.py new file mode 100644 index 0000000..7be9b02 --- /dev/null +++ b/aircox/views/dashboard.py @@ -0,0 +1,57 @@ +from django.db.models import Q +from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin +from django.utils.translation import gettext_lazy as _ +from django.views.generic.base import TemplateView + +from aircox import models +from aircox.controllers.log_archiver import LogArchiver +from .base import BaseView +from .log import LogListView + + +__all__ = ("DashboardBaseView", "DashboardView", "StatisticsView") + + +class DashboardBaseView(LoginRequiredMixin, UserPassesTestMixin, BaseView): + title = _("Dashboard") + + def test_func(self): + user = self.request.user + return user.is_staff or user.is_superuser + + +class DashboardView(DashboardBaseView, TemplateView): + template_name = "aircox/dashboard/dashboard.html" + + def get_context_data(self, **kwargs): + programs = models.Program.objects.editor(self.request.user) + comments = models.Comment.objects.filter( + Q(page__in=programs) | Q(page__episode__parent__in=programs) | Q(page__article__parent__in=programs) + ) + + kwargs.update( + { + "subtitle": self.request.user.get_username(), + "programs": programs.order_by("title"), + "comments": comments.order_by("-date"), + "next_diffs": models.Diffusion.objects.editor(self.request.user) + .select_related("episode") + .after() + .order_by("start"), + } + ) + return super().get_context_data(**kwargs) + + +class StatisticsView(DashboardBaseView, LogListView): + template_name = "aircox/dashboard/statistics.html" + date = None + # redirect_date_url = "dashboard-statistics" + + # TOOD: test_func & perms check + + def get_object_list(self, logs, full=False): + if not logs.exists(): + logs = LogArchiver().load(self.station, self.date) if self.date else [] + objs = super().get_object_list(logs, True) + return objs diff --git a/aircox/views/diffusion.py b/aircox/views/diffusion.py index 5b90efb..7417a1a 100644 --- a/aircox/views/diffusion.py +++ b/aircox/views/diffusion.py @@ -1,30 +1,57 @@ import datetime +from django.urls import reverse from django.views.generic import ListView -from aircox.models import Diffusion, StaticPage +from aircox.models import Diffusion, Log, StaticPage from .base import BaseView from .mixins import AttachedToMixin, GetDateMixin +from .page import attach -__all__ = ("DiffusionListView",) +__all__ = ("DiffusionListView", "TimeTableView") -class DiffusionListView(GetDateMixin, AttachedToMixin, BaseView, ListView): +class BaseDiffusionListView(AttachedToMixin, BaseView, ListView): + model = Diffusion + queryset = Diffusion.objects.on_air().order_by("-start") + + +class DiffusionListView(BaseDiffusionListView): """View for timetables.""" model = Diffusion - has_filters = True - redirect_date_url = "diffusion-list" - attach_to_value = StaticPage.ATTACH_TO_DIFFUSIONS + + +@attach +class TimeTableView(GetDateMixin, BaseDiffusionListView): + model = Diffusion + redirect_date_url = "timetable-list" + attach_to_value = StaticPage.Target.TIMETABLE + template_name = "aircox/timetable_list.html" def get_date(self): date = super().get_date() return date if date is not None else datetime.date.today() - def get_queryset(self): - return super().get_queryset().date(self.date).order_by("start") + def get_logs(self, date): + return Log.objects.on_air().date(self.date).filter(track__isnull=False) - def get_context_data(self, **kwargs): + def get_queryset(self): + return super().get_queryset().date(self.date) + + @classmethod + def get_secondary_nav(cls): + date = datetime.date.today() + start = date - datetime.timedelta(days=date.weekday()) + dates = [start + datetime.timedelta(days=i) for i in range(0, 7)] + return tuple((date.strftime("%A %d"), reverse("timetable-list", kwargs={"date": date})) for date in dates) + + def get_context_data(self, object_list=None, **kwargs): start = self.date - datetime.timedelta(days=self.date.weekday()) dates = [start + datetime.timedelta(days=i) for i in range(0, 7)] - return super().get_context_data(date=self.date, dates=dates, **kwargs) + + if object_list is None: + logs = self.get_logs(self.date) + object_list = Log.merge_diffusions(logs, self.object_list, group_logs=True) + object_list = list(reversed(object_list)) + return super().get_context_data(date=self.date, dates=dates, object_list=object_list, **kwargs) diff --git a/aircox/views/episode.py b/aircox/views/episode.py index e53dd19..bbe0be6 100644 --- a/aircox/views/episode.py +++ b/aircox/views/episode.py @@ -1,27 +1,138 @@ -from ..filters import EpisodeFilters -from ..models import Episode, Program, StaticPage -from .page import PageListView -from .program import ProgramPageDetailView +from django.contrib.auth.mixins import UserPassesTestMixin +from django.urls import reverse + +from aircox.models import Episode, Program, StaticPage, Track +from aircox import forms, filters, permissions + +from .mixins import VueFormDataMixin +from .page import attach, PageDetailView, PageListView, PageUpdateView + __all__ = ( "EpisodeDetailView", "EpisodeListView", + "PodcastListView", + "EpisodeUpdateView", ) -class EpisodeDetailView(ProgramPageDetailView): +class EpisodeDetailView(PageDetailView): model = Episode + def can_edit(self, obj): + return permissions.program.can(self.request.user, "update", obj) + def get_context_data(self, **kwargs): if "tracks" not in kwargs: kwargs["tracks"] = self.object.track_set.order_by("position") return super().get_context_data(**kwargs) + def get_related_queryset(self): + return ( + self.get_queryset().parent(self.object.parent).exclude(pk=self.object.pk).published().order_by("-pub_date") + ) + def get_related_url(self): + return reverse("episode-list", kwargs={"parent_slug": self.object.parent.slug}) + + +@attach class EpisodeListView(PageListView): model = Episode - filterset_class = EpisodeFilters - item_template_name = "aircox/widgets/episode_item.html" - has_headline = True + filterset_class = filters.EpisodeFilters parent_model = Program - attach_to_value = StaticPage.ATTACH_TO_EPISODES + attach_to_value = StaticPage.Target.EPISODES + + +@attach +class PodcastListView(EpisodeListView): + attach_to_value = StaticPage.Target.PODCASTS + queryset = Episode.objects.published().with_podcasts().order_by("-pub_date") + + +class EpisodeUpdateView(UserPassesTestMixin, VueFormDataMixin, PageUpdateView): + model = Episode + form_class = forms.EpisodeForm + template_name = "aircox/episode_form.html" + + def test_func(self): + obj = self.get_object() + return permissions.program.can(self.request.user, "update", obj) + + def get_tracklist_queryset(self, episode): + return Track.objects.filter(episode=episode).order_by("position") + + def get_tracklist_formset(self, episode, **kwargs): + kwargs.update( + { + "prefix": "tracks", + "queryset": self.get_tracklist_queryset(episode), + "initial": [ + { + "episode": episode.id, + } + ], + } + ) + return forms.TrackFormSet(**kwargs) + + def get_soundlist_queryset(self, episode): + return episode.episodesound_set.all().select_related("sound").order_by("position") + + def get_soundlist_formset(self, episode, **kwargs): + kwargs.update( + { + "prefix": "sounds", + "queryset": self.get_soundlist_queryset(episode), + "initial": [ + { + "episode": episode.id, + } + ], + } + ) + return forms.EpisodeSoundFormSet(**kwargs) + + def get_sound_form(self, episode, **kwargs): + kwargs.update( + { + "initial": { + "program": episode.parent_id, + "name": episode.title, + "is_public": True, + }, + } + ) + return forms.SoundCreateForm(**kwargs) + + def get_context_data(self, **kwargs): + forms = ( + ("soundlist_formset", self.get_soundlist_formset), + ("tracklist_formset", self.get_tracklist_formset), + ("sound_form", self.get_sound_form), + ) + for key, func in forms: + if key not in kwargs: + kwargs[key] = func(self.object) + + for key in ("soundlist_formset", "tracklist_formset"): + formset = kwargs[key] + kwargs[f"{key}_data"] = self.get_formset_data(formset, {"episode": self.object.id}) + return super().get_context_data(**kwargs) + + def post(self, request, *args, **kwargs): + resp = super().post(request, *args, **kwargs) + + formsets = { + "soundlist_formset": self.get_soundlist_formset(self.object, data=request.POST), + "tracklist_formset": self.get_tracklist_formset(self.object, data=request.POST), + } + invalid = False + for formset in formsets.values(): + if not formset.is_valid(): + invalid = True + else: + formset.save() + if invalid: + return self.get(request, **formsets) + return resp diff --git a/aircox/views/home.py b/aircox/views/home.py index 0bf4e08..a439ad5 100644 --- a/aircox/views/home.py +++ b/aircox/views/home.py @@ -1,57 +1,84 @@ -from datetime import date +from datetime import date, datetime, timedelta from django.utils import timezone as tz from django.views.generic import ListView -from ..models import Diffusion, Log, Page, StaticPage +from ..models import Diffusion, Episode, Log, Page, StaticPage from .base import BaseView +from .mixins import AttachedToMixin +from .page import attach -class HomeView(BaseView, ListView): +@attach +class HomeView(AttachedToMixin, BaseView, ListView): template_name = "aircox/home.html" + attach_to_value = StaticPage.Target.HOME model = Diffusion - attach_to_value = StaticPage.ATTACH_TO_HOME - queryset = Diffusion.objects.on_air().select_related("episode") - logs_count = 5 - publications_count = 5 - has_filters = False + queryset = Diffusion.objects.on_air().select_related("episode").order_by("-start") + + publications_queryset = Page.objects.select_subclasses().published().order_by("-pub_date") + podcasts_queryset = Episode.objects.published().with_podcasts().order_by("-pub_date") def get_queryset(self): - return super().get_queryset().date(date.today()) + now = datetime.now() + return super().get_queryset().after(now - timedelta(hours=24)).before(now).order_by("-start") def get_logs(self, diffusions): today = date.today() - logs = Log.objects.on_air().date(today).filter(track__isnull=False) + now = datetime.now() # diffs = Diffusion.objects.on_air().date(today) - return Log.merge_diffusions(logs, diffusions, self.logs_count) + object_list = self.object_list + diffs = list(object_list[: self.related_count]) + logs = Log.objects.on_air().filter(track__isnull=False, date__lte=now) + if diffs: + min_date = diffs[-1].start - timedelta(hours=1) + logs = logs.after(min_date) + else: + logs = logs.date(today) + return Log.merge_diffusions( + logs, object_list, diff_count=self.related_count, count=self.related_count + 2, group_logs=True + ) def get_next_diffs(self): now = tz.now() - current_diff = Diffusion.objects.on_air().now(now).first() - next_diffs = Diffusion.objects.on_air().after(now) + query = Diffusion.objects.on_air().select_related("episode") + current_diff = query.now(now).first() + next_diffs = query.after(now) if current_diff: - diffs = [current_diff] + list(next_diffs.exclude(pk=current_diff.pk)[:2]) + diffs = [current_diff] + list(next_diffs.exclude(pk=current_diff.pk)[:9]) else: - diffs = next_diffs[:3] + diffs = next_diffs[: self.related_carousel_count] return diffs - def get_last_publications(self): + def get_publications(self): # note: with postgres db, possible to use distinct() - qs = Page.objects.select_subclasses().published().order_by("-pub_date") + qs = self.publications_queryset.all() parents = set() items = [] for publication in qs: - parent_id = publication.parent_id + parent_id = getattr(publication, "parent_id", None) if parent_id is not None and parent_id in parents: continue items.append(publication) - if len(items) == self.publications_count: + if len(items) == self.related_count: break return items + def get_podcasts(self): + return self.podcasts_queryset.all()[: self.related_carousel_count] + def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context["logs"] = self.get_logs(context["object_list"]) - context["next_diffs"] = self.get_next_diffs() - context["last_publications"] = self.get_last_publications()[:5] - return context + next_diffs = self.get_next_diffs() + current_diff = next_diffs and next_diffs[0] + + kwargs.update( + { + "object": current_diff.episode, + "diffusion": current_diff, + "logs": self.get_logs(self.object_list), + "next_diffs": next_diffs, + "publications": self.get_publications(), + "podcasts": self.get_podcasts(), + } + ) + return super().get_context_data(**kwargs) diff --git a/aircox/views/log.py b/aircox/views/log.py index 392b611..87dbc87 100644 --- a/aircox/views/log.py +++ b/aircox/views/log.py @@ -6,45 +6,43 @@ from django.views.decorators.cache import cache_page from django.views.generic import ListView from rest_framework.generics import ListAPIView -from ..models import Diffusion, Log, StaticPage +from ..models import Diffusion, Log from ..serializers import LogInfo, LogInfoSerializer from .base import BaseAPIView, BaseView from .mixins import AttachedToMixin, GetDateMixin -__all__ = ["LogListMixin", "LogListView"] +__all__ = ("LogListMixin", "LogListView", "LogListAPIView") class LogListMixin(GetDateMixin): model = Log min_date = None + max_date = None - def get_date(self): - date = super().get_date() + def get_date(self, param): + date = super().get_date(param) if date is not None and not self.request.user.is_staff: return min(date, datetime.date.today()) return date + def filter_qs(self, query): + if self.min_date: + query = query.after(self.min_date) + if self.max_date: + query = query.before(self.max_date) + if not self.min_date and not self.max_date and self.date: + return query.date(self.date) + return query + def get_queryset(self): # only get logs for tracks: log for diffusion will be retrieved # by the diffusions' queryset. - qs = super().get_queryset().on_air().filter(track__isnull=False).filter(date__lte=tz.now()) - return ( - qs.date(self.date) - if self.date is not None - else qs.after(self.min_date) - if self.min_date is not None - else qs - ) + query = super().get_queryset().on_air().filter(track__isnull=False).filter(date__lte=tz.now()) + return self.filter_qs(query) def get_diffusions_queryset(self): - qs = Diffusion.objects.station(self.station).on_air().filter(start__lte=tz.now()) - return ( - qs.date(self.date) - if self.date is not None - else qs.after(self.min_date) - if self.min_date is not None - else qs - ) + query = Diffusion.objects.station(self.station).on_air().filter(start__lte=tz.now()).before() + return self.filter_qs(query) def get_object_list(self, logs, full=False): """Return diffusions merged to the provided logs iterable. @@ -62,13 +60,31 @@ class LogListView(AttachedToMixin, BaseView, LogListMixin, ListView): `request.GET`, defaults to today).""" redirect_date_url = "log-list" - has_filters = True - attach_to_value = StaticPage.ATTACH_TO_LOGS + date_delta = tz.timedelta(days=7) - def get_date(self): - date = super().get_date() + def get_date(self, param): + date = super().get_date(param) return datetime.date.today() if date is None else date + def get(self, request, **kwargs): + min_date = self.get_date("min_date") + max_date = self.get_date("max_date") + + # ensure right values for min and max + min_date, max_date = min(min_date, max_date), max(min_date, max_date) + + # limit logs list size using date delta + if min_date and max_date: + max_date = min(min_date + self.date_delta, max_date) + elif min_date: + max_date = min_date + self.date_delta + elif max_date: + min_date = max_date - self.date_delta + + self.min_date = min_date + self.max_date = max_date + return super().get(request, **kwargs) + def get_context_data(self, **kwargs): today = datetime.date.today() # `super()...` must be called before updating kwargs, in order @@ -76,6 +92,9 @@ class LogListView(AttachedToMixin, BaseView, LogListMixin, ListView): kwargs = super().get_context_data(**kwargs) kwargs.update( { + "min_date": self.min_date or today, + "max_date": self.max_date or today, + "today": datetime.date.today(), "date": self.date, "dates": (today - datetime.timedelta(days=i) for i in range(0, 7)), "object_list": self.get_object_list(self.object_list), @@ -101,8 +120,8 @@ class LogListAPIView(LogListMixin, BaseAPIView, ListAPIView): def list(self, *args, **kwargs): return super().list(*args, **kwargs) - def get_date(self): - date = super().get_date() + def get_date(self, param): + date = super().get_date(param) if date is None: self.min_date = tz.now() - tz.timedelta(minutes=30) return date diff --git a/aircox/views/mixins.py b/aircox/views/mixins.py index 8d13c28..666ab65 100644 --- a/aircox/views/mixins.py +++ b/aircox/views/mixins.py @@ -12,9 +12,9 @@ class GetDateMixin: date = None redirect_date_url = None - def get_date(self): - date = self.request.GET.get("date") - return str_to_date(date, "-") if date is not None else self.kwargs["date"] if "date" in self.kwargs else None + def get_date(self, param="date"): + date = self.request.GET.get(param) + return str_to_date(date, "-") if date else self.kwargs[param] if param in self.kwargs else None def get(self, *args, **kwargs): if self.redirect_date_url and self.request.GET.get("date"): @@ -23,7 +23,7 @@ class GetDateMixin: date=self.request.GET["date"].replace("-", "/"), ) - self.date = self.get_date() + self.date = self.get_date("date") return super().get(*args, **kwargs) @@ -44,16 +44,16 @@ class ParentMixin: parent = None """Parent page object.""" - def get_parent(self, request, *args, **kwargs): + def get_parent(self, request, **kwargs): if self.parent_model is None or self.parent_url_kwarg not in kwargs: return lookup = {self.parent_field: kwargs[self.parent_url_kwarg]} return get_object_or_404(self.parent_model.objects.select_related("cover"), **lookup) - def get(self, request, *args, **kwargs): - self.parent = self.get_parent(request, *args, **kwargs) - return super().get(request, *args, **kwargs) + def dispatch(self, request, *args, **kwargs): + self.parent = self.get_parent(request, **kwargs) + return super().dispatch(request, *args, **kwargs) def get_queryset(self): if self.parent is not None: @@ -61,9 +61,10 @@ class ParentMixin: return super().get_queryset() def get_context_data(self, **kwargs): - self.parent = kwargs.setdefault("parent", self.parent) - if self.parent is not None: - kwargs.setdefault("cover", self.parent.cover) + parent = kwargs.setdefault("parent", self.parent) + + if parent is not None and parent.cover: + kwargs.setdefault("cover", parent.cover.url) return super().get_context_data(**kwargs) @@ -107,3 +108,42 @@ class FiltersMixin: params = self.request.GET.copy() kwargs["get_params"] = params.pop("page", True) and params return super().get_context_data(**kwargs) + + +class VueFormDataMixin: + """Provide form information as data to be used with vue components.""" + + # Note: values corresponds to AFormSet expected one + + def get_form_items(self, formset): + return [form.initial for form in formset.forms] + + def get_form_field_data(self, form, values=None): + """Return form fields as data.""" + model = form.Meta.model + fields = ((name, field, model._meta.get_field(name)) for name, field in form.base_fields.items()) + return [ + { + "name": name, + "label": str(m_field.verbose_name).capitalize(), + "help": str(m_field.help_text).capitalize(), + "hidden": field.widget.is_hidden, + "value": values and values.get(name), + } + for name, field, m_field in fields + ] + + def get_formset_data(self, formset, field_values=None, **kwargs): + """Return formset as data object.""" + return { + "prefix": formset.prefix, + "management": { + "initial_forms": formset.initial_form_count(), + "min_num_forms": formset.min_num, + "max_num_forms": formset.max_num, + }, + "fields": self.get_form_field_data(formset.form, field_values), + "initial_extra": formset.initial_extra and formset.initial_extra[0], + "initials": self.get_form_items(formset), + **kwargs, + } diff --git a/aircox/views/page.py b/aircox/views/page.py index 7a0ef42..9e68ff8 100644 --- a/aircox/views/page.py +++ b/aircox/views/page.py @@ -1,72 +1,119 @@ -from django.http import Http404, HttpResponse +from django.http import HttpResponse from django.utils.translation import gettext_lazy as _ from django.views.generic import DetailView, ListView +from django.views.generic.edit import CreateView, UpdateView +from django.urls import reverse from honeypot.decorators import check_honeypot +from aircox.conf import settings from ..filters import PageFilters from ..forms import CommentForm -from ..models import Comment -from ..utils import Redirect +from ..models import Comment, Category from .base import BaseView from .mixins import AttachedToMixin, FiltersMixin, ParentMixin __all__ = [ + "attached_views", + "attach", "BasePageListView", "BasePageDetailView", "PageDetailView", "PageListView", + "PageUpdateView", ] -class BasePageListView(AttachedToMixin, ParentMixin, BaseView, ListView): +attached_views = {} +"""Register views by StaticPage.Target.""" + + +def attach(cls): + """Add decorated view class to `attached_views`""" + attached_views[cls.attach_to_value] = cls + return cls + + +class BasePageMixin: + category = None + + def get_queryset(self): + qs = super().get_queryset().select_subclasses().select_related("cover") + if self.request.user.is_authenticated: + return qs + return qs.published() + + def get_category(self, page, **kwargs): + if page: + if getattr(page, "category_id", None): + return page.category + if getattr(page, "parent_id", None): + return self.get_category(page.parent_subclass) + if slug := self.kwargs.get("category_slug"): + return Category.objects.get(slug=slug) + return None + + def get_context_data(self, *args, **kwargs): + kwargs.setdefault("category", self.category) + return super().get_context_data(*args, **kwargs) + + +class BasePageListView(AttachedToMixin, BasePageMixin, ParentMixin, BaseView, ListView): """Base view class for BasePage list.""" template_name = "aircox/basepage_list.html" - item_template_name = "aircox/widgets/page_item.html" - has_sidebar = True paginate_by = 30 has_headline = True + def dispatch(self, request, *args, **kwargs): + return super().dispatch(request, *args, **kwargs) + def get(self, *args, **kwargs): + self.category = self.get_category(self.parent) return super().get(*args, **kwargs) def get_queryset(self): - return super().get_queryset().select_subclasses().published().select_related("cover") + query = super().get_queryset() + if self.category: + query = query.filter(category=self.category) + return query def get_context_data(self, **kwargs): - kwargs.setdefault("item_template_name", self.item_template_name) kwargs.setdefault("has_headline", self.has_headline) - return super().get_context_data(**kwargs) + context = super().get_context_data(**kwargs) + + parent = context.get("parent") + if not context.get("page"): + if not context.get("title"): + model = self.model._meta.verbose_name_plural + title = _("{model}") + context["title"] = title.format(model=model, parent=parent) + + if not context.get("cover") and parent and parent.cover: + context["cover"] = parent.cover.url + + return context -class BasePageDetailView(BaseView, DetailView): +class BasePageDetailView(BasePageMixin, BaseView, DetailView): """Base view class for BasePage.""" - template_name = "aircox/basepage_detail.html" + template_name = "aircox/public.html" context_object_name = "page" - has_filters = False - def get_queryset(self): - return super().get_queryset().select_related("cover") - - # This should not exists: it allows mapping not published pages - # or it should be only used for trashed pages. - def not_published_redirect(self, page): - """When a page is not published, redirect to the returned url instead - of an HTTP 404 code.""" - return None + def get_context_data(self, **kwargs): + if self.object.cover: + kwargs.setdefault("cover", self.object.cover.url) + if self.object.title: + kwargs.setdefault("title", self.object.display_title) + return super().get_context_data(**kwargs) def get_object(self): if getattr(self, "object", None): return self.object obj = super().get_object() - if not obj.is_published: - redirect_url = self.not_published_redirect(obj) - if redirect_url: - raise Redirect(redirect_url) - raise Http404("%s not found" % self.model._meta.verbose_name) + self.category = self.get_category(obj) return obj def get_page(self): @@ -78,7 +125,6 @@ class PageListView(FiltersMixin, BasePageListView): filterset_class = PageFilters template_name = None - has_filters = True categories = None filters = None @@ -92,15 +138,21 @@ class PageListView(FiltersMixin, BasePageListView): def get_queryset(self): qs = super().get_queryset().select_related("category").order_by("-pub_date") + cat_ids = self.model.objects.published().values_list("category_id", flat=True) + self.categories = Category.objects.filter(id__in=cat_ids) return qs - def get_context_data(self, **kwargs): - kwargs["categories"] = ( - self.model.objects.published() - .filter(category__isnull=False) - .values_list("category__title", "category__id") - .distinct() + @classmethod + def get_secondary_nav(cls): + cat_ids = cls.model.objects.published().values_list("category_id", flat=True) + categories = Category.objects.filter(id__in=cat_ids) + return tuple( + (category.title, reverse(cls.model.list_url_name, kwargs={"category_slug": category.slug})) + for category in categories ) + + def get_context_data(self, **kwargs): + kwargs["categories"] = self.categories return super().get_context_data(**kwargs) @@ -109,7 +161,10 @@ class PageDetailView(BasePageDetailView): template_name = None context_object_name = "page" - has_filters = False + + def can_edit(self, object): + """Return True if user can edit current page.""" + return False def get_template_names(self): return super().get_template_names() + ["aircox/page_detail.html"] @@ -118,11 +173,26 @@ class PageDetailView(BasePageDetailView): return super().get_queryset().select_related("category") def get_context_data(self, **kwargs): - if self.object.allow_comments and "comment_form" not in kwargs: - kwargs["comment_form"] = CommentForm() + if "comment_form" not in kwargs: + kwargs["comment_form"] = self.get_comment_form() kwargs["comments"] = Comment.objects.filter(page=self.object).order_by("-date") + + if parent_subclass := getattr(self.object, "parent_subclass", None): + kwargs["parent"] = parent_subclass + + if "related_objects" not in kwargs: + related = self.get_related_queryset() + if related: + related = related[: self.related_count] + kwargs["related_objects"] = related + kwargs["can_edit"] = self.can_edit(self.object) return super().get_context_data(**kwargs) + def get_comment_form(self): + if settings.ALLOW_COMMENTS and self.object.allow_comments: + return CommentForm() + return None + @classmethod def as_view(cls, *args, **kwargs): view = super(PageDetailView, cls).as_view(*args, **kwargs) @@ -138,3 +208,19 @@ class PageDetailView(BasePageDetailView): comment.page = self.object comment.save() return self.get(request, *args, **kwargs) + + +class PageCreateView(BaseView, CreateView): + def get_page(self): + return self.object + + def get_success_url(self): + return self.request.path + + +class PageUpdateView(BaseView, UpdateView): + def get_page(self): + return self.object + + def get_success_url(self): + return self.request.path diff --git a/aircox/views/program.py b/aircox/views/program.py index fb36d7d..5b8882e 100644 --- a/aircox/views/program.py +++ b/aircox/views/program.py @@ -1,60 +1,76 @@ +import random + +from django.contrib.auth.mixins import PermissionRequiredMixin, UserPassesTestMixin from django.urls import reverse -from ..models import Page, Program, StaticPage -from .mixins import ParentMixin -from .page import PageDetailView, PageListView - -__all__ = ["ProgramPageDetailView", "ProgramDetailView", "ProgramPageListView"] +from aircox import models, forms, permissions +from . import page +from .mixins import VueFormDataMixin -class BaseProgramMixin: - def get_program(self): - return self.object +__all__ = ( + "ProgramDetailView", + "ProgramDetailView", + "ProgramCreateView", + "ProgramUpdateView", +) - def get_sidebar_url(self): - return reverse("program-page-list", kwargs={"parent_slug": self.program.slug}) + +class ProgramDetailView(page.PageDetailView): + model = models.Program + + def can_edit(self, obj): + return permissions.program.can(self.request.user, "update", obj) + + def get_related_queryset(self): + queryset = ( + self.get_queryset() + .filter(category_id=self.object.category_id) + .exclude(pk=self.object.pk) + .published() + .order_by("-pub_date")[:50] + ) + return random.sample(list(queryset), min(len(queryset), self.related_count)) + + def get_related_url(self): + return reverse("program-list") + f"?category__id={self.object.category_id}" def get_context_data(self, **kwargs): - self.program = self.get_program() - kwargs["program"] = self.program - return super().get_context_data(**kwargs) + episodes = models.Episode.objects.program(self.object).published().order_by("-pub_date") + podcasts = episodes.with_podcasts() + articles = models.Article.objects.parent(self.object).published().order_by("-pub_date") + return super().get_context_data( + articles=articles[: self.related_count], + episodes=episodes[: self.related_count], + podcasts=podcasts[: self.related_count], + **kwargs, + ) + + def get_template_names(self): + return super().get_template_names() + ["aircox/program_detail.html"] -class ProgramDetailView(BaseProgramMixin, PageDetailView): - model = Program +@page.attach +class ProgramListView(page.PageListView): + model = models.Program + attach_to_value = models.StaticPage.Target.PROGRAMS - def get_sidebar_queryset(self): - return super().get_sidebar_queryset().filter(parent=self.program) + def get_queryset(self): + return super().get_queryset().order_by("title") -class ProgramListView(PageListView): - model = Program - attach_to_value = StaticPage.ATTACH_TO_PROGRAMS +class ProgramEditMixin(VueFormDataMixin): + model = models.Program + form_class = forms.ProgramForm + queryset = models.Program.objects.select_related("editors_group") -# FIXME: not used -class ProgramPageDetailView(BaseProgramMixin, ParentMixin, PageDetailView): - """Base view class for a page that is displayed as a program's child - page.""" - - parent_model = Program - - def get_program(self): - self.parent = self.object.program - return self.object.program - - def get_sidebar_queryset(self): - return super().get_sidebar_queryset().filter(parent=self.program) +# FIXME: not used as long there is no complete administration mgt (schedule, etc.) +class ProgramCreateView(PermissionRequiredMixin, ProgramEditMixin, page.PageCreateView): + permission_required = "aircox.add_program" -class ProgramPageListView(BaseProgramMixin, PageListView): - model = Page - parent_model = Program - queryset = Page.objects.select_subclasses() - - def get_program(self): - return self.parent - - def get_context_data(self, **kwargs): - kwargs.setdefault("sidebar_url_parent", None) - return super().get_context_data(**kwargs) +class ProgramUpdateView(UserPassesTestMixin, ProgramEditMixin, page.PageUpdateView): + def test_func(self): + obj = self.get_object() + return permissions.program.can(self.request.user, "update", obj) diff --git a/aircox/viewsets.py b/aircox/viewsets.py index d1ac7fa..4ca3442 100644 --- a/aircox/viewsets.py +++ b/aircox/viewsets.py @@ -1,54 +1,34 @@ -from django_filters import rest_framework as filters -from rest_framework import status, viewsets +from django.contrib.auth.models import User, Group +from django_filters import rest_framework as drf_filters +from rest_framework import status, viewsets, parsers, permissions from rest_framework.decorators import action -from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from .models import Sound, Track -from .serializers import SoundSerializer, admin +from filer.models.imagemodels import Image + +from . import models, forms, filters, serializers from .views import BaseAPIView + __all__ = ( - "SoundFilter", + "ImageViewSet", "SoundViewSet", - "TrackFilter", "TrackROViewSet", + "UserGroupViewSet", "UserSettingsViewSet", ) -class SoundFilter(filters.FilterSet): - station = filters.NumberFilter(field_name="program__station__id") - program = filters.NumberFilter(field_name="program_id") - episode = filters.NumberFilter(field_name="episode_id") - search = filters.CharFilter(field_name="search", method="search_filter") +class AutocompleteMixin: + """Based on provided filterset and serializer, add an "autocomplete" action + to the viewset. - def search_filter(self, queryset, name, value): - return queryset.search(value) + Url ``GET`` parameters: + - `field` (many): if provided, only return provided field names + - filterset's lookups. - -class SoundViewSet(BaseAPIView, viewsets.ModelViewSet): - serializer_class = SoundSerializer - queryset = Sound.objects.available().order_by("-pk") - filter_backends = (filters.DjangoFilterBackend,) - filterset_class = SoundFilter - - -# --- admin -class TrackFilter(filters.FilterSet): - artist = filters.CharFilter(field_name="artist", lookup_expr="icontains") - album = filters.CharFilter(field_name="album", lookup_expr="icontains") - title = filters.CharFilter(field_name="title", lookup_expr="icontains") - - -class TrackROViewSet(viewsets.ReadOnlyModelViewSet): - """Track viewset used for auto completion.""" - - serializer_class = admin.TrackSerializer - permission_classes = [IsAuthenticated] - filter_backends = (filters.DjangoFilterBackend,) - filterset_class = TrackFilter - queryset = Track.objects.all() + Return a list of values if ``field`` is provided, result of `list()` otherwise. + """ @action(name="autocomplete", detail=False) def autocomplete(self, request): @@ -56,18 +36,138 @@ class TrackROViewSet(viewsets.ReadOnlyModelViewSet): if field: queryset = self.filter_queryset(self.get_queryset()) values = queryset.values_list(field, flat=True).distinct() - return Response(values) + return Response(values[:10]) return self.list(request) +class ListCommitMixin: + @action(name="commit", detail=False, methods=["POST"]) + def commit(self, request): + """ + Data: + { + "delete": [pk], + "update": [{pk, **object}], + "create": [object_data] + } + + Return: + { + "deleted": [pk], + "updated": [object], + "created": [object], + } + """ + queryset = self.get_queryset() + resp = {"deleted": [], "updated": [], "created": []} + if ids := request.data.get("delete"): + q = queryset.filter(id__in=ids) + resp["deleted"] = list(q.values_list("id", flat=True)) + q.delete() + + # TODO: bulk save and update + if items := request.data.get("update"): + resp["updated"] = self._commit_save_many(items) + + if items := request.data.get("create"): + resp["created"] = self._commit_save_many(items) + + return Response(data=resp) + + def _commit_save_many(self, data): + ser = self.get_serializer(data=data, many=True) + ser.is_valid(raise_exception=True) + + items = ser.save() + ser = self.get_serializer(items, many=True) + return ser.data + + +class ImageViewSet(viewsets.ModelViewSet): + parsers = (parsers.MultiPartParser,) + permissions = (permissions.IsAuthenticatedOrReadOnly,) + serializer_class = serializers.admin.ImageSerializer + queryset = Image.objects.all().order_by("-uploaded_at") + filter_backends = (drf_filters.DjangoFilterBackend,) + filterset_class = filters.ImageFilterSet + + def create(self, request, **kwargs): + # FIXME: to be replaced by regular DRF + form = forms.ImageForm(request.POST, request.FILES) + if form.is_valid(): + file = form.cleaned_data["file"] + Image.objects.create(original_filename=file.name, file=file) + return Response({"status": "ok"}) + return Response({"status": "error", "errors": form.errors}) + + +class SoundViewSet(BaseAPIView, viewsets.ModelViewSet): + parsers = (parsers.MultiPartParser,) + permissions = (permissions.IsAuthenticatedOrReadOnly,) + serializer_class = serializers.SoundSerializer + queryset = models.Sound.objects.order_by("-pk") + filter_backends = (drf_filters.DjangoFilterBackend,) + filterset_class = filters.SoundFilterSet + + def perform_create(self, serializer): + obj = serializer.save() + # FIXME: hack to avoid "TYPE_REMOVED" status + # -> file is saved to fs after object is saved to db + obj.save() + + def get_queryset(self): + query = super().get_queryset() + if not self.request.user.is_authenticated: + return query.available() + return query + + +class TrackROViewSet(AutocompleteMixin, viewsets.ReadOnlyModelViewSet): + """Track viewset used for auto completion.""" + + serializer_class = serializers.admin.TrackSerializer + permission_classes = (permissions.IsAuthenticated,) + filterset_class = filters.TrackFilterSet + queryset = models.Track.objects.all() + + +class CommentViewSet(viewsets.ModelViewSet): + serializer_class = serializers.CommentSerializer + permission_classes = (permissions.IsAuthenticatedOrReadOnly,) + queryset = models.Comment.objects.all() + + +# --- admin +class UserViewSet(AutocompleteMixin, viewsets.ModelViewSet): + serializer_class = serializers.auth.UserSerializer + permission_classes = (permissions.IsAdminUser,) + filterset_class = filters.UserFilterSet + queryset = User.objects.all().distinct().order_by("username") + + +class GroupViewSet(AutocompleteMixin, viewsets.ModelViewSet): + serializer_class = serializers.auth.GroupSerializer + permission_classes = (permissions.IsAdminUser,) + filterset_class = filters.GroupFilterSet + queryset = Group.objects.all().distinct().order_by("name") + + +class UserGroupViewSet(ListCommitMixin, viewsets.ModelViewSet): + serializer_class = serializers.auth.UserGroupSerializer + permission_classes = (permissions.IsAdminUser,) + filterset_class = filters.UserGroupFilterSet + model = User.groups.through + queryset = model.objects.all().distinct().order_by("user__username") + + class UserSettingsViewSet(viewsets.ViewSet): """User's settings specific to aircox. Allow only to create and edit user's own settings. """ - serializer_class = admin.UserSettingsSerializer - permission_classes = [IsAuthenticated] + serializer_class = serializers.UserSettingsSerializer + permission_classes = (permissions.IsAuthenticated,) def get_serializer(self, instance=None, **kwargs): return self.serializer_class(instance=instance, context={"user": self.request.user}, **kwargs) diff --git a/aircox_streamer/conf.py b/aircox_streamer/conf.py new file mode 100644 index 0000000..9877db4 --- /dev/null +++ b/aircox_streamer/conf.py @@ -0,0 +1,19 @@ +import os +import tempfile + +from aircox.conf import BaseSettings + + +__all__ = ("Settings", "settings") + + +class Settings(BaseSettings): + WORKING_DIR = os.path.join(tempfile.gettempdir(), "aircox") + """Parent working directory for all stations.""" + + def get_dir(self, station, *paths): + """Return working directory for the provided station.""" + return os.path.join(self.WORKING_DIR, station.slug.replace("-", "_"), *paths) + + +settings = Settings("AIRCOX_STREAMER") diff --git a/aircox_streamer/connector.py b/aircox_streamer/connector.py index ef04b28..37382d6 100755 --- a/aircox_streamer/connector.py +++ b/aircox_streamer/connector.py @@ -70,14 +70,14 @@ class Connector: data = bytes("".join([str(d) for d in data]) + "\n", encoding="utf-8") try: self.socket.sendall(data) - data = "" - while not response_re.search(data): - data += self.socket.recv(1024).decode("utf-8") + resp = "" + while not response_re.search(resp): + resp += self.socket.recv(1024).decode("utf-8") - if data: - data = response_re.sub(r"\1", data).strip() - data = self.parse(data) if parse else self.parse_json(data) if parse_json else data - return data + if resp: + resp = response_re.sub(r"\1", resp).strip() + resp = self.parse(resp) if parse else self.parse_json(resp) if parse_json else resp + return resp except Exception: self.close() if try_count > 0: diff --git a/aircox_streamer/controllers/metadata.py b/aircox_streamer/controllers/metadata.py index 6c3ddab..112db44 100755 --- a/aircox_streamer/controllers/metadata.py +++ b/aircox_streamer/controllers/metadata.py @@ -77,9 +77,12 @@ class Metadata: air_time = tz.datetime.strptime(air_time, "%Y/%m/%d %H:%M:%S") return local_tz.localize(air_time) - def validate(self, data): + def validate(self, data, as_dict=False): """Validate provided data and set as attribute (must already be declared)""" + if as_dict and isinstance(data, list): + data = {v[0]: v[1] for v in data} + for key, value in data.items(): if hasattr(self, key) and not callable(getattr(self, key)): setattr(self, key, value) diff --git a/aircox_streamer/controllers/monitor.py b/aircox_streamer/controllers/monitor.py index 605401e..34f26d0 100644 --- a/aircox_streamer/controllers/monitor.py +++ b/aircox_streamer/controllers/monitor.py @@ -133,8 +133,10 @@ class Monitor: # get sound diff = None sound = Sound.objects.path(air_uri).first() - if sound and sound.episode_id is not None: - diff = Diffusion.objects.episode(id=sound.episode_id).on_air().now(air_time).first() + if sound: + ids = sound.episodesound_set.values_list("episode_id", flat=True) + if ids: + diff = Diffusion.objects.filter(episode_id__in=ids).on_air().now(air_time).first() # log sound on air return self.log( @@ -198,7 +200,7 @@ class Monitor: Diffusion.objects.station(self.station) .on_air() .now(now) - .filter(episode__sound__type=Sound.TYPE_ARCHIVE) + .filter(episode__episodesound__broadcast=True) .first() ) # Can't use delay: diffusion may start later than its assigned start. @@ -227,7 +229,7 @@ class Monitor: return log def start_diff(self, source, diff): - playlist = Sound.objects.episode(id=diff.episode_id).playlist() + playlist = diff.episode.episodesound_set.all().broadcast().playlist() source.push(*playlist) self.log( type=Log.TYPE_START, diff --git a/aircox_streamer/controllers/sources.py b/aircox_streamer/controllers/sources.py index dc398a6..7993502 100755 --- a/aircox_streamer/controllers/sources.py +++ b/aircox_streamer/controllers/sources.py @@ -3,6 +3,7 @@ import tzlocal from aircox.utils import to_seconds +from ..conf import settings from .metadata import Metadata, Request @@ -43,9 +44,9 @@ class Source(Metadata): except ValueError: self.remaining = None - data = self.controller.send(self.id, ".get", parse=True) + data = self.controller.send(f"var.get {self.id}_meta", parse_json=True) if data: - self.validate(data if data and isinstance(data, dict) else {}) + self.validate(data if data and isinstance(data, (dict, list)) else {}, as_dict=True) def skip(self): """Skip the current source sound.""" @@ -76,11 +77,11 @@ class PlaylistSource(Source): self.program = program super().__init__(controller, id=id, **kwargs) - self.path = os.path.join(self.station.path, f"{self.id}.m3u") + self.path = settings.get_dir(self.station, f"{self.id}.m3u") def get_sound_queryset(self): """Get playlist's sounds queryset.""" - return self.program.sound_set.archive() + return self.program.sound_set.broadcast() def get_playlist(self): """Get playlist from db.""" @@ -88,6 +89,7 @@ class PlaylistSource(Source): def write_playlist(self, playlist=[]): """Write playlist to file.""" + playlist = playlist or self.get_playlist() os.makedirs(os.path.dirname(self.path), exist_ok=True) with open(self.path, "w") as file: file.write("\n".join(playlist or [])) @@ -129,7 +131,7 @@ class QueueSource(Source): def push(self, *paths): """Add the provided paths to source's play queue.""" for path in paths: - self.controller.send(f"{self.id}_queue.push {path}") + print(self.controller.send(f"{self.id}_queue.push {path}")) def fetch(self): super().fetch() diff --git a/aircox_streamer/controllers/streamer.py b/aircox_streamer/controllers/streamer.py index 5d19101..512b36e 100755 --- a/aircox_streamer/controllers/streamer.py +++ b/aircox_streamer/controllers/streamer.py @@ -8,8 +8,7 @@ import subprocess import psutil from django.template.loader import render_to_string -from aircox.conf import settings - +from ..conf import settings from ..connector import Connector from .sources import PlaylistSource, QueueSource @@ -46,8 +45,8 @@ class Streamer: self.outputs = self.station.port_set.active().output() self.id = self.station.slug.replace("-", "_") - self.path = os.path.join(station.path, "station.liq") - self.connector = connector or Connector(os.path.join(station.path, "station.sock")) + self.path = settings.get_dir(station, "station.liq") + self.connector = connector or Connector(settings.get_dir(station, "station.sock")) self.init_sources() @property @@ -96,9 +95,10 @@ class Streamer: data = render_to_string( self.template_name, { + "dir": settings.get_dir(self.station), + "log_file": settings.get_dir(self.station, "liquidsoap.log"), "station": self.station, "streamer": self, - "settings": settings, }, ) data = re.sub("[\t ]+\n", "\n", data) diff --git a/aircox_streamer/locale/fr/LC_MESSAGES/django.mo b/aircox_streamer/locale/fr/LC_MESSAGES/django.mo index 105f720a04d701be81e580be6e1073ae54811101..a0e02973fcece29d156a201d72aa95add211ab44 100644 GIT binary patch delta 508 zcmYk&KTE?<6o&EBm{x18e_K%MP!w7x9UPpciXbS6PT~^mMG7XBB<<Qo2Nw}iCp)<n z#|{pHOK0bTI5;@^A$(43!2`*U+;Go*&mCksnfsmAl!z28krb}sIBsAbw{RK{a2C(d zMi=MM#~Hjv-QOPih!cE2VFsUZ1z#|YAGn<m`L?7LA6TO69zWJm4{V|~zQF~2K;76y z4cNm1zG4msxP(8bK~hPPMa<$dmQdF#L-$bw9wl2MdIP5{l<)$xc!vaxYo52RjY@;* z^rPa;xCcYUO~-JOM}uqWKpIkSLPO|H(*CdXa4nrG9la+G?cHiEj5e)iuHNv^jU0zg z*a*Nkj%#{v)@Ne%#JE>Mv08S#(0HDa{w;fqU%V{$yKXHAYIQIAvOD>I9l}uuKWZl{ Ezi<~nX8-^I delta 567 zcmZwDy-Ncz7zXgSXX{th`c<e@Fo@_Mp$O8R4x)>bix7H23T^Jl<y;(!4uZG{hal3) zsRhBoU4%}8&Mq#_4h~LkK5r;a29Dn)mpt#AYv<qcm(^CDh{_((EL?$ua19Q@3M|5H zH~}S`gb~cb6F3FWU;$o3KHq`7?;djg6I_HZa0GtAjSSI`M=e)q6=_@eP=Q?N0&?(8 z-#f?wkB|dCAUF64%kUfW1qwN$Ik*J5k#$&tKAeXK{q<uwjrnP=MKp$&D=aL-TR0A% zAsTdpH*!<|4Q>*(-5FfUH8%kH)-!mx32u~|<2pR}&mi9YvA@M(ER3eFncY%VS}Ec% zF*QYgP`ARUhO{b37rdjQl{SfK&l)A0%7~p%*KPRF_1-g^z7|qjrL_`)ijq3Exe&}m z5Nh4)D(amh5a$|7L?%2mve{Ik78;}CDAWPYr=4uO#8oW0aFrMih+V~WKUet$7KK-` diff --git a/aircox_streamer/locale/fr/LC_MESSAGES/django.po b/aircox_streamer/locale/fr/LC_MESSAGES/django.po index 749829c..2d61cdf 100644 --- a/aircox_streamer/locale/fr/LC_MESSAGES/django.po +++ b/aircox_streamer/locale/fr/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-09-12 18:48+0000\n" +"POT-Creation-Date: 2024-04-28 18:19+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Language-Team: LANGUAGE <LL@li.org>\n" @@ -18,85 +18,85 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" -#: aircox_streamer/templates/aircox_streamer/source_item.html:18 -msgid "Edit related program" -msgstr "Éditer le programme correspondant" +#: templates/aircox/widgets/nav.html:7 views.py:10 +msgid "Streamer" +msgstr "Streamer" -#: aircox_streamer/templates/aircox_streamer/source_item.html:27 -msgid "Synchronize source with Liquidsoap" -msgstr "Synchroniser la source avec Liquidsoap" +#: templates/aircox_streamer/source_item.html:19 +msgid "Edit program" +msgstr "Éditer l'émission" -#: aircox_streamer/templates/aircox_streamer/source_item.html:31 -msgid "Synchronise" -msgstr "Synchroniser" - -#: aircox_streamer/templates/aircox_streamer/source_item.html:34 -msgid "Restart current track" -msgstr "Rejouer le morceau en cours" - -#: aircox_streamer/templates/aircox_streamer/source_item.html:38 -msgid "Restart" -msgstr "Rejouer" - -#: aircox_streamer/templates/aircox_streamer/source_item.html:41 -msgid "Skip current file" -msgstr "Passer le fichier actuel" - -#: aircox_streamer/templates/aircox_streamer/source_item.html:42 -msgid "Skip" -msgstr "Passer" - -#: aircox_streamer/templates/aircox_streamer/source_item.html:51 -msgid "Add sound" -msgstr "Ajouter un son" - -#: aircox_streamer/templates/aircox_streamer/source_item.html:58 -msgid "Select a sound" -msgstr "Sélectionner un son" - -#: aircox_streamer/templates/aircox_streamer/source_item.html:69 -msgid "Add" -msgstr "Ajouter" - -#: aircox_streamer/templates/aircox_streamer/source_item.html:74 -msgid "Add a sound to the queue (queue may start playing)" -msgstr "Ajouter un son à la file de lecture (la file de lecture peut démarer)" - -#: aircox_streamer/templates/aircox_streamer/source_item.html:80 -msgid "Sounds in queue" -msgstr "Sons dans la file de lecture" - -#: aircox_streamer/templates/aircox_streamer/source_item.html:98 +#: templates/aircox_streamer/source_item.html:29 msgid "Status" msgstr "Statut" -#: aircox_streamer/templates/aircox_streamer/source_item.html:108 +#: templates/aircox_streamer/source_item.html:39 msgid "Air time" -msgstr "En antenne depuis" +msgstr "Temps d'antenne" -#: aircox_streamer/templates/aircox_streamer/source_item.html:118 +#: templates/aircox_streamer/source_item.html:49 msgid "Time left" msgstr "Temps restant" -#: aircox_streamer/templates/aircox_streamer/source_item.html:126 -msgid "Data source" -msgstr "Source de donnée" +#: templates/aircox_streamer/source_item.html:57 +msgid "Source" +msgstr "Source" -#: aircox_streamer/templates/aircox_streamer/streamer.html:23 +#: templates/aircox_streamer/source_item.html:70 +msgid "Restart current track" +msgstr "Rejouer le morceau en cours" + +#: templates/aircox_streamer/source_item.html:74 +msgid "Restart" +msgstr "Rejouer" + +#: templates/aircox_streamer/source_item.html:77 +msgid "Skip current file" +msgstr "Passer le fichier actuel" + +#: templates/aircox_streamer/source_item.html:78 +msgid "Skip" +msgstr "Passer" + +#: templates/aircox_streamer/source_item.html:84 +msgid "Synchronize source with Liquidsoap" +msgstr "Synchroniser la source avec Liquidsoap" + +#: templates/aircox_streamer/source_item.html:88 +msgid "Synchronise" +msgstr "Synchroniser" + +#: templates/aircox_streamer/source_item.html:94 +msgid "Add sound" +msgstr "Ajouter un son" + +#: templates/aircox_streamer/source_item.html:101 +msgid "Select a sound" +msgstr "Sélectionner un son" + +#: templates/aircox_streamer/source_item.html:112 +msgid "Add" +msgstr "Ajouter" + +#: templates/aircox_streamer/source_item.html:117 +msgid "Add a sound to the queue (queue may start playing)" +msgstr "Ajouter un son à la file de lecture (la file de lecture peut démarer)" + +#: templates/aircox_streamer/source_item.html:123 +msgid "Sounds in queue" +msgstr "Sons dans la file de lecture" + +#: templates/aircox_streamer/streamer.html:19 msgid "Reload" msgstr "Recharger" -#: aircox_streamer/templates/aircox_streamer/streamer.html:30 -#: aircox_streamer/templates/aircox_streamer/streamer.html:31 +#: templates/aircox_streamer/streamer.html:26 +#: templates/aircox_streamer/streamer.html:27 msgid "Select a station" msgstr "Sélectionner une station" -#: aircox_streamer/urls.py:13 aircox_streamer/views.py:10 -msgid "Streamer Monitor" -msgstr "Moniteur de stream" - #~ msgid "playing" -#~ msgstr "en cours de lecture" +#~ msgstr "lecture" #~ msgid "paused" #~ msgstr "pause" diff --git a/aircox_streamer/serializers.py b/aircox_streamer/serializers.py index ac3a2a3..0822fa2 100644 --- a/aircox_streamer/serializers.py +++ b/aircox_streamer/serializers.py @@ -53,13 +53,13 @@ class SourceSerializer(MetadataSerializer): class PlaylistSerializer(SourceSerializer): program = serializers.CharField(source="program.id") - url_name = "admin:api:streamer-playlist-detail" + url_name = "streamer:api:streamer-playlist-detail" class QueueSourceSerializer(SourceSerializer): queue = serializers.ListField(child=RequestSerializer(), source="requests") - url_name = "admin:api:streamer-queue-detail" + url_name = "streamer:api:streamer-queue-detail" class StreamerSerializer(BaseSerializer): @@ -69,7 +69,7 @@ class StreamerSerializer(BaseSerializer): playlists = serializers.ListField(child=PlaylistSerializer()) queues = serializers.ListField(child=QueueSourceSerializer()) - url_name = "admin:api:streamer-detail" + url_name = "streamer:api:streamer-detail" def get_url(self, obj, **kwargs): kwargs["pk"] = obj.station.pk diff --git a/aircox_streamer/templates/aircox/widgets/nav.html b/aircox_streamer/templates/aircox/widgets/nav.html new file mode 100644 index 0000000..3676c51 --- /dev/null +++ b/aircox_streamer/templates/aircox/widgets/nav.html @@ -0,0 +1,9 @@ +{% extends "aircox/widgets/nav.html" %} +{% load i18n %} + +{% block admin-menu %} +{{ block.super }} +<a class="dropdown-item" href="{% url "streamer:dashboard-streamer" %}"> + {% translate "Streamer" %} +</a> +{% endblock %} diff --git a/aircox_streamer/templates/aircox_streamer/scripts/station.liq b/aircox_streamer/templates/aircox_streamer/scripts/station.liq index 26ac7dd..e0f3650 100755 --- a/aircox_streamer/templates/aircox_streamer/scripts/station.liq +++ b/aircox_streamer/templates/aircox_streamer/scripts/station.liq @@ -10,16 +10,16 @@ Base liquidsoap station configuration. {% block functions %} {# Seek function #} -def seek(source, t) = +def seek(s, t) = t = float_of_string(default=0.,t) - ret = source.seek(source,t) + ret = source.seek(s,t) log("seek #{ret} seconds.") "#{ret}" end {# Transition to live sources #} def to_live(stream, live) - stream = fade.final(duration=2., type='log', stream) + stream = fade.out(duration=2., type='log', stream) live = fade.initial(duration=2., type='log', live) add(normalize=false, [stream,live]) end @@ -30,6 +30,17 @@ def to_stream(live, stream) add(normalize=false, [live,stream]) end +{# Skip command #} +def add_skip_command(id, s) = + def skip(_) = + source.skip(s) + "Done!" + end + server.register(namespace=id, + usage="skip", + description="Skip the current song.", + "skip",skip) +end {% comment %} An interactive source is a source that: @@ -45,10 +56,13 @@ def interactive (id, s) = server.register(namespace=id, description="Get source's track remaining time", usage="remaining", - "remaining", fun (_) -> begin json_of(source.remaining(s)) end) + "remaining", fun (_) -> begin json.stringify(source.remaining(s)) end) + + add_skip_command(id, s) + + s_meta = interactive.string("#{id}_meta", "") + s = source.on_metadata(s, fun(meta) -> s_meta.set(json.stringify(meta))) - s = store_metadata(id=id, size=1, s) - add_skip_command(s) s end @@ -65,15 +79,9 @@ end {% block config %} set("server.socket", true) set("server.socket.path", "{{ streamer.socket_path }}") -set("log.file.path", "{{ station.path }}/liquidsoap.log") -{% for key, value in settings.AIRCOX_LIQUIDSOAP_SET.items %} -set("{{ key|safe }}", {{ value|safe }}) -{% endfor %} +set("log.file.path", "{{ log_file }}") {% endblock %} - -{% block config_extras %} -{% endblock %} - +{% block config_extras %}{% endblock %} {% block sources %} {% with source=streamer.dealer %} @@ -82,7 +90,6 @@ live = audio_to_stereo(interactive('{{ source.id }}', )) {% endwith %} - streams = rotate(id="streams", [ {% for source in streamer.sources %} {% if source != streamer.dealer %} diff --git a/aircox_streamer/templates/aircox_streamer/source_item.html b/aircox_streamer/templates/aircox_streamer/source_item.html old mode 100644 new mode 100755 index bbeadf2..0355a27 --- a/aircox_streamer/templates/aircox_streamer/source_item.html +++ b/aircox_streamer/templates/aircox_streamer/source_item.html @@ -1,8 +1,9 @@ {% comment %}List item for a source.{% endcomment %} {% load i18n %} -<section class="box"><div class="columns is-desktop"> - <div class="column"> +<section class="box"><div class="flex-row gap-3"> + + <div class="flex-grow-1"> <h5 class='title is-5' :class="{'has-text-danger': source.isPlaying, 'has-text-warning': source.isPaused}"> <span> <span v-if="source.isPlaying" class="fas fa-play"></span> @@ -13,36 +14,78 @@ <small v-if="source.isPaused || source.isPlaying">([[ source.remainingString ]])</small> <a v-if="source.data.program !== undefined" - :href="'{% url 'admin:aircox_program_change' "$$" %}'.replace('$$', source.data.program)" - title="{% translate "Edit related program" %}"> + :href="'{% url 'aircox:program_edit' "$$" %}'.replace('$$', source.data.program)" + title="{% translate "Edit program" %}"> <span class="icon"> <span class="fas fa-edit"></span> </span> </a> </h5> + <table class="table bg-transparent"> + <tbody> + <tr><th class="has-text-right ws-nowrap"> + {% translate "Status" %} + </th> + <td :class="{'has-text-danger': source.isPlaying, 'has-text-warning': source.isPaused}"> + <span v-if="source.isPlaying" class="fas fa-play"></span> + <span v-else-if="source.data.status" class="fas fa-pause"></span> + [[ source.data.status_verbose || "—" ]] + </td> + </tr> + <tr v-if="source.data.air_time"> + <th class="has-text-right ws-nowrap"> + {% translate "Air time" %} + </th><td> + <span class="far fa-clock"></span> + <time :datetime="source.date"> + [[ source.data.air_time.toLocaleDateString() ]], + [[ source.data.air_time.toLocaleTimeString() ]] + </time> + </td> + <tr v-if="source.remaining"> + <th class="has-text-right ws-nowrap"> + {% translate "Time left" %} + </th><td> + <span class="far fa-hourglass"></span> + [[ source.remainingString ]] + </td> + </tr> + <tr v-if="source.data.uri"> + <th class="has-text-right ws-nowrap"> + {% translate "Source" %} + </th><td> + <span class="far fa-play-circle"></span> + <template v-if="source.data.uri.length > 64">...</template>[[ (source.data.uri && source.data.uri.slice(-64)) || '—' ]] + </td> + </tr> + </tbody> + </table> + </div> + + <div> <div> - <button class="button" @click="source.sync()" - title="{% translate "Synchronize source with Liquidsoap" %}"> - <span class="icon is-small"> - <span class="fas fa-sync"></span> - </span> - <span>{% translate "Synchronise" %}</span> - </button> - <button class="button" @click="source.restart()" + <button class="button smaller mr-2 mb-2" @click="source.restart()" title="{% translate "Restart current track" %}"> <span class="icon is-small"> <span class="fas fa-step-backward"></span> </span> <span>{% translate "Restart" %}</span> </button> - <button class="button" @click="source.skip()" + <button class="button smaller mr-2 mb-2" @click="source.skip()" title="{% translate "Skip current file" %}"> <span>{% translate "Skip" %}</span> <span class="icon is-small"> <span class="fas fa-step-forward"></span> </span> </button> + <button class="button smaller mr-2 mb-2" @click="source.sync()" + title="{% translate "Synchronize source with Liquidsoap" %}"> + <span class="icon is-small"> + <span class="fas fa-sync"></span> + </span> + <span>{% translate "Synchronise" %}</span> + </button> </div> <div v-if="source.isQueue"> @@ -89,46 +132,4 @@ </div> </div> - <div class="column is-two-fifths"> - <h6 class="subtitle is-6 is-marginless">Metadata</h6> - <table class="table has-background-transparent"> - <tbody> - <tr><th class="has-text-right has-text-nowrap"> - {% translate "Status" %} - </th> - <td :class="{'has-text-danger': source.isPlaying, 'has-text-warning': source.isPaused}"> - <span v-if="source.isPlaying" class="fas fa-play"></span> - <span v-else-if="source.data.status" class="fas fa-pause"></span> - [[ source.data.status_verbose || "—" ]] - </td> - </tr> - <tr v-if="source.data.air_time"> - <th class="has-text-right has-text-nowrap"> - {% translate "Air time" %} - </th><td> - <span class="far fa-clock"></span> - <time :datetime="source.date"> - [[ source.data.air_time.toLocaleDateString() ]], - [[ source.data.air_time.toLocaleTimeString() ]] - </time> - </td> - <tr v-if="source.remaining"> - <th class="has-text-right has-text-nowrap"> - {% translate "Time left" %} - </th><td> - <span class="far fa-hourglass"></span> - [[ source.remainingString ]] - </td> - </tr> - <tr v-if="source.data.uri"> - <th class="has-text-right has-text-nowrap"> - {% translate "Data source" %} - </th><td> - <span class="far fa-play-circle"></span> - <template v-if="source.data.uri.length > 64">...</template>[[ (source.data.uri && source.data.uri.slice(-64)) || '—' ]] - </td> - </tr> - </tbody> - </table> - </div> </div></section> diff --git a/aircox_streamer/templates/aircox_streamer/streamer.html b/aircox_streamer/templates/aircox_streamer/streamer.html index a2ee224..c142687 100644 --- a/aircox_streamer/templates/aircox_streamer/streamer.html +++ b/aircox_streamer/templates/aircox_streamer/streamer.html @@ -1,16 +1,13 @@ -{% extends "admin/base_site.html" %} -{% comment %}Admin tools used to manage the streamer.{% endcomment %} +{% extends "aircox/dashboard/base.html" %} {% load i18n static %} -{% block init-scripts %} -aircox.init({apiUrl: "{% url "admin:api:streamer-list" %}"}, - {config: window.StreamerApp}) -{% endblock %} -{% block content %} +{% block title %}{% translate "Streamer" %}{% endblock %} + +{% block content-container %} {{ block.super }} -<div id="app"> - <a-streamer api-url="{% url "admin:api:streamer-list" %}"> +<div class="container"> + <a-streamer api-url="{% url "streamer:api:streamer-list" %}"> <template v-slot="{streamer,streamers,sources,fetchStreamers,Sound}"> <div class="navbar toolbar"> <div class="navbar-start"> diff --git a/aircox_streamer/tests/conftest.py b/aircox_streamer/tests/conftest.py index 992daac..73a8249 100644 --- a/aircox_streamer/tests/conftest.py +++ b/aircox_streamer/tests/conftest.py @@ -66,7 +66,7 @@ class FakeSocket: # -- models @pytest.fixture def station(): - station = models.Station(name="test", path=working_dir, default=True, active=True) + station = models.Station(name="test", default=True, active=True) station.save() return station @@ -77,7 +77,6 @@ def stations(station): models.Station( name=f"test-{i}", slug=f"test-{i}", - path=working_dir, default=(i == 0), active=True, ) @@ -146,24 +145,28 @@ def episode(program): def sound(program, episode): sound = models.Sound( program=program, - episode=episode, name="sound", - type=models.Sound.TYPE_ARCHIVE, - position=0, + broadcast=True, file="sound.mp3", ) - sound.save(check=False) + sound.save(sync=False) return sound +@pytest.fixture +def episode_sound(episode, sound): + obj = models.EpisodeSound(episode=episode, sound=sound, position=0, broadcast=sound.broadcast) + obj.save() + return obj + + @pytest.fixture def sounds(program): items = [ models.Sound( name=f"sound {i}", program=program, - type=models.Sound.TYPE_ARCHIVE, - position=i, + broadcast=True, file=f"sound-{i}.mp3", ) for i in range(0, 3) diff --git a/aircox_streamer/tests/test_controllers_monitor.py b/aircox_streamer/tests/test_controllers_monitor.py index f479765..4cef699 100644 --- a/aircox_streamer/tests/test_controllers_monitor.py +++ b/aircox_streamer/tests/test_controllers_monitor.py @@ -20,7 +20,7 @@ def monitor(streamer): @pytest.fixture -def diffusion(program, episode, sound): +def diffusion(program, episode, episode_sound): return baker.make( models.Diffusion, program=program, @@ -33,10 +33,10 @@ def diffusion(program, episode, sound): @pytest.fixture -def source(monitor, streamer, sound, diffusion): +def source(monitor, streamer, episode_sound, diffusion): source = next(monitor.streamer.playlists) - source.uri = sound.file.path - source.episode_id = sound.episode_id + source.uri = episode_sound.sound.file.path + source.episode_id = episode_sound.episode_id source.air_time = diffusion.start + tz.timedelta(seconds=10) return source @@ -185,7 +185,7 @@ class TestMonitor: monitor.trace_tracks(log) @pytest.mark.django_db(transaction=True) - def test_handle_diffusions(self, monitor, streamer, diffusion, sound): + def test_handle_diffusions(self, monitor, streamer, diffusion, episode_sound): interface( monitor, { diff --git a/aircox_streamer/tests/test_controllers_sources.py b/aircox_streamer/tests/test_controllers_sources.py index f620c47..be27446 100644 --- a/aircox_streamer/tests/test_controllers_sources.py +++ b/aircox_streamer/tests/test_controllers_sources.py @@ -67,7 +67,7 @@ class TestPlaylistSource: @pytest.mark.django_db def test_get_sound_queryset(self, playlist_source, sounds): query = playlist_source.get_sound_queryset() - assert all(r.program_id == playlist_source.program.pk and r.type == r.TYPE_ARCHIVE for r in query) + assert all(r.program_id == playlist_source.program.pk and r.broadcast for r in query) @pytest.mark.django_db def test_get_playlist(self, playlist_source, sounds): diff --git a/aircox_streamer/urls.py b/aircox_streamer/urls.py old mode 100644 new mode 100755 index fdd86ec..6ca7691 --- a/aircox_streamer/urls.py +++ b/aircox_streamer/urls.py @@ -1,33 +1,25 @@ -from django.contrib import admin -from django.utils.translation import gettext_lazy as _ +from django.urls import include, path +from rest_framework.routers import DefaultRouter from aircox.viewsets import SoundViewSet -from . import viewsets -from .views import StreamerAdminView - -admin.site.route_view( - "tools/streamer", - StreamerAdminView.as_view(), - "tools-streamer", - label=_("Streamer Monitor"), -) - -streamer_prefix = "streamer/(?P<station_pk>[0-9]+)/" +from . import views, viewsets -router = admin.site.router -router.register( - streamer_prefix + "playlist", - viewsets.PlaylistSourceViewSet, - basename="streamer-playlist", -) -router.register( - streamer_prefix + "queue", - viewsets.QueueSourceViewSet, - basename="streamer-queue", -) +__all__ = ("api", "urls") + + +prefix = "<int:station_pk>/" + + +router = DefaultRouter(use_regex_path=False) +router.register(prefix + "playlist", viewsets.PlaylistSourceViewSet, basename="streamer-playlist") +router.register(prefix + "queue", viewsets.QueueSourceViewSet, basename="streamer-queue") router.register("streamer", viewsets.StreamerViewSet, basename="streamer") router.register("sound", SoundViewSet, basename="sound") -urls = [] +api = router.urls +urls = [ + path("api/", include((api, "aircox_streamer"), namespace="api")), + path("", views.StreamerView.as_view(), name="dashboard-streamer"), +] diff --git a/aircox_streamer/views.py b/aircox_streamer/views.py index 43832e0..ab8adc6 100644 --- a/aircox_streamer/views.py +++ b/aircox_streamer/views.py @@ -1,13 +1,13 @@ from django.utils.translation import gettext_lazy as _ from django.views.generic import TemplateView -from aircox.views.admin import AdminMixin +from aircox.views.dashboard import DashboardBaseView from .controllers import streamers -class StreamerAdminView(AdminMixin, TemplateView): +class StreamerView(DashboardBaseView, TemplateView): template_name = "aircox_streamer/streamer.html" - title = _("Streamer Monitor") + title = _("Streamer") streamers = streamers def dispatch(self, *args, **kwargs): diff --git a/aircox_streamer/viewsets.py b/aircox_streamer/viewsets.py old mode 100644 new mode 100755 index af5beb7..9b9948a --- a/aircox_streamer/viewsets.py +++ b/aircox_streamer/viewsets.py @@ -43,8 +43,10 @@ class ControllerViewSet(viewsets.ViewSet): if station_pk is None: station_pk = self.request.station.pk self.streamers.fetch() + if station_pk is None: + return None if station_pk not in self.streamers: - raise Http404("station not found") + raise Http404(f"station not found: {station_pk}") return self.streamers[station_pk] def get_serializer(self, **kwargs): @@ -58,7 +60,8 @@ class ControllerViewSet(viewsets.ViewSet): return serializer.data def dispatch(self, request, *args, station_pk=None, **kwargs): - self.streamer = self.get_streamer(station_pk) + if not self.streamer: + self.streamer = self.get_streamer(station_pk) return super().dispatch(request, *args, **kwargs) @@ -78,7 +81,10 @@ class StreamerViewSet(ControllerViewSet): def dispatch(self, request, *args, pk=None, **kwargs): if pk is not None: kwargs.setdefault("station_pk", pk) - self.streamer = self.get_streamer(request, **kwargs) + if pk := kwargs.get("station_pk"): + kwargs["station_pk"] = int(pk) + + self.streamer = self.get_streamer(**kwargs) self.object = self.streamer return super().dispatch(request, *args, **kwargs) @@ -86,6 +92,8 @@ class StreamerViewSet(ControllerViewSet): class SourceViewSet(ControllerViewSet): serializer_class = SourceSerializer model = controllers.Source + lookup_field = "pk" + lookup_value_converter = "str" def get_sources(self): return (s for s in self.streamer.sources if isinstance(s, self.model)) @@ -137,7 +145,7 @@ class QueueSourceViewSet(SourceViewSet): model = controllers.QueueSource def get_sound_queryset(self, request): - return Sound.objects.station(request.station).archive() + return Sound.objects.station(request.station) @action(detail=True, methods=["POST"]) def push(self, request, pk): diff --git a/assets/README.md b/assets/README.md index 3a49b04..a990764 100644 --- a/assets/README.md +++ b/assets/README.md @@ -1,24 +1,29 @@ -# aircox-assets +# aircox -## Project setup -``` +This template should help get you started developing with Vue 3 in Vite. + +## Recommended IDE Setup + +[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur). + +## Customize configuration + +See [Vite Configuration Reference](https://vitejs.dev/config/). + +## Project Setup + +```sh npm install ``` -### Compiles and hot-reloads for development -``` -npm run serve +### Compile and Hot-Reload for Development + +```sh +npm run dev ``` -### Compiles and minifies for production -``` +### Compile and Minify for Production + +```sh npm run build ``` - -### Lints and fixes files -``` -npm run lint -``` - -### Customize configuration -See [Configuration Reference](https://cli.vuejs.org/config/). diff --git a/assets/babel.config.js b/assets/babel.config.js deleted file mode 100644 index e955840..0000000 --- a/assets/babel.config.js +++ /dev/null @@ -1,5 +0,0 @@ -module.exports = { - presets: [ - '@vue/cli-plugin-babel/preset' - ] -} diff --git a/assets/jsconfig.json b/assets/jsconfig.json index 4aafc5f..5a1f2d2 100644 --- a/assets/jsconfig.json +++ b/assets/jsconfig.json @@ -1,19 +1,8 @@ { "compilerOptions": { - "target": "es5", - "module": "esnext", - "baseUrl": "./", - "moduleResolution": "node", "paths": { - "@/*": [ - "src/*" - ] - }, - "lib": [ - "esnext", - "dom", - "dom.iterable", - "scripthost" - ] - } + "@/*": ["./src/*"] + } + }, + "exclude": ["node_modules", "dist"] } diff --git a/assets/package.json b/assets/package.json index 2b99bc2..da95be4 100644 --- a/assets/package.json +++ b/assets/package.json @@ -1,36 +1,37 @@ { - "name": "aircox-assets", - "version": "0.1.0", + "name": "aircox", + "version": "0.0.0", "private": true, - "sideEffects": true, + "type": "module", "scripts": { - "serve": "vue-cli-service serve", - "build": "vue-cli-service build", - "lint": "vue-cli-service lint" + "dev": "vite", + "build": "vite build", + "watch": "vite build --watch", + "preview": "vite preview" }, "dependencies": { "@fortawesome/fontawesome-free": "^6.0.0", + "@popperjs/core": "^2.11.8", + "@rollup/plugin-commonjs": "^25.0.7", "core-js": "^3.8.3", "lodash": "^4.17.21", - "vue": "^3.2.13" + "v-calendar": "^3.1.2", + "vite-plugin-babel-macros": "^1.0.6", + "vue": "^3.4.21" }, "devDependencies": { - "@babel/core": "^7.12.16", - "@babel/eslint-parser": "^7.12.16", - "@vue/cli-plugin-babel": "~5.0.0", - "@vue/cli-plugin-eslint": "~5.0.0", - "@vue/cli-service": "~5.0.0", - "bulma": "^0.9.3", + "@vitejs/plugin-vue": "^5.0.4", + "bulma": "^0.9.4", "eslint": "^7.32.0", "eslint-plugin-vue": "^8.0.3", "sass": "^1.49.9", - "sass-loader": "^12.6.0", - "vue-cli": "^2.9.6" + "vite": "^5.2.8" }, "eslintConfig": { "root": true, "env": { - "node": true + "node": true, + "es2022": true }, "extends": [ "plugin:vue/vue3-essential", diff --git a/assets/public/logo.png b/assets/public/logo.png deleted file mode 100644 index 4b71388a2944c533d77a4665ad9cbc7a459a3379..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7275 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 FV1l*i!PEc% diff --git a/assets/public/vue.esm-browser.js b/assets/public/vue.esm-browser.js deleted file mode 120000 index ec53caf..0000000 --- a/assets/public/vue.esm-browser.js +++ /dev/null @@ -1 +0,0 @@ -../node_modules/vue/dist/vue.esm-browser.js \ No newline at end of file diff --git a/assets/public/vue.esm-browser.prod.js b/assets/public/vue.esm-browser.prod.js deleted file mode 120000 index 3ab9489..0000000 --- a/assets/public/vue.esm-browser.prod.js +++ /dev/null @@ -1 +0,0 @@ -../node_modules/vue/dist/vue.esm-browser.prod.js \ No newline at end of file diff --git a/assets/src/admin.js b/assets/src/admin.js index df2305e..5b9fa68 100644 --- a/assets/src/admin.js +++ b/assets/src/admin.js @@ -1,10 +1,8 @@ -import './assets/styles.scss' -import './assets/admin.scss' +import './styles/admin.scss' import './index.js' import App from './app'; -import {admin as components} from './components' -import Track from './track' +import components from './components/admin.js' const AdminApp = { ...App, @@ -13,8 +11,21 @@ const AdminApp = { data() { return { ...super.data, - Track, + modalItem: null, } + }, + + methods: { + ...App.methods, + + fileSelected(select, input, preview) { + const item = this.$refs[select].item + if(item) { + this.$refs[input].value = item.id + if(preview) + preview.src = item.file + } + }, } } export default AdminApp; diff --git a/assets/src/app.js b/assets/src/app.js index ea37962..881db6f 100644 --- a/assets/src/app.js +++ b/assets/src/app.js @@ -1,13 +1,39 @@ +import {Calendar, DatePicker} from 'v-calendar'; import components from './components' const App = { el: '#app', delimiters: ['[[', ']]'], - components: {...components}, + components: { + ...components, + ...{ + VCalendar: Calendar, + VDatepicker: DatePicker + }, + }, computed: { player() { return window.aircox.player; }, }, + + methods: { + //! Delete elements from DOM using provided selector. + deleteElements(sel) { + for(var el of document.querySelectorAll(sel)) + el.parentNode.removeChild(el) + }, + + //! File has been selected + //! TODO: replace using regular ref and bindings. + fileSelected(select, input, preview) { + const item = this.$refs[select].item + if(item) { + this.$refs[input].value = item.id + if(preview) + preview.src = item.file + } + }, + } } export const PlayerApp = { diff --git a/assets/src/appBuilder.js b/assets/src/appBuilder.js deleted file mode 100644 index 4546ba3..0000000 --- a/assets/src/appBuilder.js +++ /dev/null @@ -1,139 +0,0 @@ -import {createApp} from 'vue' - -/** - * Utility class used to handle Vue applications. It provides way to load - * remote application and update history. - */ -export default class Builder { - constructor(config={}) { - this.config = config - this.title = null - this.app = null - this.vm = null - } - - /** - * Fetch app from remote and mount application. - */ - fetch(url, {el='#app', ...options}={}) { - return fetch(url, options).then(response => response.text()) - .then(content => { - let doc = new DOMParser().parseFromString(content, 'text/html') - let app = doc.querySelector(el) - content = app ? app.innerHTML : content - return this.mount({content, title: doc.title, reset:true, url }) - }) - } - - /** - * Mount application, using `create_app` if required. - * - * @param {String} options.content: replace app container content with it - * @param {String} options.title: set DOM document title. - * @param {String} [options.el=this.config.el]: mount application on this element (querySelector argument) - * @param {Boolean} [reset=False]: if True, force application recreation. - * @return `app.mount`'s result. - */ - mount({content=null, title=null, el=null, reset=false, props=null}={}) { - try { - this.unmount() - - let config = this.config - if(el === null) - el = config.el - if(reset || !this.app) - this.app = this.createApp({title,content,el,...config}, props) - - this.vm = this.app.mount(el) - window.scroll(0, 0) - return this.vm - } catch(error) { - this.unmount() - throw error - } - } - - createApp({el, title=null, content=null, ...config}, props) { - const container = document.querySelector(el) - if(!container) - return - if(content) - container.innerHTML = content - if(title) - document.title = title - return createApp(config, props) - } - - unmount() { - this.app && this.app.unmount() - this.app = null - this.vm = null - } - - /** - * Enable hot reload: catch page change in order to fetch them and - * load page without actually leaving current one. - */ - enableHotReload(node=null, historySave=true) { - if(historySave) - this.historySave(document.location, true) - node.addEventListener('click', event => this.pageChanged(event), true) - node.addEventListener('submit', event => this.pageChanged(event), true) - node.addEventListener('popstate', event => this.statePopped(event), true) - } - - pageChanged(event) { - let submit = event.type == 'submit'; - let target = submit || event.target.tagName == 'A' - ? event.target : event.target.closest('a'); - if(!target || target.hasAttribute('target')) - return; - - let url = submit ? target.getAttribute('action') || '' - : target.getAttribute('href'); - let domain = window.location.protocol + '//' + window.location.hostname - let stay = (url === '' || url.startsWith('/') || url.startsWith('?') || - url.startsWith(domain)) && url.indexOf('wp-admin') == -1 - if(url===null || !stay) { - return; - } - - let options = {}; - if(submit) { - let formData = new FormData(event.target); - if(target.method == 'get') - url += '?' + (new URLSearchParams(formData)).toString(); - else - options = {...options, method: target.method, body: formData} - } - this.fetch(url, options).then(() => this.historySave(url)) - event.preventDefault(); - event.stopPropagation(); - } - - statePopped(event) { - const state = event.state - if(state && state.content) - // document.title = this.title; - this.historyLoad(state); - } - - /// Save application state into browser history - historySave(url,replace=false) { - const el = document.querySelector(this.config.el) - const state = { - content: el.innerHTML, - title: document.title, - } - - if(replace) - history.replaceState(state, '', url) - else - history.pushState(state, '', url) - } - - /// Load application from browser history's state - historyLoad(state) { - return this.mount({ content: state.content, title: state.title }) - } -} diff --git a/assets/src/assets/admin.scss b/assets/src/assets/admin.scss deleted file mode 100644 index 10b6f4b..0000000 --- a/assets/src/assets/admin.scss +++ /dev/null @@ -1,30 +0,0 @@ - -.admin { - .navbar .navbar-brand { - padding-right: 1em; - } - - .navbar .navbar-brand img { - margin: 0em 0.4em; - margin-top: 0.3em; - max-height: 3em; - } - - .breadcrumbs { - margin-bottom: 1em; - } - - .results > #result_list { - width: 100%; - margin: 1em 0em; - } - - - ul.menu-list li { - list-style-type: none; - } - - .submit-row a.deletelink { - height: 35px; - } -} diff --git a/assets/src/assets/styles.scss b/assets/src/assets/styles.scss deleted file mode 100644 index b7d6e2a..0000000 --- a/assets/src/assets/styles.scss +++ /dev/null @@ -1,327 +0,0 @@ -@charset "utf-8"; - -@import "~bulma/sass/utilities/_all.sass"; -@import "~bulma/sass/components/dropdown.sass"; - -$body-background-color: $light; -$menu-item-hover-background-color: #dfdfdf; -$menu-item-active-background-color: #d2d2d2; - -@import "~bulma"; - -//-- helpers/modifiers -.is-fullwidth { width: 100%; } -.is-fullheight { height: 100%; } -.is-fixed-bottom { - position: fixed; - bottom: 0; - margin-bottom: 0px; - border-radius: 0; -} -.is-borderless { border: none; } - -.has-text-nowrap { - white-space: nowrap; -} - -.has-background-transparent { - background-color: transparent; -} - -.is-opacity-light { - opacity: 0.7; - &:hover { - opacity: 1; - } -} - -.float-right { float: right } -.float-left { float: left } -.overflow-hidden { overflow: hidden } -.overflow-hidden.is-fullwidth { max-width: 100%; } - - -*[draggable="true"] { - cursor: move; -} - -//-- forms -input.half-field:not(:active):not(:hover) { - border: none; - background-color: rgba(0,0,0,0); - cursor: pointer; -} - - -//-- animations -@keyframes blink { - from { opacity: 1; } - to { opacity: 0.4; } -} - -.blink { - animation: 1s ease-in-out 3s infinite alternate blink; -} - - -//-- navbar -.navbar + .container { - margin-top: 1em; -} - -.navbar.has-shadow, .navbar.is-fixed-bottom.has-shadow { - box-shadow: 0em 0em 1em rgba(0,0,0,0.1); -} - -a.navbar-item.is-active { - border-bottom: 1px grey solid; -} - -.navbar { - .navbar-dropdown { - z-index: 2000; - } - - .navbar-split { - margin: 0.2em 0em; - margin-right: 1em; - padding-right: 1em; - border-right: 1px $grey-light solid; - display: inline-block; - } - - form { - margin: 0em; - padding: 0em; - } - - &.toolbar { - margin: 1em 0em; - background-color: transparent; - margin-bottom: 1em; - - .title { - padding-right: 2em; - margin-right: 1em; - border-right: 1px $grey-light solid; - - font-size: $size-5; - color: $text-light; - font-weight: $weight-light; - } - } -} - -//-- cards -.card { - .title { - a { - color: $dark; - } - - padding: 0.2em; - font-size: $size-5; - font-weight: $weight-medium; - } - - &.is-primary { - box-shadow: 0em 0em 0.5em $black - } -} - -.card-super-title { - position: absolute; - z-index: 1000; - font-size: $size-6; - font-weight: $weight-bold; - padding: 0.2em; - top: 1em; - background-color: #ffffffc7; - max-width: 90%; - - .fas { - padding: 0.1em; - font-size: 0.8em; - } -} - - -//-- page -.page { - & > .cover { - float: right; - max-width: 45%; - } - - .header { - margin-bottom: 1.5em; - } - - .headline { - font-size: 1.4em; - padding: 0.2em 0em; - } - - p { padding: 0.4em 0em; } - hr { background-color: $grey-light; } - - .page-content { - h1 { font-size: $size-1; font-weight: bolder; margin-top:0.4em; margin-bottom:0.2em; } - h2 { font-size: $size-3; font-weight: bolder; margin-top:0.4em; margin-bottom:0.2em; } - h3 { font-size: $size-4; font-weight: bolder; margin-top:0.4em; margin-bottom:0.2em; } - h4 { font-size: $size-5; font-weight: bolder; margin-top:0.4em; margin-bottom:0.2em; } - h5 { font-size: $size-6; font-weight: bolder; margin-top:0.4em; margin-bottom:0.2em; } - h6 { font-size: $size-6; margin-top:0.4em; margin-bottom:0.2em; } - } -} - - -.media.item .headline { - line-height: 1.2em; - max-height: calc(1.2em * 3); - overflow: hidden; - - & + .headline-overflow { - position: relative; - width: 100%; - height: 2em; - margin-top: -2em; - } - - & + .headline-overflow:before { - content:''; - width:100%; - height:100%; - position:absolute; - left:0; - bottom:0; - background:linear-gradient(transparent 1em, $body-background-color); - } -} - - -//-- player -.player { - z-index: 10000; - box-shadow: 0em 1.5em 2.5em rgba(0, 0, 0, 0.6); - - .player-panels { - height: 0%; - transition: height 3s; - } - .player-panels.is-open { - height: auto; - } - - .player-panel { - margin: 0.4em; - max-height: 80%; - overflow-y: auto; - } - - .progress { - margin: 0em; - padding: 0em; - border-color: $info; - border-style: 'solid'; - } - - .player-bar { - border-top: 1px $grey-light solid; - - > div { - height: 3.75em !important; - } - - > .media-left:not(:last-child) { - margin-right: 0em; - } - - > .media-cover { - border-left: 1px black solid; - } - - .cover { - font-size: 1.5rem !important; - height: 2.5em !important; - } - - > .media-content { - padding-top: 0.4em; - padding-left: 0.4em; - } - - .button { - font-size: 1.5rem !important; - height: 100%; - padding: auto 0.2em !important; - min-width: 2.5em; - border-radius: 0px; - transition: background-color 1s; - } - - .title { - margin: 0em; - } - } - -} - -//-- media -.media { - .subtitle { - margin-bottom: 0.4em; - } - .media-content .headline { - font-size: 1em; - font-weight: 400; - } -} - -//-- general -body { - background-color: $body-background-color; -} - -section > .toolbar { - background-color: rgba(0,0,0,0.05); - padding: 1em; - margin-bottom: 1.5em; -} - - -main { - .cover.is-small { width: 10em; } - .cover.is-tiny { height: 2em; } -} - - -aside { - & > section { - margin-bottom: 2em; - } - - .cover.is-small { width: 10em; } - .cover.is-tiny { height: 2em; } - - .media .subtitle { - font-size: 1em; - } -} - -.sound-item { - .cover { height: 5em; } - .media-content a { padding: 0em; } - margin-bottom: 0.2em; -} -.sound-item .media-right .button { - margin-right: 0.2em; - min-width: 2.5em; - display: inline-block; -} - - -.timetable { - width: 100%; - border: none; -} diff --git a/assets/src/components/AActionButton.vue b/assets/src/components/AActionButton.vue index cf25b70..3caaa3d 100644 --- a/assets/src/components/AActionButton.vue +++ b/assets/src/components/AActionButton.vue @@ -1,9 +1,9 @@ <template> - <component :is="tag" @click="call" :class="buttonClass"> + <component :is="tag" @click.capture.stop="call" type="button" :class="[buttonClass, this.promise && 'blink' || '']"> <span v-if="promise && runIcon"> <i :class="runIcon"></i> </span> - <span v-else-if="icon" class="icon"> + <span v-else-if="icon" class="icon is-small"> <i :class="icon"></i> </span> <span v-if="$slots.default"><slot name="default"/></span> @@ -27,6 +27,8 @@ export default { data: Object, //! Action method, by default, `POST` method: { type: String, default: 'POST'}, + //! If provided open confirmation box before proceeding + confirm: { type: String, default: ''}, //! Action url url: String, //! Extra request options @@ -60,16 +62,19 @@ export default { call() { if(this.promise || !this.url) return + if(this.confirm && !confirm(this.confirm)) + return + const options = Model.getOptions({ ...this.fetchOptions, method: this.method, body: JSON.stringify(this.item.data), }) - this.promise = fetch(this.url, options).then(data => { - const response = data.json(); + this.promise = fetch(this.url, options).then(data => data.text()).then(data => { + data = data && JSON.parse(data) || null this.promise = null; - this.$emit('done', response) - return response + this.$emit('done', data) + return data }, data => { this.promise = null; return data }) return this.promise }, diff --git a/assets/src/components/AAutocomplete.vue b/assets/src/components/AAutocomplete.vue index 4a22506..d9e40cd 100644 --- a/assets/src/components/AAutocomplete.vue +++ b/assets/src/components/AAutocomplete.vue @@ -20,24 +20,23 @@ <span class="is-inline-block" v-if="selected"> <slot name="button" :index="selectedIndex" :item="selected" :value-field="valueField" :labelField="labelField"> - {{ labelField && selected.data[labelField] || selected }} + {{ selectedLabel }} </slot> </span> </a> <div :class="dropdownClass"> <div class="dropdown-menu is-fullwidth"> <div class="dropdown-content" style="overflow: hidden"> - <a v-for="(item, index) in items" :key="item.id" - href="#" :data-autocomplete-index="index" + <span v-for="(item, index) in items" :key="item.id" + :data-autocomplete-index="index" @click="select(index, false, false)" :class="['dropdown-item', (index == this.cursor) ? 'is-active':'']" - :title="labelField && item.data[labelField] || item" tabindex="-1"> <slot name="item" :index="index" :item="item" :value-field="valueField" :labelField="labelField"> - {{ labelField && item.data[labelField] || item }} + {{ getValue(item, labelField) || item }} </slot> - </a> + </span> </div> </div> </div> @@ -56,12 +55,14 @@ export default { props: { //! Search URL (where `${query}` is replaced by search term) url: String, + //! Extra GET url parameters + urlParams: Object, //! Items' model model: Function, //! Input tag class inputClass: Array, //! input text placeholder - placeholder: String, + placeholder: Object, //! input form field name name: String, //! Field on items to use as label @@ -94,13 +95,31 @@ export default { this.inputValue = value }, - inputValue(value) { - if(value != this.inputValue && value != this.modelValue) + inputValue(value, old) { + if(value != old && value != this.modelValue) { this.$emit('update:modelValue', value) + this.$emit('change', {target: this.$refs.input}) + } + if(this.selectedLabel != value) + this.selectedIndex = -1 }, }, computed: { + fullUrl() { + if(!this.urlParams) + return this.url + + const url = new URL(this.url, window.location.origin) + const params = new URLSearchParams(url.searchParams) + + for(var key in this.urlParams) + params.set(key, this.urlParams[key]) + const join = this.url.indexOf("?") >= 0 ? "&" : "?" + url.search = params.toString() + return url.href + }, + isFetching() { return !!this.promise }, selected() { @@ -132,12 +151,34 @@ export default { }, methods: { + reset() { + this.inputValue = "" + this.selectedIndex = -1 + this.items = [] + }, + + // TODO: move to utils/data + getValue(data, path=null) { + if(!data) + return null + if(!path) + return data + + const paths = path.split('.') + for(const key of paths) { + if(key in data) + data = data[key] + else return null; + } + return data + }, + itemValue(item) { - return this.valueField ? item && item[this.valueField] : item; + return this.valueField ? this.getValue(item, this.valueField) : item; }, itemLabel(item) { - return this.labelField ? item && item[this.labelField] : item; + return this.labelField ? this.getValue(item, this.labelField) : item; }, hide() { @@ -176,8 +217,11 @@ export default { }, onBlur(event) { - var index = event.relatedTarget && event.relatedTarget.dataset.autocompleteIndex; - if(index !== undefined) + if(!this.items.length) + return + + var index = event.relatedTarget && Math.floor(event.relatedTarget.dataset.autocompleteIndex); + if(index !== undefined && index !== null) this.select(index, false, false) this.cursor = -1; }, @@ -220,12 +264,14 @@ export default { return this.query = query - var url = this.url.replace('${query}', query) + var url = this.fullUrl.replace('${query}', query).replace('%24%7Bquery%7D', query) var promise = this.model ? this.model.fetch(url, {many:true}) : fetch(url, Model.getOptions()).then(d => d.json()) promise = promise.then(items => { - this.items = items || [] + if(items.results) + items = items.results + this.items = items.filter((i) => i) || [] this.promise = null; this.move(0) return items @@ -237,7 +283,7 @@ export default { mounted() { const form = this.$el.closest('form') - form.addEventListener('reset', () => { + form && form.addEventListener('reset', () => { this.inputValue = this.value; this.select(-1) }) diff --git a/assets/src/components/ACarousel.vue b/assets/src/components/ACarousel.vue new file mode 100644 index 0000000..5ee0611 --- /dev/null +++ b/assets/src/components/ACarousel.vue @@ -0,0 +1,242 @@ +<template> + <section class="a-carousel"> + <nav ref="viewport" class="a-carousel-viewport"> + <section ref="container" :class="['a-carousel-container', containerClass]"> + <slot name="default"></slot> + </section> + </nav> + + <nav class="a-carousel-bullets-container"> + <span class="left"> + <span class="icon bullet" @click="prev()" v-if="showPrev"> + <i :class="leftButtonIcon"></i> + </span> + </span> + <template v-if="bullets.length > 1"> + <span class="icon bullet" v-bind:key="bullet" v-for="bullet of bullets" @click="select(bullet)"> + <i v-if="bullet == index" class="fa fa-circle"></i> + <i v-else class="far fa-circle"></i> + </span> + </template> + <span class="right"> + <span class="icon bullet" @click="next()" v-if="showNext"> + <i :class="rightButtonIcon"></i> + </span> + </span> + + <slot name="bullets-right" :v-bind="this"></slot> + </nav> + </section> +</template> +<style scoped> +.a-carousel { + width: 100%; + position: relative; +} + +.a-carousel-viewport { + width: 100%; + overflow-x: hidden; +} + +.a-carousel-container { + display: flex; + flex-direction: row; + align-items: left; +} + +.a-carousel-container > * { + flex-shrink: 0; +} + +.a-carousel-bullets-container { + flex-grow: 1; +} + +.a-carousel-bullets-container .bullet { + cursor: pointer; +} + +.a-carousel-bullets-container .left { + min-width: 2rem; + margin-right: auto; +} + +.a-carousel-bullets-container .right { + min-width: 2rem; + margin-left: auto; +} + +.a-carousel-bullets-container { + display: flex; + flex-direction: row; +} +</style> +<script> +import {ref} from 'vue' + + +class Offset { + constructor(el, min=null, max=null) { + this.el = el + this.rect = el.getBoundingClientRect(); + ({min, max} = this.minmax(min, max)) + this.min = min + this.max = max + this.size = max-min + } + + minmax(min=null, max=null) { + min = min === null ? this.rect.left : min + max = max === null ? this.rect.right : max + return {min, max} + } + + relative(to) { + return new Offset(this.el, this.min-to.min, this.max-to.min) + } +} + + +class Card extends Offset { + constructor(el, index) { + super(el) + this.index = index + } + + visible(viewportOffset) { + return viewportOffset.min <= this.min && viewportOffset.max >= this.max + } +} + + +export default { + setup() { + return { + viewport: ref(null), + container: ref(null), + } + }, + + data() { + return { + cards: [], + index: 0, + refresh_: 0, + } + }, + + props: { + cardSelector: {type: String, default: ''}, + containerClass: {type: String, default: ''}, + buttonClass: {type: String, default: 'button'}, + leftButtonIcon: {type: String, default: "fas fa-chevron-left"}, + rightButtonIcon: {type: String, default: "fas fa-chevron-right"}, + }, + + computed: { + card() { return this.cards()[this.index] }, + + showPrev() { + return this.index > 0 + }, + + showNext() { + if(!this.cards || this.cards.length <= 1) + return false + + let last = this.bullets[this.bullets.length-1] + return this.index != last + }, + + bullets() { + if(!this.cards || !this.$refs.viewport) + return [] + + let contOff = new Offset(this.$refs.container) + let viewMax = new Offset(this.$refs.viewport).size + let bullets = [] + + let i = 0; + let max = viewMax + bullets.push(i) + while(i < this.cards.length) { + // skip until next view + for(; i < this.cards.length; i++) { + let card = this.cards[i].relative(contOff) + if(card.max > max) { + max = card.min + viewMax + bullets.push(i) + i++ + break + } + } + } + return bullets + }, + }, + + methods: { + getCards() { + if(!this.$refs.container) + return [] + let nodes = (!this.cardSelector) ? + [...this.$refs.container.children] : + [...this.$refs.container.querySelectorAll(this.cardSelector)] + return nodes.map((el, index) => new Card(el, index)) + }, + + select(index, relative=false) { + if(relative) + index = this.index + index + + index = Math.min(index, this.cards.length) + index = Math.max(index, 0) + let card = this.cards[index] + if(!card) + return null; + + card = new Card(card.el) + const cont = new Offset(this.$refs.container) + const rel = card.relative(cont) + this.$refs.container.style.marginLeft = `-${rel.min}px` + this.index = index; + return card.el + }, + + next() { + let n = this.bullets.indexOf(this.index) + let index = this.bullets[n+1] + this.select(index) + }, + + prev() { + let n = this.bullets.indexOf(this.index) + let index = this.bullets[n-1] + this.select(index) + }, + + refresh() { + this.cards = this.getCards() + this.select(this.index) + this.refresh_++ + } + }, + + + mounted() { + this.observers = [ + new MutationObserver(() => this.refresh()), + new ResizeObserver(() => this.refresh()) + ] + this.observers[0].observe(this.$refs.container, {"childList": true}) + this.observers[1].observe(this.$refs.container) + this.refresh() + }, + + unmounted() { + for(var observer of this.observers) + observer.disconnect() + } +} +</script> diff --git a/assets/src/components/ADropdown.vue b/assets/src/components/ADropdown.vue new file mode 100644 index 0000000..179c830 --- /dev/null +++ b/assets/src/components/ADropdown.vue @@ -0,0 +1,49 @@ +<template> +<component :is="tag" :class="[itemClass, active ? activeClass : '']"> + <slot name="before-button" :toggle="toggle" :active="active"></slot> + <slot name="button" :toggle="toggle" :active="active"> + <component :is="buttonTag" :class="buttonClass" @click="toggle()"> + <span class="icon" v-if="labelIcon"> + <i :class="labelIcon"></i> + </span> + <span>{{ label }}</span> + <span class="icon"> + <i v-if="!active" :class="buttonIcon"></i> + <i v-if="active" :class="buttonIconClose"></i> + </span> + </component> + </slot> + <div :class="contentClass" v-show="active"> + <slot></slot> + </div> +</component> +</template> +<script> +export default { + data() { + return { + active: this.open, + } + }, + + props: { + tag: {type: String, default: "div"}, + label: {type: String, default: ""}, + labelIcon: {type: String, default: ""}, + buttonTag: {type: String, default: "button"}, + activeClass: {type: String, default: "is-active"}, + buttonClass: {type: String, default: "button"}, + buttonIcon: { type: String, default:"fa fa-angle-down"}, + buttonIconClose: { type: String, default:"fa fa-angle-up"}, + contentClass: String, + open: {type: Boolean, default: false}, + noButton: {type: Boolean, default: false}, + }, + + methods: { + toggle() { + this.active = !this.active + } + }, +} +</script> diff --git a/assets/src/components/AEpisode.vue b/assets/src/components/AEpisode.vue index abeec35..5dc7422 100644 --- a/assets/src/components/AEpisode.vue +++ b/assets/src/components/AEpisode.vue @@ -1,13 +1,11 @@ <template> - <div> - <slot :page="page" :podcasts="podcasts"></slot> - </div> + <slot :page="page" :podcasts="podcasts"></slot> </template> <script> -import {Set} from '../model'; -import Sound from '../sound'; -import APage from './APage'; +import {Set} from '../model.js'; +import Sound from '../sound.js'; +import APage from './APage.vue'; export default { extends: APage, diff --git a/assets/src/components/AFileUpload.vue b/assets/src/components/AFileUpload.vue new file mode 100644 index 0000000..8adfb40 --- /dev/null +++ b/assets/src/components/AFileUpload.vue @@ -0,0 +1,110 @@ +<template> + <div ref="list" class="a-select-file-list"> + <form ref="form" class="flex-column" v-if="state == STATE.DEFAULT"> + <slot name="form"></slot> + <div class="field is-horizontal"> + <label class="label">{{ label }}</label> + <input type="file" ref="uploadFile" :name="fieldName" @change="onFileChange"/> + </div> + <div class="flex-row align-right" v-if="submitLabel"> + <button type="button" class="button small" @click="submit"> + {{ submitLabel }} + </button> + </div> + </form> + <div class="flex-column" v-else> + <slot name="preview" :fileUrl="fileUrl" :file="file" :loaded="loaded" :total="total"></slot> + <div class="flex-row"> + <progress :max="total" :value="loaded"/> + <button type="button" class="button small square ml-2" @click="abort"> + <span class="icon small"> + <i class="fa fa-close"></i> + </span> + </button> + </div> + </div> + </div> +</template> +<script> +import {getCsrf} from "../model.js" + +export default { + emit: ["fileChange", "load", "abort", "error"], + + props: { + url: { type: String }, + fieldName: { type: String, default: "file" }, + label: { type: String, default: "Select a file" }, + submitLabel: { type: String, default: "Upload" }, + }, + + data() { + return { + STATE: { + DEFAULT: 0, + UPLOADING: 1, + }, + state: 0, + upload: {}, + file: null, + fileUrl: null, + total: 0, + loaded: 0, + request: null, + } + }, + + methods: { + abort() { + this.request && this.request.abort() + }, + + onFileChange() { + const [file] = this.$refs.uploadFile.files + if(!file) + return + this._setUploadFile(file) + this.$emit("fileChange", {upload: this, file: this.file, fileUrl: this.fileUrl}) + }, + + submit() { + const req = new XMLHttpRequest() + req.open("POST", this.url) + req.upload.addEventListener("progress", (e) => this.onUploadProgress(e)) + req.addEventListener("load", (e) => this.onUploadDone(e, 'load')) + req.addEventListener("abort", (e) => this.onUploadDone(e, 'abort')) + req.addEventListener("error", (e) => this.onUploadDone(e, 'error')) + + const formData = new FormData(this.$refs.form); + formData.append('csrfmiddlewaretoken', getCsrf()) + req.send(formData) + + this._resetUpload(this.STATE.UPLOADING, false, req) + }, + + onUploadProgress(event) { + this.loaded = event.loaded + this.total = event.total + }, + + onUploadDone(event, eventName) { + this.$emit(eventName, event) + this._resetUpload(this.STATE.DEFAULT, true) + }, + + _setUploadFile(file) { + this.file = file + this.fileURL = file && URL.createObjectURL(file) + }, + + _resetUpload(state, resetFile=false, request=null) { + this.state = state + this.loaded = 0 + this.total = 0 + this.request = request + if(resetFile) + this.file = null + } + + },} +</script> diff --git a/assets/src/components/AFormSet.vue b/assets/src/components/AFormSet.vue new file mode 100644 index 0000000..7121550 --- /dev/null +++ b/assets/src/components/AFormSet.vue @@ -0,0 +1,193 @@ +<template> + <div> + <input type="hidden" :name="_prefix + 'TOTAL_FORMS'" :value="items.length || 0"/> + <template v-for="(value,name) in formData.management" v-bind:key="name"> + <input type="hidden" :name="_prefix + name.toUpperCase()" + :value="value"/> + </template> + + <a-rows ref="rows" :set="set" :context="this" + :columns="visibleFields" :columnsOrderable="columnsOrderable" + :orderable="orderable" @move="moveItem" @colmove="onColumnMove" + @cell="e => $emit('cell', e)"> + + <template #header-head> + <template v-if="orderable"> + <th style="max-width:2em" :title="orderField.label" + :aria-label="orderField.label" + :aria-description="orderField.help || ''"> + <span class="icon"> + <i class="fa fa-arrow-down-1-9"></i> + </span> + </th> + <slot name="rows-header-head"></slot> + </template> + </template> + + <template #row-head="data"> + <input v-if="orderable" type="hidden" + :name="_prefix + data.row + '-' + orderBy" + :value="data.row"/> + <input type="hidden" :name="_prefix + data.row + '-id'" + :value="data.item ? data.item.id : ''"/> + + <template v-for="field of hiddenFields" v-bind:key="field.name"> + <input type="hidden" + v-if="!(field.name in ['id', orderBy])" + :name="_prefix + data.row + '-' + field.name" + :value="field.value in [null, undefined] ? data.item.data[name] : field.value"/> + </template> + + <slot name="row-head" v-bind="data"> + <td v-if="orderable">{{ data.row+1 }}</td> + </slot> + </template> + + <template v-for="(field,slot) of fieldSlots" v-bind:key="field.name" + v-slot:[slot]="data"> + <slot :name="slot" v-bind="data" :field="field" :input-name="_prefix + data.cell.row + '-' + field.name"> + <div class="field"> + <div class="control"> + <slot :name="'control-' + field.name" v-bind="data" :field="field" :input-name="_prefix + data.cell.row + '-' + field.name"/> + </div> + <p v-for="[error,index] in data.item.error(field.name)" class="help is-danger" v-bind:key="index"> + {{ error }} + </p> + </div> + </slot> + </template> + + <template #row-tail="data"> + <slot v-if="$slots['row-tail']" name="row-tail" v-bind="data"/> + <td class="align-right pr-0"> + <button type="button" class="button square" + @click.stop="removeItem(data.row, data.item)" + :title="labels.remove_item" + :aria-label="labels.remove_item"> + <span class="icon"><i class="fa fa-trash" /></span> + </button> + </td> + </template> + </a-rows> + <div class="a-formset-footer flex-row"> + <div class="flex-grow-1 flex-row"> + <slot name="footer"/> + </div> + <div class="flex-grow-1 align-right"> + <button type="button" class="button square is-warning p-2" + @click="reset()" + :title="labels.discard_changes" + :aria-label="labels.discard_changes" + > + <span class="icon"><i class="fa fa-rotate" /></span> + </button> + <button type="button" class="button square is-primary p-2" + @click="onActionAdd" + :title="labels.add_item" + :aria-label="labels.add_item" + > + <span class="icon"> + <i class="fa fa-plus"/></span> + </button> + </div> + </div> + </div> +</template> +<script> + import {cloneDeep} from 'lodash' + import Model, {Set} from '../model' + + import ARows from './ARows' + + export default { + emit: ['cell', 'move', 'colmove', 'load'], + components: {ARows}, + + props: { + labels: Object, + + //! If provided call this function instead of adding an item to rows on "+" button click. + actionAdd: Function, + + //! If True, columns can be reordered + columnsOrderable: Boolean, + //! Field name used for ordering + orderBy: String, + + //! Formset data as returned by get_formset_data + formData: Object, + //! Model class used for item's set + model: {type: Function, default: Model}, + }, + + data() { + return { + set: new Set(Model), + } + }, + + computed: { + // ---- fields + _prefix() { return this.formData.prefix ? this.formData.prefix + '-' : '' }, + fields() { return this.formData.fields }, + orderField() { return this.orderBy && this.fields.find(f => f.name == this.orderBy) }, + orderable() { return !!this.orderField }, + + hiddenFields() { return this.fields.filter(f => f.hidden && !(this.orderable && f == this.orderField)) }, + visibleFields() { return this.fields.filter(f => !f.hidden) }, + + fieldSlots() { return this.visibleFields.reduce( + (slots, f) => ({...slots, ['row-' + f.name]: f}), + {} + )}, + + items() { return this.set.items }, + rows() { return this.$refs.rows }, + }, + + methods: { + onCellEvent(event) { this.$emit('cell', event) }, + onColumnMove(event) { this.$emit('colmove', event) }, + onActionAdd() { + if(this.actionAdd) + return this.actionAdd(this) + this.set.push() + }, + + moveItem(event) { + const {from, to} = event + const set_ = event.set || this.set + set_.move(from, to); + this.$emit('move', {...event, seŧ: set_}) + }, + + removeItem(row) { + const item = this.items[row] + if(item.id) { + // TODO + } + else { + this.items.splice(row,1) + } + }, + + //! Load items into set + load(items=[], reset=false) { + if(reset) + this.set.items = [] + for(var item of items) + this.set.push(cloneDeep(item)) + this.$emit('load', items) + }, + + //! Reset forms to initials + reset() { + this.load(this.formData?.initials || [], true) + }, + }, + + mounted() { + this.reset() + } + } +</script> diff --git a/assets/src/components/AManyToManyEdit.vue b/assets/src/components/AManyToManyEdit.vue new file mode 100644 index 0000000..35af828 --- /dev/null +++ b/assets/src/components/AManyToManyEdit.vue @@ -0,0 +1,109 @@ +<template> + <div class="a-m2m-edit"> + <table class="table is-fullwidth"> + <thead> + <tr> + <th> + <slot name="items-title"></slot> + </th> + <th style="width: 1rem"> + <span class="icon"> + <i class="fa fa-trash"/> + </span> + </th> + </tr> + </thead> + <tbody> + <template v-for="item of items" :key="item.id"> + <tr :class="[item.created && 'has-text-info', item.deleted && 'has-text-danger']"> + <td> + <slot name="item" :item="item"> + {{ item.data }} + </slot> + </td> + <td class="align-center"> + <input type="checkbox" class="checkbox" @change="item.deleted = $event.target.checked"> + </td> + </tr> + </template> + </tbody> + </table> + <div> + <label> + <span class="icon"> + <i class="fa fa-plus"/> + </span> + Add + </label> + <a-autocomplete ref="autocomplete" v-bind="autocomplete" + @select="onSelect"> + <template #item="{item}"> + <slot name="autocomplete-item" :item="item">{{ item }}</slot> + </template> + </a-autocomplete> + </div> + </div> +</template> +<script> +import Model, { Set } from "../model.js" +import AAutocomplete from "./AAutocomplete.vue" + +export default { + components: {AAutocomplete}, + props: { + model: {type: Function, default: Model }, + // List url + url: String, + // POST url + commitUrl: String, + // v-bind to autocomplete search box + autocomplete: {type: Object }, + + source_id: Number, + source_field: String, + target_field: String, + }, + + data() { + return { + set: new Set(this.model, {url: this.url, unique: true}), + } + }, + + computed: { + items() { return this.set?.items || [] }, + initials() { + let obj = {} + obj[this.source_id_attr] = this.source_id + return obj + }, + + source_id_attr() { return this.source_field + "_id" }, + target_id_attr() { return this.target_field + "_id" }, + target_ids() { return this.set?.items.map(i => i.data[this.target_id_attr]) }, + }, + + methods: { + onSelect(index, item, value) { + if(this.target_ids.indexOf(item.id) != -1) + return + + let obj = {...this.initials} + obj[this.target_field] = {...item} + obj[this.target_id_attr] = item.id + this.set.push(obj) + this.$refs.autocomplete.reset() + }, + + save() { + this.set.commit(this.commitUrl, { + fields: [...Object.keys(this.initials), this.target_id_attr] + }) + }, + }, + + mounted() { + this.set.fetch() + }, +} +</script> diff --git a/assets/src/components/AModal.vue b/assets/src/components/AModal.vue new file mode 100644 index 0000000..664447d --- /dev/null +++ b/assets/src/components/AModal.vue @@ -0,0 +1,53 @@ +<template> + <section :class="['modal', active && 'is-active' || '']"> + <div class="modal-background" @click="close"></div> + <div class="modal-card"> + <header class="modal-card-head"> + <div class="modal-card-title"> + <slot name="title" :item="item">{{ title }}</slot> + </div> + <slot name="bar" :item="item"></slot> + <button type="button" class="delete square" aria-label="close" @click="close"> + <span class="icon"> + <i class="fa fa-close"></i> + </span> + </button> + </header> + <section class="modal-card-body"> + <slot name="default" :item="item"></slot> + </section> + <div class="modal-card-foot align-right"> + <slot name="footer" :item="item" :close="close"></slot> + </div> + </div> + </section> +</template> +<script> +export default { + props: { + title: { type: String, default: ""}, + }, + + data() { + return { + ///! If true, modal is open + active: false, + ///! Item or data passed down to slots. + item: null, + } + }, + + methods: { + ///! Open modal dialog. Set provided `item` to dialog's one. + open(item=null) { + this.active = true + this.item = item + }, + ///! Close modal and reset item to null. + close() { + this.active = false + this.item = null + }, + } +} +</script> diff --git a/assets/src/components/APlayer.vue b/assets/src/components/APlayer.vue index 60a4da6..23269de 100644 --- a/assets/src/components/APlayer.vue +++ b/assets/src/components/APlayer.vue @@ -1,69 +1,63 @@ <template> - <div class="player"> - <div :class="['player-panels', panel ? 'is-open' : '']"> - <APlaylist ref="pin" class="player-panel menu" v-show="panel == 'pin' && sets.pin.length" - name="Pinned" - :actions="['page']" - :editable="true" :player="self" :set="sets.pin" @select="togglePlay('pin', $event.index)" - listClass="menu-list" itemClass="menu-item"> - <template v-slot:header=""> - <p class="menu-label"> - <span class="icon"><span class="fa fa-thumbtack"></span></span> - Pinned - </p> - </template> - </APlaylist> - <APlaylist ref="queue" class="player-panel menu" v-show="panel == 'queue' && sets.queue.length" - :actions="['page']" - :editable="true" :player="self" :set="sets.queue" @select="togglePlay('queue', $event.index)" - listClass="menu-list" itemClass="menu-item"> - <template v-slot:header=""> - <p class="menu-label"> - <span class="icon"><span class="fa fa-list"></span></span> - Playlist - </p> - </template> - </APlaylist> + <div class="a-player"> + <div :class="['a-player-panels', panel ? 'is-open' : '']"> + <template v-for="(info, key) in playlists" v-bind:key="key"> + <APlaylist + :ref="key" class="a-player-panel a-playlist" + v-show="panel == key && sets[key].length" + :actions="['page', key != 'pin' && 'pin' || '']" + :editable="true" :player="self" :set="sets[key]" + @select="togglePlay(key, $event.index)" + listClass="menu-list" itemClass="menu-item"> + <template v-slot:header=""> + <div class="title is-flex-grow-1"> + <span class="icon"> + <i :class="info[1]"></i> + </span> + {{ info[0] }} + </div> + <button class="action button no-border"> + <span class="icon" @click.stop="togglePanel()"> + <i class="fa fa-close"></i> + </span> + </button> + </template> + </APlaylist> + </template> </div> - <div class="player-bar media"> - <div class="media-left"> - <button class="button" @click="togglePlay()" - :title="buttonTitle" :aria-label="buttonTitle"> - <span class="fas fa-pause" v-if="playing"></span> - <span class="fas fa-play" v-else></span> - </button> - </div> - <div class="media-left media-cover" v-if="current && current.data.cover"> - <img :src="current.data.cover" class="cover" /> - </div> - <div class="media-content"> + <div class="a-player-progress" v-if="loaded && duration"> + <AProgress v-if="loaded && duration" :value="currentTime" :max="this.duration" + :format="displayTime" + @select="audio.currentTime = $event"></AProgress> + </div> + <div class="a-player-bar button-group"> + <button class="button" @click="togglePlay()" + :title="buttonTitle" :aria-label="buttonTitle"> + <span class="fas fa-pause" v-if="playing"></span> + <span class="fas fa-play" v-else></span> + </button> + <div :class="['a-player-bar-content', loaded && duration ? 'has-progress' : '']"> <slot name="content" :loaded="loaded" :live="live" :current="current"></slot> - <AProgress v-if="loaded && duration" :value="currentTime" :max="this.duration" - :format="displayTime" class="pt-1 is-size-7" - @select="audio.currentTime = $event"></AProgress> - </div> - <div class="media-right"> - <button class="button has-text-weight-bold" v-if="loaded" @click="play()"> - <span class="icon is-size-6 has-text-danger"> - <span class="fa fa-circle"></span> - </span> - <span>Live</span> - </button> - <button ref="pinPlaylistButton" :class="playlistButtonClass('pin')" - @click="togglePanel('pin')" v-show="sets.pin.length"> - <span class="is-size-6" v-if="sets.pin.length"> - {{ sets.pin.length }}</span> - <span class="icon"><span class="fa fa-thumbtack"></span></span> - </button> - <button :class="playlistButtonClass('queue')" - @click="togglePanel('queue')" v-show="sets.queue.length"> - <span class="is-size-6" v-if="sets.queue.length"> - {{ sets.queue.length }}</span> - <span class="icon"><span class="fa fa-list"></span></span> - </button> - </div> + <button class="button has-text-weight-bold" v-if="loaded" @click="play()" + title="Live"> + <span class="icon is-size-6 has-text-danger"> + <span class="fa fa-circle"></span> + </span> + </button> + <template v-if="sets"> + <template v-for="(info, key) in playlists" v-bind:key="key"> + <button :class="playlistButtonClass(key)" + @click="togglePanel(key)" + v-show="sets[key] && sets[key].length"> + <span class="is-size-6">{{ sets[key] && sets[key].length }}</span> + <span class="icon"> + <i :class="info[1]"></i> + </span> + </button> + </template> + </template> </div> </div> </template> @@ -101,6 +95,11 @@ export default { let live = this.liveArgs ? reactive(new Live(this.liveArgs)) : null; live && live.refresh(); + const sets = {} + for(const key in this.playlists) + sets[key] = Set.storeLoad(Sound, 'playlist.' + key, + {max: 30, unique: true}) + return { audio, duration: 0, currentTime: 0, state: State.paused, live, @@ -112,16 +111,15 @@ export default { //! current playing playlist name playlistName: null, //! players' playlists' sets - sets: { - queue: Set.storeLoad(Sound, "playlist.queue", { max: 30, unique: true }), - pin: Set.storeLoad(Sound, "player.pin", { max: 30, unique: true }), - } + sets, } }, props: { buttonTitle: String, liveArgs: Object, + ///! dict of {'slug': ['Label', 'icon']} + playlists: Object, }, computed: { @@ -131,7 +129,7 @@ export default { loading() { return this.state == State.loading; }, playlist() { - return this.playlistName ? this.$refs[this.playlistName] : null; + return this.playlistName ? this.$refs[this.playlistName][0] : null; }, current() { @@ -156,10 +154,9 @@ export default { playlistButtonClass(name) { let set = this.sets[name]; return (set ? (set.length ? "" : "has-text-grey-light ") - + (this.panel == name ? "is-info " - : this.playlistName == name ? 'is-primary ' - : '') : '') - + "button has-text-weight-bold"; + + (this.panel == name ? "open" + : this.playlistName == name ? 'active' : '') : '') + + " button"; }, /// Show/hide panel @@ -172,8 +169,8 @@ export default { _setPlaylist(playlist) { this.playlistName = playlist; for(var p in this.sets) - if(p != playlist) - this.$refs[p].unselect(); + if(p != playlist && this.$refs[p]) + this.$refs[p][0].unselect(); }, /// Load a sound from playlist or live @@ -182,7 +179,7 @@ export default { // from playlist if(playlist !== null && index != -1) { - let item = this.$refs[playlist].get(index); + let item = this.$refs[playlist][0].get(index); if(!item) throw `No sound at index ${index} for playlist ${playlist}`; this.loaded = item @@ -226,7 +223,7 @@ export default { /// Push and play items playItems(playlist, ...items) { let index = this.push(playlist, ...items); - this.$refs[playlist].selectedIndex = index; + this.$refs[playlist][0].selectedIndex = index; this.play(playlist, index); }, @@ -244,6 +241,7 @@ export default { //! Play/pause togglePlay(playlist=null, index=0) { if(playlist !== null) { + this.panel = null; let item = this.sets[playlist].get(index); if(!this.playlist || this.playlistName !== playlist || this.loaded != item) { this.play(playlist, index); @@ -257,13 +255,14 @@ export default { }, //! Pin/Unpin an item - togglePin(item) { - let index = this.sets.pin.findIndex(item); + togglePlaylist(playlist, item) { + const set = this.sets[playlist] + let index = set.findIndex(item); if(index > -1) - this.sets.pin.remove(index); + set.remove(index); else { - this.sets.pin.push(item); - this.$refs.pinPlaylistButton.focus(); + set.push(item); + // this.$refs.pinPlaylistButton.focus(); } }, diff --git a/assets/src/components/APlaylist.vue b/assets/src/components/APlaylist.vue index 9b1f4c6..deb9089 100644 --- a/assets/src/components/APlaylist.vue +++ b/assets/src/components/APlaylist.vue @@ -1,21 +1,23 @@ <template> - <div class="playlist"> - <slot name="header"></slot> + <div class="a-playlist"> + <div class="header"><slot name="header"></slot></div> <ul :class="listClass"> - <li v-for="(item,index) in items" :class="itemClass" @click="!hasAction('play') && select(index)" + <li v-for="(item,index) in items" :class="[itemClass, player.isPlaying(item) ? 'is-active' : '']" @click="!hasAction('play') && select(index)" :key="index"> - <a :class="player.isPlaying(item) ? 'is-active' : ''"> - <ASoundItem - :data="item" :index="index" :set="set" :player="player_" - @togglePlay="togglePlay(index)" - :actions="actions"> - <template v-slot:extra-right="{}"> - <button class="button" v-if="editable" @click.stop="remove(index,true)"> - <span class="icon is-small"><span class="fa fa-close"></span></span> - </button> - </template> - </ASoundItem> - </a> + <ASoundItem + :data="item" :index="index" :set="set" :player="player_" + @togglePlay="togglePlay(index)" + :actions="actions"> + <template #after-title="bindings"> + <slot name="after-title" v-bind="bindings"></slot> + </template> + <template #actions="bindings"> + <slot name="actions" v-bind="bindings"></slot> + <button class="button" v-if="editable" @click.stop="remove(index,true)"> + <span class="icon is-small"><span class="fa fa-close"></span></span> + </button> + </template> + </ASoundItem> </li> </ul> <slot name="footer"></slot> @@ -32,9 +34,11 @@ export default { props: { actions: Array, + // FIXME: remove name: String, player: Object, editable: Boolean, + withLink: Boolean }, computed: { diff --git a/assets/src/components/APlaylistEditor.vue b/assets/src/components/APlaylistEditor.vue deleted file mode 100644 index e947950..0000000 --- a/assets/src/components/APlaylistEditor.vue +++ /dev/null @@ -1,329 +0,0 @@ -<template> - <div class="playlist-editor"> - <div class="columns"> - <div class="column"> - <slot name="title" /> - </div> - <div class="column has-text-right"> - <div class="float-right field has-addons"> - <p class="control"> - <a :class="['button','p-2', page == Page.Text ? 'is-primary' : 'is-light']" - @click="page = Page.Text"> - <span class="icon is-small"> - <i class="fa fa-pencil"></i> - </span> - <span>Texte</span> - </a> - </p> - <p class="control"> - <a :class="['button','p-2', page == Page.List ? 'is-primary' : 'is-light']" - @click="page = Page.List"> - <span class="icon is-small"> - <i class="fa fa-list"></i> - </span> - <span>Liste</span> - </a> - </p> - </div> - </div> - </div> - <slot name="top" :set="set" :columns="columns" :items="items"/> - <section class="page" v-show="page == Page.Text"> - <textarea ref="textarea" class="is-fullwidth is-size-6" rows="20" - @change="updateList" - /> - - </section> - <section class="page" v-show="page == Page.List"> - <a-rows :set="set" :columns="columns" :labels="labels" - :allow-create="true" - :orderable="true" @move="listItemMove" @colmove="columnMove" - @cell="onCellEvent"> - <template v-for="[name,slot] of rowsSlots" :key="slot" - v-slot:[slot]="data"> - <slot v-if="name != 'row-tail'" :name="name" v-bind="data"/> - </template> - - <template v-slot:row-tail="data"> - <slot v-if="$slots['row-tail']" :name="row-tail" v-bind="data"/> - <td> - <a class="button is-danger is-outlined p-3 is-size-6" - @click="items.splice(data.row,1)" - :title="labels.remove_track" - :aria-label="labels.remove_track"> - <span class="icon"><i class="fa fa-trash" /></span> - </a> - </td> - </template> - </a-rows> - </section> - - <div class="mt-2"> - <div class="float-right"> - <a class="button is-warning p-2 ml-2" - @click="loadData({items: this.initData.items},true)"> - <span class="icon"><i class="fa fa-rotate" /></span> - <span>{{ labels.discard_changes }}</span> - </a> - <a class="button is-primary p-2 ml-2" t-if="page == page.List" - @click="this.set.push(new this.set.model())"> - <span class="icon"><i class="fa fa-plus"/></span> - <span>{{ labels.add_track }}</span> - </a> - </div> - <div class="field is-inline-block is-vcentered mr-3"> - <label class="label is-inline mr-2" - style="vertical-align: middle"> - Séparateur</label> - <div class="control is-inline-block" - style="vertical-align: middle;"> - <input type="text" ref="sep" class="input is-inline is-text-centered is-small" - style="max-width: 5em;" - v-model="separator" @change="updateList()"/> - </div> - </div> - <div class="field is-inline-block is-vcentered mr-3"> - <label class="label is-inline mr-2" - style="vertical-align: middle"> - {{ labels.columns }}</label> - <table class="table is-bordered is-inline-block" - style="vertical-align: middle"> - <tr> - <a-row :columns="columns" :item="labels" - @move="formatMove" :orderable="true"> - <template v-slot:cell-after="{cell}"> - <td style="cursor:pointer;" v-if="cell.col < columns.length-1"> - <span class="icon" @click="formatMove({from: cell.col, to: cell.col+1})" - ><i class="fa fa-left-right"/> - </span> - </td> - </template> - </a-row> - </tr> - </table> - </div> - <div class="field is-vcentered is-inline-block" - v-if="settingsChanged"> - <a-action-button icon="fa fa-floppy-disk" - class="button control p-3 is-info" run-class="blink" - :url="settingsUrl" method="POST" - :data="settings" - :aria-label="labels.save_settings" - @done="settingsSaved()"> - {{ labels.save_settings }} - </a-action-button> - </div> - </div> - <slot name="bottom" :set="set" :columns="columns" :items="items"/> - </div> -</template> -<script> -import {dropRightWhile, cloneDeep, isEqual} from 'lodash' -import {Set} from '../model' -import Track from '../track' - -import AActionButton from './AActionButton' -import ARow from './ARow.vue' -import ARows from './ARows.vue' - -/// Page display -export const Page = { - Text: 0, List: 1, Settings: 2, -} - -export default { - components: { AActionButton, ARow, ARows }, - props: { - initData: Object, - dataPrefix: String, - labels: Object, - settingsUrl: String, - defaultColumns: { - type: Array, - default: () => ['artist', 'title', 'tags', 'album', 'year', 'timestamp']}, - }, - - data() { - const settings = { - playlist_editor_columns: this.defaultColumns, - playlist_editor_sep: ' -- ', - } - return { - Page: Page, - page: Page.Text, - set: new Set(Track), - extraData: {}, - settings, - savedSettings: cloneDeep(settings), - } - }, - - computed: { - settingsChanged() { - var k = Object.keys(this.savedSettings) - .findIndex(k => !isEqual(this.settings[k], this.savedSettings[k])) - return k != -1 - }, - - separator: { - set(value) { - this.settings.playlist_editor_sep = value - if(this.page == Page.List) - this.updateInput() - }, - get() { return this.settings.playlist_editor_sep } - }, - - columns: { - set(value) { - var cols = value.filter(x => x in this.defaultColumns) - var left = this.defaultColumns.filter(x => !(x in cols)) - value = cols.concat(left) - this.settings.playlist_editor_columns = value - }, - get() { - return this.settings.playlist_editor_columns - } - }, - - items() { - return this.set.items - }, - - rowsSlots() { - return Object.keys(this.$slots) - .filter(x => x.startsWith('row-') || x.startsWith('rows-')) - .map(x => [x, x.startsWith('rows-') ? x.slice(5) : x]) - }, - }, - - methods: { - onCellEvent(event) { - switch(event.name) { - case 'change': this.updateInput(); - break; - } - }, - - formatMove({from, to}) { - const value = this.columns[from] - this.settings.playlist_editor_columns.splice(from, 1) - this.settings.playlist_editor_columns.splice(to, 0, value) - if(this.page == Page.Text) - this.updateList() - else - this.updateInput() - }, - - columnMove({from, to}) { - const value = this.columns[from] - this.columns.splice(from, 1) - this.columns.splice(to, 0, value) - this.updateInput() - }, - - listItemMove({from, to, set}) { - set.move(from, to); - this.updateInput() - }, - - updateList() { - const items = this.toList(this.$refs.textarea.value) - this.set.reset(items) - }, - - updateInput() { - const input = this.toText(this.items) - this.$refs.textarea.value = input - }, - - /** - * From input and separator, return list of items. - */ - toList(input) { - var lines = input.split('\n') - var items = [] - - for(let line of lines) { - line = line.trimLeft() - if(!line) - continue - - var lineBits = line.split(this.separator) - var item = {} - for(var col in this.columns) { - if(col >= lineBits.length) - break - const attr = this.columns[col] - item[attr] = lineBits[col].trim() - } - item && items.push(item) - } - return items - }, - - /** - * From items and separator return a string - */ - toText(items) { - const sep = ` ${this.separator.trim()} ` - const lines = [] - for(let item of items) { - if(!item) - continue - var line = [] - for(var col of this.columns) - line.push(item.data[col] || '') - line = dropRightWhile(line, x => !x || !('' + x).trim()) - line = line.join(sep).trimRight() - lines.push(line) - } - return lines.join('\n') - }, - - - _data_key(key) { - key = key.slice(this.dataPrefix.length) - try { - var [index, attr] = key.split('-', 1) - return [Number(index), attr] - } - catch(err) { - return [null, key] - } - }, - - //! Update saved settings from this.settings - settingsSaved(settings=null) { - if(settings !== null) - this.settings = settings - this.savedSettings = cloneDeep(this.settings) - }, - - /** - * Load initial data - */ - loadData({items=[], settings=null}, reset=false) { - if(reset) { - this.set.items = [] - } - for(var index in items) - this.set.push(cloneDeep(items[index])) - if(settings) - this.settingsSaved(settings) - this.updateInput() - }, - }, - - watch: { - initData(val) { - this.loadData(val) - }, - }, - - mounted() { - this.initData && this.loadData(this.initData) - this.page = this.items.length ? Page.List : Page.Text - }, -} -</script> diff --git a/assets/src/components/AProgress.vue b/assets/src/components/AProgress.vue index a504184..3afe369 100644 --- a/assets/src/components/AProgress.vue +++ b/assets/src/components/AProgress.vue @@ -1,15 +1,20 @@ <template> - <div class="media"> - <div class="media-left"> - <slot name="value" :value="valueDisplay" :max="max">{{ format(valueDisplay) }}</slot> - </div> - <div ref="bar" class="media-content" @click.stop="onClick" @mouseleave.stop="onMouseMove" + <div class="a-progress m-0"> + <time class="time-now"> + <slot name="value" :value="value" :max="max">{{ format(value) }}</slot> + </time> + <div ref="bar" class="a-progress-bar-container" @click.stop="onClick" @mouseleave.stop="onMouseMove" @mousemove.stop="onMouseMove"> - <div :class="progressClass" :style="progressStyle"> </div> + <div :class="progressClass" :style="progressStyle"> + <time v-if="hoverValue"> + {{ format(hoverValue) }} + </time> + <template v-else> </template> + </div> </div> - <div class="media-right"> + <time class="time-total"> <slot name="value" :value="valueDisplay" :max="max">{{ format(max) }}</slot> - </div> + </time> </div> </template> @@ -25,7 +30,7 @@ export default { value: Number, max: Number, format: { type: Function, default: x => x }, - progressClass: { default: 'has-background-primary' }, + progressClass: { default: 'a-progress-bar' }, vertical: { type: Boolean, default: false }, }, diff --git a/assets/src/components/ARow.vue b/assets/src/components/ARow.vue index 02e5ec8..171734c 100644 --- a/assets/src/components/ARow.vue +++ b/assets/src/components/ARow.vue @@ -1,25 +1,25 @@ <template> <tr> - <slot name="head" :item="item" :row="row"/> + <slot name="head" :context="context" :item="item" :row="row"/> <template v-for="(attr,col) in columns" :key="col"> - <slot name="cell-before" :item="item" :cell="cells[col]" + <slot name="cell-before" :context="context" :item="item" :cell="cells[col]" :attr="attr"/> <component :is="cellTag" :class="['cell', 'cell-' + attr]" :data-col="col" :draggable="orderable" @dragstart="onDragStart" @dragover="onDragOver" @drop="onDrop"> - <slot :name="attr" :item="item" :cell="cells[col]" + <slot :name="attr" :context="context" :item="item" :cell="cells[col]" :data="itemData" :attr="attr" :emit="cellEmit" :value="itemData && itemData[attr]"> {{ itemData && itemData[attr] }} </slot> - <slot name="cell" :item="item" :cell="cells[col]" + <slot name="cell" :context="context" :item="item" :cell="cells[col]" :data="itemData" :attr="attr" :emit="cellEmit" :value="itemData && itemData[attr]"/> </component> - <slot name="cell-after" :item="item" :col="col" :cell="cells[col]" + <slot name="cell-after" :context="context" :item="item" :col="col" :cell="cells[col]" :attr="attr"/> </template> - <slot name="tail" :item="item" :row="row"/> + <slot name="tail" :context="context" :item="item" :row="row"/> </tr> </template> <script> @@ -27,12 +27,17 @@ import {isReactive, toRefs} from 'vue' import Model from '../model' export default { - emit: ['move', 'cell'], + emits: ['move', 'cell'], props: { + //! Context object + context: {type: Object, default: () => ({})}, //! Item to display in row - item: Object, + item: {type: Object, default: () => ({})}, //! Columns to display, as items' attributes + //! - name: field name / item attribute value + //! - label: display label + //! - help: help text columns: Array, //! Default cell's info cell: {type: Object, default() { return {row: 0}}}, diff --git a/assets/src/components/ARows.vue b/assets/src/components/ARows.vue index 7ba494e..d214bc9 100644 --- a/assets/src/components/ARows.vue +++ b/assets/src/components/ARows.vue @@ -1,34 +1,38 @@ <template> <table class="table is-stripped is-fullwidth"> <thead> - <a-row :item="labels" :columns="columns" :orderable="orderable" - @move="$emit('colmove', $event)"> + <a-row :context="context" :columns="columnNames" + :orderable="columnsOrderable" cellTag="th" + @move="moveColumn"> <template v-if="$slots['header-head']" v-slot:head="data"> <slot name="header-head" v-bind="data"/> </template> <template v-if="$slots['header-tail']" v-slot:tail="data"> <slot name="header-tail" v-bind="data"/> </template> + <template v-for="column of columns" v-bind:key="column.name" + v-slot:[column.name]="data"> + <slot :name="'header-' + column.name" v-bind="data"> + {{ column.label }} + <span v-if="column.help" class="icon small" + :title="column.help"> + <i class="fa fa-circle-question"/> + </span> + </slot> + </template> </a-row> </thead> <tbody> <slot name="head"/> <template v-for="(item,row) in items" :key="row"> <!-- data-index comes from AList component drag & drop --> - <a-row :item="item" :cell="{row}" :columns="columns" :data-index="row" + <a-row :context="context" :item="item" :cell="{row}" :columns="columnNames" :data-index="row" :data-row="row" :draggable="orderable" @dragstart="onDragStart" @dragover="onDragOver" @drop="onDrop" @cell="onCellEvent(row, $event)"> <template v-for="[name,slot] of rowSlots" :key="slot" v-slot:[slot]="data"> - <template v-if="slot == 'head' || slot == 'tail'"> - <slot :name="name" v-bind="data"/> - </template> - <template v-else> - <div> - <slot :name="name" v-bind="data"/> - </div> - </template> + <slot :name="name" v-bind="data"/> </template> </a-row> </template> @@ -43,29 +47,41 @@ import ARow from './ARow.vue' const Component = { extends: AList, components: { ARow }, - emit: ['cell', 'colmove'], + //! Event: + //! - cell(event): an event occured inside cell + //! - colmove({from,to}), colmove(): columns moved + emits: ['cell', 'colmove'], props: { ...AList.props, + //! Context object + context: {type: Object, default: () => ({})}, + + //! Ordered list of columns, as objects with: + //! - name: item attribute value + //! - label: display label + //! - help: help text + //! - hidden: if true, field is hidden columns: Array, - labels: Object, - allowCreate: Boolean, + //! If True, columns are orderable + columnsOrderable: Boolean, }, data() { return { ...super.data, + // TODO: add observer + columns_: [...this.columns], extraItem: new this.set.model(), } }, computed: { - rowCells() { - const cells = [] - for(var row in this.items) - cells.push({row}) - }, - + columnNames() { return this.columns_.map(c => c.name) }, + columnLabels() { return this.columns_.reduce( + (labels, c) => ({...labels, [c.name]: c.label}), + {} + )}, rowSlots() { return Object.keys(this.$slots).filter(x => x.startsWith('row-')) .map(x => [x, x.slice(4)]) @@ -73,6 +89,25 @@ const Component = { }, methods: { + // TODO: use in tracklist + sortColumns(names) { + const ordered = names.map(n => this.columns_.find(c => c.name == n)).filter(c => !!c); + const remaining = this.columns_.filter(c => names.indexOf(c.name) == -1) + this.columns_ = [...ordered, ...remaining] + this.$emit('colmove') + }, + + /** + * Move column using provided event object (as `{from, to}`) + */ + moveColumn(event) { + const {from, to} = event + const value = this.columns_[from] + this.columns_.splice(from, 1) + this.columns_.splice(to, 0, value) + this.$emit('colmove', event) + }, + /** * React on 'cell' event, re-emitting it with additional values: * - `set`: data set diff --git a/assets/src/components/ASelectFile.vue b/assets/src/components/ASelectFile.vue new file mode 100644 index 0000000..19a61aa --- /dev/null +++ b/assets/src/components/ASelectFile.vue @@ -0,0 +1,167 @@ +<template> + <a-modal ref="modal" :title="title"> + <template #bar> + <button type="button" class="button small mr-3" v-if="panel == LIST" + @click="showPanel(UPLOAD)"> + <span class="icon"> + <i class="fa fa-upload"></i> + </span> + <span>{{ labels.upload }}</span> + </button> + + <button type="button" class="button small mr-3" v-else + @click="showPanel(LIST)"> + <span class="icon"> + <i class="fa fa-list"></i> + </span> + <span>{{ labels.list }}</span> + </button> + </template> + <template #default> + <a-file-upload ref="upload" v-if="panel == UPLOAD" + :url="uploadUrl" + :label="uploadLabel" :field-name="uploadFieldName" + @load="uploadDone"> + <template #form="data"> + <slot name="upload-form" v-bind="data"></slot> + </template> + <template #preview="data"> + <slot name="upload-preview" v-bind="data"></slot> + </template> + </a-file-upload> + <div class="a-select-file" v-else> + <div ref="list" + :class="['a-select-file-list', listClass]"> + <!-- tiles --> + <div v-if="prevUrl"> + <a href="#" @click="load(prevUrl)"> + {{ labels.show_previous }} + </a> + </div> + + <template v-for="item in items" v-bind:key="item.id"> + <div :class="['file-preview', this.item && item.id == this.item.id && 'active']" @click="select(item)"> + <slot :item="item" :load="load" :lastUrl="lastUrl"></slot> + <a-action-button v-if="deleteUrl" + class="has-text-danger small float-right" + icon="fa fa-trash" + :confirm="labels.confirm_delete" + method="DELETE" + :url="deleteUrl.replace('123', item.id)" + @done="load(lastUrl)"> + </a-action-button> + </div> + </template> + + <div v-if="nextUrl"> + <a href="#" @click="load(nextUrl)"> + {{ labels.show_next }} + </a> + </div> + </div> + </div> + </template> + <template #footer> + <slot name="footer" :item="item"> + <span class="mr-3" v-if="item">{{ item.name }}</span> + </slot> + <button type="button" v-if="panel == LIST" class="button align-right" + @click="selected"> + {{ labels.select_file }} + </button> + </template> + </a-modal> +</template> +<script> +import AModal from "./AModal" +import AActionButton from "./AActionButton" +import AFileUpload from "./AFileUpload" + +export default { + emit: ["select"], + + components: {AActionButton, AFileUpload, AModal}, + + props: { + title: { type: String }, + labels: Object, + listClass: {type: String, default: ""}, + + // List url + listUrl: { type: String }, + + // URL to delete an item, where "123" is replaced by + // the item id. + deleteUrl: {type: String }, + + uploadUrl: { type: String }, + uploadFieldName: { type: String, default: "file" }, + uploadLabel: { type: String, default: "Upload a file" }, + }, + + data() { + return { + LIST: 0, + UPLOAD: 1, + + panel: 0, + item: null, + items: [], + nextUrl: "", + prevUrl: "", + lastUrl: "", + } + }, + + methods: { + open() { + this.$refs.modal.open() + }, + + close() { + this.$refs.modal.close() + }, + + showPanel(panel) { + this.panel = panel + }, + + load(url) { + return fetch(url || this.listUrl).then( + response => response.ok ? response.json() : Promise.reject(response) + ).then(data => { + this.lastUrl = url + this.nextUrl = data.next + this.prevUrl = data.previous + this.items = data.results + this.showPanel(this.LIST) + + this.$forceUpdate() + this.$refs.list.scroll(0, 0) + return this.items + }) + }, + + //! Select an item + select(item) { + this.item = item; + }, + + //! User click on select button (confirm selection) + selected() { + this.$emit("select", this.item) + this.close() + }, + + uploadDone(reload=false) { + reload && this.load().then(items => { + this.item = items[0] + }) + }, + }, + + mounted() { + this.load() + }, +} +</script> diff --git a/assets/src/components/ASoundItem.vue b/assets/src/components/ASoundItem.vue index eb654f2..1d2aa5f 100644 --- a/assets/src/components/ASoundItem.vue +++ b/assets/src/components/ASoundItem.vue @@ -1,32 +1,30 @@ <template> - <div class="media sound-item"> - <div class="media-left" @click.stop="$emit('togglePlay')"> - <img class="cover is-tiny" :src="item.data.cover" v-if="item.data.cover"> - </div> - <div class="media-content"> - <slot name="content" :player="player" :item="item" :loaded="loaded"> - <h4 class="title is-5" @click.stop="$emit('togglePlay')"> - <span class="icon is-small is-size-7 blink" v-if="playing"> - <span class="fa fa-play"></span> - </span> - {{ name || item.name }} - </h4> - <a class="subtitle is-6 is-inline-block" v-if="hasAction('page') && item.data.page_url" + <div :class="['a-sound-item m-0 button-group', playing && 'playing' || '']"> + <slot name="title" :player="player" :item="item" :loaded="loaded"> + <span :class="['label is-flex-grow-1 align-left', playing && 'blink' || '']" @click.stop="$emit('togglePlay')"> + {{ name || item.name }} + </span> + </slot> + <slot name="after-title" :player="player" :item="item" :loaded="loaded"> + </slot> + <div class="button-group actions"> + <a class="button action" v-if="hasAction('page')" :href="item.data.page_url"> - {{ item.data.page_title }} - </a> - </slot> - </div> - <div class="media-right"> - <a class="button" v-if="item.data.is_downloadable" + <span class="icon is-small"> + <i class="fa fa-external-link"></i> + </span> + </a> + <a class="button action" + v-if="hasAction('download') && item.data.is_downloadable" :href="item.data.url" target="_blank"> <span class="icon is-small"> <span class="fa fa-download"></span> </span> </a> - <button class="button" v-if="player && player.sets.pin != $parent.set" @click.stop="player.togglePin(item)"> + <button :class="['button action', pinned ? 'selected' : 'not-selected']" + v-if="hasAction('pin') && player && player.sets.pin != $parent.set" @click.stop="player.togglePlaylist('pin', item)"> <span class="icon is-small"> - <span :class="(pinned ? '' : 'has-text-grey-light ') + 'fa fa-thumbtack'"></span> + <span class="fa fa-star"></span> </span> </button> <slot name="actions" :player="player" :item="item" :loaded="loaded"></slot> diff --git a/assets/src/components/ASoundListEditor.vue b/assets/src/components/ASoundListEditor.vue new file mode 100644 index 0000000..8a8b49a --- /dev/null +++ b/assets/src/components/ASoundListEditor.vue @@ -0,0 +1,83 @@ +<template> + <div class="a-playlist-editor"> + <a-select-file ref="select-file" + :title="labels && labels.add_sound" + :labels="labels" + :list-url="soundListUrl" + :deleteUrl="soundDeleteUrl" + :uploadUrl="soundUploadUrl" + :uploadLabel="labels.select_file" + @select="selected" + > + <template #upload-preview="{upload}"> + <slot name="upload-preview" :upload="upload"></slot> + </template> + <template #upload-form> + <slot name="upload-form"></slot> + </template> + <template #default="{item}"> + <audio controls :src="item.url"></audio> + <label class="label small flex-grow-1">{{ item.name }}</label> + </template> + </a-select-file> + + <a-form-set ref="formset" :form-data="formData" :labels="labels" + :initials="initData.items" + order-by="position" + :action-add="actionAdd"> + <template v-for="[name,slot] of rowsSlots" :key="slot" + v-slot:[slot]="data"> + <slot v-if="name != 'row-tail'" :name="name" v-bind="data"/> + </template> + + <template #row-sound="{item,inputName}"> + <label>{{ item.data.name }}</label><br> + <audio controls :src="item.data.url"/> + <input type="hidden" :name="inputName" :value="item.data.sound"/> + </template> + </a-form-set> + </div> +</template> +<script> +import AFormSet from './AFormSet' +import ASelectFile from "./ASelectFile" + +export default { + components: {AFormSet, ASelectFile}, + + props: { + formData: Object, + labels: Object, + // initial datas + initData: Object, + + soundListUrl: String, + soundUploadUrl: String, + soundDeleteUrl: String, + }, + + computed: { + rowsSlots() { + return Object.keys(this.$slots) + .filter(x => x.startsWith('row-') || x.startsWith('rows-') || x.startsWith('control-')) + .map(x => [x, x.startsWith('rows-') ? x.slice(5) : x]) + }, + }, + + methods: { + actionAdd() { + this.$refs['select-file'].open() + }, + + selected(item) { + const data = { + "sound": item.id, + "name": item.name, + "url": item.url, + "broadcast": item.broadcast, + } + this.$refs.formset.set.push(data) + }, + }, +} +</script> diff --git a/assets/src/components/AStatistics.vue b/assets/src/components/AStatistics.vue index e679e4d..deb5f2e 100644 --- a/assets/src/components/AStatistics.vue +++ b/assets/src/components/AStatistics.vue @@ -5,7 +5,7 @@ </template> <script> -const splitReg = new RegExp(',\\s*', 'g'); +const splitReg = new RegExp(',\\s*|\\s+', 'g'); export default { data() { @@ -22,7 +22,8 @@ export default { for(var item of items) if(item.value) for(var tag of item.value.split(splitReg)) - counts[tag.trim()] = (counts[tag.trim()] || 0) + 1; + if(tag.trim()) + counts[tag.trim()] = (counts[tag.trim()] || 0) + 1; this.counts = counts; }, diff --git a/assets/src/components/ASwitch.vue b/assets/src/components/ASwitch.vue new file mode 100644 index 0000000..c979c50 --- /dev/null +++ b/assets/src/components/ASwitch.vue @@ -0,0 +1,80 @@ +<template> + <button :title="ariaLabel" + type="button" + :aria-label="ariaLabel || label" :aria-description="ariaDescription" + @click="toggle" :class="buttonClass"> + <slot name="default" :active="active"> + <span class="icon"> + <i :class="icon"></i> + </span> + <label v-if="label">{{ label }}</label> + </slot> + </button> +</template> +<script> +export default { + props: { + initialActive: {type: Boolean, default: null}, + el: {type: String, default: ""}, + label: {type: String, default: ""}, + icon: {type: String, default: "fa fa-bars"}, + ariaLabel: {type: String, default: ""}, + ariaDescription: {type: String, default: ""}, + activeClass: {type: String, default:"active"}, + /// switch toggle of all items of this group. + group: {type: String, default: ""}, + }, + + data() { + return { + active: this.initialActive, + } + }, + + computed: { + groupClass() { + return this.group && "a-switch-" + this.group || '' + }, + + buttonClass() { + return [ + this.active && 'active' || '', + this.groupClass + ] + } + }, + + methods: { + toggle() { + this.set(!this.active) + }, + + set(active) { + if(this.el) { + const el = document.querySelector(this.el) + if(active) + el.classList.add(this.activeClass) + else + el.classList.remove(this.activeClass) + } + this.active = active + if(active) + this.resetGroup() + }, + + resetGroup() { + if(!this.groupClass) + return + const els = document.querySelectorAll("." + this.groupClass) + for(var el of els) + if(el != this.$el) + el.__vnode.ctx.ctx.set(false) + }, + }, + + mounted() { + if(this.initialActive !== null) + this.set(this.initialActive) + }, +} +</script> diff --git a/assets/src/components/ATrackListEditor.vue b/assets/src/components/ATrackListEditor.vue new file mode 100644 index 0000000..4285712 --- /dev/null +++ b/assets/src/components/ATrackListEditor.vue @@ -0,0 +1,288 @@ +<template> + <div class="a-tracklist-editor"> + <div class="flex-row"> + <div class="flex-grow-1"> + <slot name="title" /> + </div> + <div class="flex-row align-right"> + <div class="field has-addons"> + <p class="control"> + <button type="button" :class="['button','p-2', page == Page.Text ? 'is-primary' : 'is-light']" + @click="page = Page.Text"> + <span class="icon is-small"> + <i class="fa fa-pencil"></i> + </span> + <span>{{ labels.text }}</span> + </button> + </p> + <p class="control"> + <button type="button" :class="['button','p-2', page == Page.List ? 'is-primary' : 'is-light']" + @click="page = Page.List"> + <span class="icon is-small"> + <i class="fa fa-list"></i> + </span> + <span>{{ labels.list }}</span> + </button> + </p> + <p class="control ml-3"> + <button type="button" class="button is-info square" + :title="labels.settings" + @click="$refs.settings.open()"> + <span class="icon is-small"> + <i class="fa fa-cog"></i> + </span> + </button> + </p> + </div> + </div> + </div> + <section v-show="page == Page.Text" class="panel"> + <textarea ref="textarea" class="is-fullwidth is-size-6" rows="20" + @change="updateList" + /> + + </section> + <section v-show="page == Page.List" class="panel"> + <a-form-set ref="formset" + :form-data="formData" :initials="initData.items" + :columnsOrderable="true" :labels="labels" + order-by="position" + @load="updateInput" @colmove="onColumnMove" @move="updateInput" + @cell="onCellEvent"> + <template v-for="[name,slot] of rowsSlots" :key="slot" + v-slot:[slot]="data"> + <slot v-if="name != 'row-tail'" :name="name" v-bind="data"/> + </template> + </a-form-set> + </section> + + <a-modal ref="settings" :title="labels.settings"> + <template #default> + <div class="field"> + <label class="label" style="vertical-align: middle"> + {{ labels.columns }} + </label> + <table class="table is-bordered" + style="vertical-align: middle"> + <tr v-if="$refs.formset"> + <a-row :columns="$refs.formset.rows.columnNames" + :item="$refs.formset.rows.columnLabels" + @move="$refs.formset.rows.moveColumn" + > + <template v-slot:cell-after="{cell}"> + <td style="cursor:pointer;" v-if="cell.col < $refs.formset.rows.columns_.length-1"> + <span class="icon" @click="$refs.formset.rows.moveColumn({from: cell.col, to: cell.col+1})" + ><i class="fa fa-left-right"/> + </span> + </td> + </template> + </a-row> + </tr> + </table> + </div> + + <div class="flex-row"> + <div class="field is-inline-block is-vcentered flex-grow-1"> + <label class="label is-inline mr-2" + style="vertical-align: middle"> + Séparateur</label> + <div class="control is-inline-block" + style="vertical-align: middle;"> + <input type="text" ref="sep" class="input is-inline is-text-centered is-small" + style="max-width: 5em;" + v-model="separator" @change="updateList()"/> + </div> + </div> + </div> + </template> + <template #footer> + <div class="flex-row align-right"> + <a-action-button icon="fa fa-floppy-disk" + v-if="settingsChanged" + class="button control p-2 mr-3 is-secondary" run-class="blink" + :url="settingsUrl" method="POST" + :data="settings" + :aria-label="labels.save_settings" + @done="settingsSaved()"> + {{ labels.save_settings }} + </a-action-button> + <button class="button" type="button" @click="$refs.settings.close()"> + Fermer + </button> + </div> + </template> + </a-modal> + </div> +</template> +<script> +import {dropRightWhile, cloneDeep, isEqual} from 'lodash' + +import AActionButton from './AActionButton' +import AFormSet from './AFormSet' +import ARow from './ARow' +import AModal from "./AModal" + +/// Page display +export const Page = { + Text: 0, List: 1, Settings: 2, +} + +export default { + components: { AActionButton, AFormSet, ARow, AModal }, + props: { + formData: Object, + labels: Object, + + ///! initial data as: {items: [], fields: {column_name: label, settings: {}} + initData: Object, + dataPrefix: String, + settingsUrl: String, + defaultColumns: { + type: Array, + default: () => ['artist', 'title', 'tags', 'album', 'year', 'timestamp']}, + }, + + data() { + const settings = { + // tracklist_editor_columns: this.columns, + tracklist_editor_sep: ' -- ', + } + return { + Page: Page, + page: Page.Text, + extraData: {}, + settings, + savedSettings: cloneDeep(settings), + } + }, + + computed: { + rows() { return this.$refs.formset && this.$refs.formset.rows }, + columns() { return this.rows && this.rows.columns_ || [] }, + + settingsChanged() { + var k = Object.keys(this.savedSettings) + .findIndex(k => !isEqual(this.settings[k], this.savedSettings[k])) + return k != -1 + }, + + separator: { + set(value) { + this.settings.tracklist_editor_sep = value + if(this.page == Page.List) + this.updateInput() + }, + get() { return this.settings.tracklist_editor_sep } + }, + + rowsSlots() { + return Object.keys(this.$slots) + .filter(x => x.startsWith('row-') || x.startsWith('rows-') || x.startsWith('control-')) + .map(x => [x, x.startsWith('rows-') ? x.slice(5) : x]) + }, + }, + + methods: { + onCellEvent(event) { + switch(event.name) { + case 'change': this.updateInput(); + break; + } + }, + + onColumnMove() { + this.settings.tracklist_editor_columns = this.$refs.formset.rows.columnNames + if(this.page == this.Page.List) + this.updateInput() + else + this.updateList() + }, + + updateList() { + const items = this.toList(this.$refs.textarea.value) + this.$refs.formset.set.reset(items) + }, + + updateInput() { + const input = this.toText(this.$refs.formset.items) + this.$refs.textarea.value = input + }, + + /** + * From input and separator, return list of items. + */ + toList(input) { + const columns = this.$refs.formset.rows.columns_ + var lines = input.split('\n') + var items = [] + + for(let line of lines) { + line = line.trimLeft() + if(!line) + continue + + var lineBits = line.split(this.separator) + var item = {} + for(var col in columns) { + if(col >= lineBits.length) + break + const column = columns[col] + item[column.name] = lineBits[col].trim() + } + item && items.push(item) + } + return items + }, + + /** + * From items and separator return a string + */ + toText(items) { + const columns = this.$refs.formset.rows.columns_ + const sep = ` ${this.separator.trim()} ` + const lines = [] + for(let item of items) { + if(!item) + continue + var line = [] + for(var col of columns) + line.push(item.data[col.name] || '') + line = dropRightWhile(line, x => !x || !('' + x).trim()) + line = line.join(sep).trimRight() + lines.push(line) + } + return lines.join('\n') + }, + + + _data_key(key) { + key = key.slice(this.dataPrefix.length) + try { + var [index, attr] = key.split('-', 1) + return [Number(index), attr] + } + catch(err) { + return [null, key] + } + }, + + //! Update saved settings from this.settings + settingsSaved(settings=null) { + if(settings !== null) + this.settings = settings + if(this.$refs.settings) + this.$refs.settings.close() + this.savedSettings = cloneDeep(this.settings) + }, + }, + + mounted() { + const settings = this.initData && this.initData.settings + if(settings) { + this.settingsSaved(settings) + this.rows.sortColumns(settings.tracklist_editor_columns) + } + this.page = this.initData.items.length ? Page.List : Page.Text + }, +} +</script> diff --git a/assets/src/components/admin.js b/assets/src/components/admin.js new file mode 100644 index 0000000..4433e00 --- /dev/null +++ b/assets/src/components/admin.js @@ -0,0 +1,23 @@ +import AFileUpload from "./AFileUpload.vue" +import ASelectFile from "./ASelectFile.vue" +import AStatistics from './AStatistics.vue' +import AStreamer from './AStreamer.vue' + +import AFormSet from './AFormSet.vue' +import ATrackListEditor from './ATrackListEditor.vue' +import ASoundListEditor from './ASoundListEditor.vue' + +import AManyToManyEdit from "./AManyToManyEdit.vue" + +import base from "./index.js" + + +export const admin = { + ...base, + AManyToManyEdit, + AFileUpload, ASelectFile, + AFormSet, ATrackListEditor, ASoundListEditor, + AStatistics, AStreamer, +} + +export default admin diff --git a/assets/src/components/index.js b/assets/src/components/index.js index cebbd78..9160578 100644 --- a/assets/src/components/index.js +++ b/assets/src/components/index.js @@ -1,26 +1,26 @@ import AAutocomplete from './AAutocomplete.vue' +import AModal from "./AModal.vue" +import AActionButton from './AActionButton.vue' +import ADropdown from "./ADropdown.vue" +import ACarousel from './ACarousel.vue' import AEpisode from './AEpisode.vue' import AList from './AList.vue' import APage from './APage.vue' import APlayer from './APlayer.vue' import APlaylist from './APlaylist.vue' -import APlaylistEditor from './APlaylistEditor.vue' import AProgress from './AProgress.vue' import ASoundItem from './ASoundItem.vue' -import AStatistics from './AStatistics.vue' -import AStreamer from './AStreamer.vue' +import ASwitch from './ASwitch.vue' + /** * Core components */ export const base = { - AAutocomplete, AEpisode, AList, APage, APlayer, APlaylist, - AProgress, ASoundItem, + AActionButton, AAutocomplete, AModal, + ACarousel, ADropdown, AEpisode, AList, APage, APlayer, APlaylist, + AProgress, ASoundItem, ASwitch, + } export default base - -export const admin = { - ...base, - AStatistics, AStreamer, APlaylistEditor -} diff --git a/assets/src/index.js b/assets/src/index.js index 0bb8482..dc89a1c 100644 --- a/assets/src/index.js +++ b/assets/src/index.js @@ -2,28 +2,27 @@ * This module includes code available for both the public website and * administration interface) */ -//-- vendor -import '@fortawesome/fontawesome-free/css/all.min.css'; +import 'vue' //-- aircox import App, {PlayerApp} from './app' -import Builder from './appBuilder' +import VueLoader from './vueLoader' import Sound from './sound' import {Set} from './model' -import './assets/styles.scss' +import './styles/common.scss' window.aircox = { // main application - builder: new Builder(App), - get app() { return this.builder.app }, + loader: null, + get app() { return this.loader.app }, // player application - playerBuilder: new Builder(PlayerApp), - get playerApp() { return this.playerBuilder && this.playerBuilder.app }, - get player() { return this.playerBuilder.vm && this.playerBuilder.vm.$refs.player }, + playerLoader: null, + get playerApp() { return this.playerLoader && this.playerLoader.app }, + get player() { return this.playerLoader.vm && this.playerLoader.vm.$refs.player }, Set, Sound, @@ -31,30 +30,38 @@ window.aircox = { /** * Initialize main application and player. */ - init(props=null, {config=null, builder=null, initBuilder=true, - initPlayer=true, hotReload=false, el=null}={}) + init(props=null, {hotReload=false, el=null, + config=null, playerConfig=null, + initApp=true, initPlayer=true, + loader=null, playerLoader=null}={}) { if(initPlayer) { - let playerBuilder = this.playerBuilder - playerBuilder.mount() + playerConfig = playerConfig || PlayerApp + playerLoader = playerLoader || new VueLoader(playerConfig) + playerLoader.enable(false) + this.playerLoader = playerLoader + + document.addEventListener("keyup", e => this.onKeyPress(e), false) } - if(initBuilder) { - builder = builder || this.builder - this.builder = builder - if(config || window.App) - builder.config = config || window.App - if(el) - builder.config.el = el - - builder.title = document.title - builder.mount({props}) - - if(hotReload) - builder.enableHotReload(hotReload) + if(initApp) { + config = config || window.App || App + config.el = el || config.el + loader = loader || new VueLoader({el, props, ...config}) + loader.enable(hotReload) + this.loader = loader } }, + onKeyPress(/*event*/) { + /* + if(event.key == " ") { + this.player.togglePlay() + event.stopPropagation() + } + */ + }, + /** * Filter navbar dropdown menu items */ @@ -68,5 +75,10 @@ window.aircox = { else for(let item of container.querySelectorAll('a.navbar-item')) item.style.display = null; + }, + + pickDate(url, date) { + url = `${url}?date=${date.id}` + this.loader.pageLoad.load(url) } } diff --git a/assets/src/live.js b/assets/src/live.js index 09674df..de4e4b1 100644 --- a/assets/src/live.js +++ b/assets/src/live.js @@ -30,6 +30,7 @@ export default class Live { response.ok ? response.json() : Promise.reject(response) ).then(data => { + data = data.results data.forEach(item => { if(item.start) item.start = new Date(item.start) if(item.end) item.end = new Date(item.end) diff --git a/assets/src/model.js b/assets/src/model.js index 6b80592..9d8bfac 100644 --- a/assets/src/model.js +++ b/assets/src/model.js @@ -41,9 +41,8 @@ export default class Model { this.commit(data); } - get errors() { - return this.data && this.data.__errors__ - } + get created() { return !this.id } + get errors() { return this.data && this.data.__errors__ } /** * Get instance id from its data @@ -113,7 +112,7 @@ export default class Model { } /** - * Update instance's data with provided data. Return None + * Set instance's data with provided data. Return None */ commit(data) { this.data = data; @@ -121,11 +120,17 @@ export default class Model { } /** - * Update model data, without reset previous value + * Update model data, without reset previous value. + * Item is marked as updated. */ update(data) { this.data = {...this.data, ...data} this.id = this.constructor.getId(this.data) + this.updated = true + } + + delete() { + this.deleted = true } /** @@ -177,8 +182,24 @@ export class Set { this.push(item, {args: args, save: false}); } + //! Return total items count get length() { return this.items.length } + //! Return a list of items marked as deleted + get deletedItems() { + return this.items.filter(i => i.deleted) + } + + //! Return a list of created items + get createdItems() { + return this.items.filter(i => !i.deleted && !i.id) + } + + //! Return a list of updated items + get updatedItems() { + return this.items.filter(i => i.updated) + } + /** * Fetch multiple items from server */ @@ -190,6 +211,63 @@ export class Set { .map(d => new model(d, {url: url, ...args}))) } + fetch({url=null, reset=false, ...options}={}, args=null) { + url = url || this.url + options = this.model.getOptions(options) + return fetch(url, options) + .then(response => response.json()) + .then(data => + (data instanceof Array ? data : data.results) + .map(d => new this.model(d, {url: url, ...args})) + ) + .then(data => { + if(reset) + this.items = data + else + // TODO: remove duplicate + this.items = [...this.items, ...data] + return data + }) + } + + /** + * Commit changes to server. + * py-ref: `views.mixin.ListCommitMixin` + */ + commit(url, {getData=null, fields=null, ...options}={}) { + if(!getData && fields) + getData = (i) => fields.reduce((r, f) => { + r[f] = i.data[f] + return r + }, {}) + const createdItems = this.createdItems + const body = { + delete: this.deletedItems.map(i => i.id), + update: this.updatedItems.map(getData), + create: createdItems.map(getData), + } + if(!body.delete && !body.update && !body.create) + return + + getData = getData || ((i) => i.data); + options = this.model.getOptions(options) + options.method = "POST" + options.body = JSON.stringify(body) + return fetch(url, options) + .then(response => response.json()) + .then(data => { + const {created, updated, deleted} = data + if(createdItems) + this.items = this.items.filter(i => createdItems.indexOf(i) == -1) + if(deleted) + this.items = this.items.filter(i => deleted.indexOf(i.id) == -1) + + this.extend(created) + this.extend(updated) + return data + }) + } + /** * Load list from localStorage */ @@ -234,22 +312,30 @@ export class Set { : this.items.findIndex(x => x.id == pred.id); } + extend(items, options) { + items.forEach(i => this.push(i, options)) + } + /** * Add item to set, return index. + * If item already exists, replace it. */ push(item, {args={},save=true}={}) { item = item instanceof this.model ? item : new this.model(item, args); - if(this.unique) { - let index = this.findIndex(item); + let index = -1 + if(this.unique && item.id) { + index = this.findIndex(item); if(index > -1) - return index; + this.items[index] = item } - if(this.max && this.items.length >= this.max) - this.items.splice(0,this.items.length-this.max) - - this.items.push(item); - save && this.save(); - return this.items.length-1; + if(index == -1) { + if(this.max && this.items.length >= this.max) + this.items.splice(0,this.items.length-this.max) + this.items.push(item) + index = this.items.length-1 + } + save && this.save() + return index; } /** diff --git a/assets/src/pageLoad.js b/assets/src/pageLoad.js new file mode 100644 index 0000000..10c8a05 --- /dev/null +++ b/assets/src/pageLoad.js @@ -0,0 +1,174 @@ + +/** + * Load page without leaving current one (hot-reload). + */ +export default class PageLoad { + constructor(el, {loadingClass="loading", append=false}={}) { + this.el = el + this.append = append + this.loadingClass = loadingClass + } + + get target() { + if(!this._target) + this._target = document.querySelector(this.el) + return this._target + } + + reset() { + this._target = null + } + + /** + * Enable hot reload: catch page change in order to fetch them and + * load page without actually leaving current one. + */ + enable(target=null) { + if(this._pageChanged) + throw "Already enabled, please disable me" + + if(!target) + target = this.target || document.body + this.historySave(document.location, true) + + this._pageChanged = event => this.pageChanged(event) + this._statePopped = event => this.statePopped(event) + + target.addEventListener('click', this._pageChanged, true) + target.addEventListener('submit', this._pageChanged, true) + window.addEventListener('popstate', this._statePopped, true) + } + + /** + * Disable hot reload, remove listeners. + */ + disable() { + this.target.removeEventListener('click', this._pageChanged, true) + this.target.removeEventListener('submit', this._pageChanged, true) + window.removeEventListener('popstate', this._statePopped, true) + + this._pageChanged = null + this._statePopped = null + } + + /** + * Fetch url, return promise, similar to standard Fetch API. + * Default implementation just forward argument to it. + */ + fetch(url, options) { + return fetch(url, options) + } + + /** + * Fetch app from remote and mount application. + */ + load(url, {mount=true, scroll=[0,0], ...options}={}) { + if(this.loadingClass) + this.target.classList.add(this.loadingClass) + + if(this.onLoad) + this.onLoad({url, el: this.el, options}) + if(scroll) + window.scroll(...scroll) + return this.fetch(url, options).then(response => response.text()) + .then(content => { + if(this.loadingClass) + this.target.classList.remove(this.loadingClass) + + var doc = new DOMParser().parseFromString(content, 'text/html') + var dom = doc.querySelectorAll(this.el) + var result = {url, + content: dom || [document.createTextNode(content)], + title: doc.title, + append: this.append} + mount && this.mount(result) + return result + }) + } + + /** + * Mount the page on provided target element + */ + mount({content, title=null, ...options}={}) { + if(this.onPreMount) + this.onPreMount({target: this.target, content, items, title}) + var items = null; + if(content) + items = this.mountContent(content, options) + if(title) + document.title = title + if(this.onMount) + this.onMount({target: this.target, content, items, title}) + } + + /** + * Mount page content + */ + mountContent(content, {append=false}={}) { + if(typeof content == "string") { + this.target.innerHTML = append ? this.target.innerHTML + content + : content; + // TODO + return [] + } + + if(!append) + this.target.innerHTML = "" + + var fragment = document.createDocumentFragment() + var items = [] + for(var node of content) + while(node.firstChild) { + items.push(node.firstChild) + fragment.appendChild(node.firstChild) + } + this.target.append(fragment) + return items + } + + /// Save application state into browser history + historySave(url,replace=false) { + const state = { content: this.target.innerHTML, + title: document.title, } + if(replace) + history.replaceState(state, '', url) + else + history.pushState(state, '', url) + } + + // --- events + pageChanged(event) { + let submit = event.type == 'submit'; + let target = submit || event.target.tagName == 'A' + ? event.target : event.target.closest('a'); + if(!target || target.hasAttribute('target') || target.data.forceReload) + return; + + let url = submit ? target.getAttribute('action') || '' + : target.getAttribute('href'); + let domain = window.location.protocol + '//' + window.location.hostname + let stay = (url === '' || url.startsWith('/') || url.startsWith('?') || + url.startsWith(domain)) && url.indexOf('wp-admin') == -1 + if(url===null || !stay) { + return; + } + + let options = {}; + if(submit) { + let formData = new FormData(event.target); + if(target.method == 'get') + url += '?' + (new URLSearchParams(formData)).toString(); + else + options = {...options, method: target.method, body: formData} + } + this.load(url, options).then(() => this.historySave(url)) + event.preventDefault(); + event.stopPropagation(); + } + + statePopped(event) { + const state = event.state + if(state && state.content) + this.mount({ content: state.content, title: state.title }); + } +} diff --git a/assets/src/core.js b/assets/src/public.js similarity index 68% rename from assets/src/core.js rename to assets/src/public.js index dbcaea4..881a2df 100644 --- a/assets/src/core.js +++ b/assets/src/public.js @@ -1,6 +1,5 @@ +import "./styles/public.scss" import './index.js' import App from './app.js' -export default App - window.App = App diff --git a/assets/src/sound.js b/assets/src/sound.js index 7b56c07..adcc7b4 100644 --- a/assets/src/sound.js +++ b/assets/src/sound.js @@ -2,8 +2,11 @@ import Model from './model'; export default class Sound extends Model { + constructor({sound={}, ...data}={}, options={}) { + // flatten EpisodeSound and sound data + super({...sound, ...data}, options) + } + get name() { return this.data.name } get src() { return this.data.url } - - static getId(data) { return data.pk } } diff --git a/assets/src/styles/admin.scss b/assets/src/styles/admin.scss new file mode 100644 index 0000000..f03af57 --- /dev/null +++ b/assets/src/styles/admin.scss @@ -0,0 +1,101 @@ +@use "./vars"; +@use "./components"; + +@import "bulma/sass/utilities/_all.sass"; +@import "bulma/sass/elements/button"; +@import "bulma/sass/components/navbar"; + + +// enforce button usage inside custom application +#player, .ax { + @include components.button; +} + + +.admin { + .navbar.has-shadow, .navbar.is-fixed-bottom.has-shadow { + box-shadow: 0em 0em 1em rgba(0,0,0,0.1); + } + + a.navbar-item.is-active { + border-bottom: 1px grey solid; + } + + .navbar { + & + .container { + margin-top: 1em; + } + + .navbar-dropdown { + z-index: 2000; + } + + .navbar-split { + margin: 0.2em 0em; + margin-right: 1em; + padding-right: 1em; + border-right: 1px vars.$grey-light solid; + display: inline-block; + } + + form { + margin: 0em; + padding: 0em; + } + + &.toolbar { + margin: 1em 0em; + background-color: transparent; + margin-bottom: 1em; + + .title { + padding-right: 2em; + margin-right: 1em; + border-right: 1px vars.$grey-light solid; + + font-size: vars.$text-size; + font-weight: vars.$weight-light; + } + } + + .navbar-dropdown { + max-height: 40rem; + overflow-y: auto; + + input { + z-index: 10000; + position: sticky; + top: 0; + } + } + + } + + .navbar .navbar-brand { + padding-right: 1em; + } + + .navbar .navbar-brand img { + margin: 0em 0.4em; + margin-top: 0.3em; + max-height: 3em; + } + + .breadcrumbs { + margin-bottom: 1em; + } + + .results > #result_list { + width: 100%; + margin: 1em 0em; + } + + + ul.menu-list li { + list-style-type: none; + } + + .submit-row a.deletelink { + height: 35px; + } +} diff --git a/assets/src/styles/common.scss b/assets/src/styles/common.scss new file mode 100644 index 0000000..0eef626 --- /dev/null +++ b/assets/src/styles/common.scss @@ -0,0 +1,98 @@ +@use "./vars" as v; +@import "./vendor"; +@import "./helpers"; + +//-- helpers/modifiers +//-- forms +input.half-field:not(:active):not(:hover) { + border: none; + background-color: rgba(0,0,0,0); + cursor: pointer; +} + + +//-- general +:root { + --body-bg: #fff; + --text-color: black; + --text-color-light: #555; + --break-color: rgb(225, 225, 225); + + --main-color: #EFCA08; + --main-color-light: #F4da51; + --main-color-dark: #F49F0A; + --secondary-color: #00A6A6; + --secondary-color-light: #4cc0c0; + --secondary-color-dark: #007ba8; + + --disabled-color: #aaa; + --disabled-bg: #eee; + --link-fg: #00A6A6; + --link-hv-fg: var(--text-color); + + --nav-primary-height: 3rem; + --nav-secondary-height: 2.5rem; + --nav-fg: var(--text-color); + --nav-bg: var(--main-color); + --nav-secondary-bg: var(--main-color-light); + --nav-hv-fg: var(--button-hv-fg); + --nav-hv-bg: var(--button-hv-bg); + --nav-active-fg: var(--button-active-fg); + --nav-active-bg: var(--button-active-bg); + --nav-fs: 1rem; + --nav-2-fs: 0.9rem; +} + + +:root { + font-size: 14px; +} + +body { + background-color: var(--body-bg); +} + + +@mixin mobile-small { + .grid { @include grid-1; } +} + + +body.mobile { + @include mobile-small; +} + +@media screen and (max-width: v.$screen-smaller) { + @include mobile-small; +} + +@media screen and (max-width: v.$screen-normal) { + html { font-size: 18px !important; } +} + +@media screen and (max-width: v.$screen-wider) { + html { font-size: 20px !important; } +} + +@media screen and (min-width: v.$screen-wider) { + html { font-size: 24px !important; } +} + +h1, h2, h3, h4, h5, h6, .heading, .title, .subtitle { + font-family: var(--heading-font-family); +} + + +.container:empty { + display: none; +} + +.header-cover { + display: flex; + flex-direction: column; +} + + +.modal .dropdown-menu { + z-index: 50, +} diff --git a/assets/src/styles/components.scss b/assets/src/styles/components.scss new file mode 100644 index 0000000..fdac627 --- /dev/null +++ b/assets/src/styles/components.scss @@ -0,0 +1,757 @@ +@use "vars" as v; + +:root { + --title-1-sz: 1.6rem; + --title-2-sz: 1.4rem; + --title-3-sz: 1.2rem; + --subtitle-1-sz: 1.6rem; + --subtitle-2-sz: 1.4rem; + --subtitle-3-sz: 1.2rem; + + --heading-font-family: default; + --heading-bg: var(--main-color); + --heading-fg: var(--text-color); + --heading-hg-fg: var(--text-color); + --heading-hg-bg: var(--secondary-color); + --heading-link-hv-fg: var(--link-fg); + + --cover-w: 14rem; + --cover-h: 14rem; + --cover-small-w: 10rem; + --cover-small-h: 10rem; + --cover-tiny-w: 10rem; + --cover-tiny-h: 10rem; + + --card-w: var(--cover-w); + + + --preview-bg: var(--body-bg); + --preview-title-sz: var(--title-3-sz); + --preview-subtitle-sz: var(--title-3-sz); + --preview-cover-size: 14rem; + --preview-cover-small-size: 10rem; + --preview-cover-tiny-size: 4rem; + --preview-wide-content-sz: #{v.$text-size-2}; + --preview-heading-bg-color: var(--main-color); + --header-height: var(--cover-h); + + --a-carousel-p: #{v.$text-size-medium}; + --a-carousel-ml: calc(#{v.$mp-4} - 0.5rem); + --a-carousel-gap: #{v.$mp-4}; + --a-carousel-nav-x: -#{v.$mp-3e}; + --a-carousel-bg: none; // var(--secondary-color-light); + + --a-progress-bg: transparent; + --a-progress-bar-bg: var(--secondary-color); + --a-progress-bar-color: var(--text-color); + --a-progress-bar-pd: #{v.$mp-2}; + + --a-playlist-header-bg: var(--secondary-color); + --a-playlist-header-fg: var(--text-color); + --a-playlist-title-sz: #{v.$text-size}; + --a-playlist-title-pd: #{v.$mp-3}; + --a-playlist-item-border: 1px var(--secondary-color) solid; + + --a-sound-bg: var(--main-color); + --a-sound-hv-bg: var(--main-color); + --a-sound-hv-fg: var(--secondary-color); + --a-sound-playing-fg: var(--secondary-color-dark); + --a-sound-text-sz: #{v.$text-size}; + + --a-player-url-fg: var(--text-color); + --a-player-panel-bg: var(--main-color); + --a-player-bar-height: var(--nav-primary-height); + --a-player-bar-bg: var(--main-color); + --a-player-bar-title-alone-sz: #{v.$text-size-medium}; + --a-player-bar-button-fg: var(--button-fg); + --a-player-bar-button-fg: var(--button-bg); + --a-player-bar-button-hv-fg: var(--button-hv-fg); + --a-player-bar-button-hv-bg: var(--button-hv-bg); + + --button-fg: var(--text-color); + --button-bg: var(--main-color); + --button-sec-bg: var(--main-color-light); + --button-hv-fg: var(--text-color); + --button-hv-bg: var(--secondary-color-light); + --button-active-fg: var(--text-color); + --button-active-bg: var(--secondary-color); +} + + +@media screen and (max-width: v.$screen-wide) { + :root { + --cover-w: 10rem; + --cover-h: 10rem; + --cover-small-w: 6rem; + --cover-small-h: 6rem; + --cover-tiny-w: 4rem; + --cover-tiny-h: 4rem; + + --section-content-sz: 1rem; + + // --preview-title-sz: #{v.$text-size}; + // --preview-subtitle-sz: #{v.$text-size-smaller}; + // --preview-wide-content-sz: #{v.$text-size}; + } +} + +// ---- headings +.title, .header.preview .title { + &.is-1 { font-size: var(--title-1-sz); } + &.is-2 { font-size: var(--title-2-sz); } + &.is-3 { font-size: var(--title-3-sz); } +} + +.subtitle, .header.preview .subtitle { + color: var(--text-color-light); + + &.is-1 { font-size: var(--subtitle-1-sz); } + &.is-2 { font-size: var(--subtitle-2-sz); } + &.is-3 { font-size: var(--subtitle-3-sz); } +} + +.title + .subtitle { + padding-top: 0em !important; +} + +.headings a, a.heading, a.subtitle { + text-decoration: none !important; +} + +.heading { + display: inline-block; + + &:not(:empty) { + // border-bottom: 1px var(--heading-bg) solid; + // color: var(--heading-fg); + padding: v.$mp-2; + margin-top: 0em !important; + vertical-align: top; + + &.highlight, &.active, + .preview.active &, + { + // border-color: var(--heading-hg-bg); + color: var(--heading-hg-fg); + } + } +} + + +// ---- bulma overrides +.modal-card { + max-width: v.$screen-wide; +} +.modal-card { + max-height: calc(100% - 10rem); +} + +// ---- button +@mixin button { + .button, a.button, button.button { + font-size: v.$text-size; + display: inline-block; + padding: v.$mp-2e; + border: none; + justify-content: center; + text-align: center; + cursor: pointer; + text-decoration: none; + + color: var(--button-fg); + background-color: var(--button-bg); + + &.square { min-width: 2.5em; } + &.secondary { background-color: var(--button-sec-bg); } + + .label, label { + cursor: pointer; + } + + .icon { + vertical-align: middle; + &:not(:only-child) { + &:first-child { margin: 0 v.$mp-3e 0 v.$mp-1e; } + &:last-child { margin: 0 v.$mp-3e 0 v.$mp-1e; } + } + } + + &:hover { + color: var(--button-hv-fg); + background-color: var(--button-hv-bg); + opacity: 1 !important; + } + + &.active:not(:hover) { + color: var(--button-active-fg); + background-color: var(--button-active-bg); + } + + &:not([disabled]), &:not(.disabled) { + cursor: pointer; + } + + &[disabled], &.disabled { + background-color: var(--text-color-light); + color: var(--secondary-color); + border-color: var(--secondary-color-light); + } + + .dropdown-trigger { + border-radius: 1.5em; + } + } + + + .button-group, .nav { + .button { + border-radius: 0px; + background-color: transparent; + border-top: 0px; + border-bottom: 0px; + height: 100%; + + &:not(:first-child) { border-left: 0px; } + &:last-child { border-right: 0px; } + } + } +} + + +// ---- preview +.preview { + position: relative; + background-size: cover; + background-color: var(--preview-bg) !important; + + &.preview-item { + width: 100%; + } + + // FIXME: remove + &.columns, .headings.columns { + margin-left: 0em; + margin-right: 0em; + .column { padding: 0em; } + } + + .title, .title:not(:last-child) { + // second is bulma reset + font-weight: v.$weight-bold; + font-size: var(--preview-title-sz); + margin-bottom: unset; + } + .subtitle { + font-weight: v.$weight-bolder; + font-size: var(--preview-subtitle-sz); + margin-bottom: unset; + } + //.content, .actions { + // font-size: v.$text-size-bigger; + //} + + .headings { + background-size: cover; + + > * { margin: 0em; } + .column { padding: 0em; } + + a { color: var(--text-color); } + a:hover { color: var(--heading-link-hv-fg) !important; } + } + + &.tiny { + .title { font-size: calc(var(--preview-title-sz) * 0.8); } + .subtitle { font-size: calc(var(--preview-subtitle-sz) * 0.8); } + .content { + font-size: v.$text-size; + max-height: 3rem; + overflow: hidden; + } + } + +} + + +.preview-cover { + background: var(--preview-bg); + background-size: cover; + background-repeat: no-repeat; + height: var(--cover-h); + max-width: calc( var(--cover-w) * 1.5 ); + min-width: var(--cover-w); + overflow: hidden; + border: 1px #c4c4c4 solid; + + img { + height: var(--cover-h); + max-width: calc( var(--cover-w) * 1.5 ); + min-width: var(--cover-w); + } + img.hide { visibility: hidden; } + + + &.small, .preview.small & { + min-width: unset; + height: var(--preview-cover-small-size); + width: var(--preview-cover-small-size) !important; + min-width: var(--preview-cover-small-size); + } + + &.tiny, .preview.tiny & { + min-width: unset; + height: var(--preview-cover-tiny-size); + width: var(--preview-cover-tiny-size) !important; + min-width: var(--preview-cover-tiny-size); + } +} + +.preview-header { + width: 100%; + + /*&:not(.no-cover) { + min-height: var(--header-height); + }*/ + + &.no-cover { + height: unset; + } + + .headings { + padding-top: v.$mp-6; + } + + .headings, > .container { + width: 100%; + } + + > .container, { + height: 100%; + } +} + + +// ---- list +.list-item { + display: flex; + flex-direction: column; + width: 100%; + // padding: v.$mp-3; + + .headings { + display: flex; + flex-direction: row; + padding: 0em; + margin-bottom: v.$mp-2 !important; + + .heading { + // background-color: var(--preview-heading-bg-color); + padding: 0rem; + } + + } + + .title { flex-grow: 1; } + .subtitle { + font-size: var(--preview-title-sz); + // background-color: var(--preview-heading-bg-color); + text-align: right; + + &:not(:empty) { min-width: 9rem; } + } + + .media-content { + height: 100%; + margin-bottom: unset; + + .list-item:not(.no-cover) & { + min-height: var(--preview-cover-small-size); + } + } + + .actions { + text-align: right; + align-items: center; + } + + &:not(.wide) .media { + padding: v.$mp-3; + // border-radius: v.$mp-2; + border: 1px solid var(--break-color) !important; + } +} + +@media screen and (max-width: v.$screen-very-small) { + .list-item .headings { + flex-direction: column; + + .heading { + display: inline; + text-align: left; + } + + .subtitle { + color: unset !important; + background: none !important; + } + } +} + + +// ---- wide +.list-item.wide { + & .preview-cover { + box-shadow: 0em 0em 1em rgba(0,0,0,0.2); + } + + & .content { + font-size: var(--preview-wide-content-sz); + flex-grow: 1; + } +} + + +// ---- card +.preview-card { + display: flex; + flex-direction: column; + width: var(--card-w); + padding: 0rem !important; + margin-bottom: auto; + + background-color: var(--preview-bg) !important; + transition: box-shadow 0.2s; + + &:hover { + figure { + // box-shadow: 0em 0em 1.2em rgba(0, 0, 0, 0.4) !important; + box-shadow: 0em 0em 1em rgba(0,0,0,0.2); + } + + a { + color: var(--heading-link-hv-fg); + } + } + + .headings { + margin-top: v.$mp-2; + + .heading { + display: block !important; + } + + .subtitle { + font-size: v.$text-size-2; + } + } + + .card-content { + flex-grow: 1; + position: relative; + + figure { + // box-shadow: 0em 0em 1em rgba(0, 0, 0, 0.2); + height: var(--cover-h); + width: var(--cover-w); + } + + .actions { + position: absolute; + padding: v.$mp-2; + bottom: 0rem; + right: 0rem; + } + + } +} + + +// ---- ---- Carousel +.a-carousel { + .a-carousel-viewport { + box-shadow: inset 0em 0em 20rem var(--a-carousel-bg); + // background-color: var(--a-carousel-bg); + padding: 0rem; + padding-top: var(--a-carousel-p); + margin-top: calc( 0rem - var(--a-carousel-p) ); + } +} + +.a-carousel-container { + width: 100%; + gap: var(--a-carousel-gap); + transition: margin-left 1s; + + > * { + flex-shrink: 0; + } +} + +.a-carousel-bullets-container { + // due to a-carousel margin-left + padding-left: var(--a-carousel-ml); + + .bullet { + margin: v.$mp-1; + cursor: pointer; + + &:hover { color: var(--link-fg); } + } +} + + +// ---- ---- progress bar +.a-progress { + display: flex; + flex-direction: row; + margin: 0em; + padding: 0em; + + &:hover { + background-color: var(--a-progress-bg); + } + + .a-progress-bar-container { + flex-grow: 1; + margin: 0em; + } + + > time, .a-progress-bar { + height: 100%; + padding: var(--a-progress-bar-pd); + } + + .a-progress-bar { + background-color: var(--a-progress-bar-bg); + color: var(--a-progress-bar-color) + } +} + + +// ---- ---- player +// ---- playlist +.playlist, .a-playlist { + .header { + display: flex; + flex-direction: row; + + .title, .button { + background-color: var(--a-playlist-header-bg); + color: var(--a-playlist-header-fg); + } + + .title { + font-size: var(--a-playlist-title-sz); + margin: 0; + padding: var(--a-playlist-title-pd); + } + } + + li { + list-style: none; + border-bottom: var(--a-playlist-item-border); + + &:last-child { + border-bottom: 0px; + } + } +} + +// ---- sound item +.a-sound-item { + display: flex; + align-items: center; + flex-direction: row; + + height: 3rem; + background-color: var(--a-sound-bg); + + &.playing .label { + color: var(--a-sound-playing-fg) !important; + } + + &:hover { + background-color: var(--a-sound-hv-bg); + + .label { + color: var(--a-sound-hv-fg) !important; + } + } + + .label:hover::before, &.playing .label::before { + content: "\f04b"; + font-family: "Font Awesome 6 Free"; + margin-right: v.$mp-3e; + } + &.playing .label:hover::before { + content: ''; + margin: 0; + } + + + .headings > * { + } + + .label { + cursor: pointer; + + .icon { + padding: 0em v.$mp-3; + } + + margin: 0em !important; + padding: v.$mp-3e; + font-size: var(--a-sound-text-sz); + font-family: var(--heading-font-family); + } + + .button { + width: 3em; + font-size: var(--a-sound-text-sz); + + &:hover { + color: var(--a-sound-hv-fg) !important; + background-color: unset; + } + } +} + + +// ---- player +.player-container { + z-index: 1000000; +} + +.a-player { + box-shadow: 0em -0.5em 0.5em rgba(0, 0, 0, 0.05); + + a { color: var(--a-player-url-fg); } + .button { + color: var(--text-black); + &:hover { color: var(--button-fg); } + } +} + +.a-player-panels { + background: var(--a-player-panel-bg); + height: 0%; + transition: height 1s; +} +.a-player-panels.is-open { + height: auto; +} + +.a-player-panel { + padding-bottom: v.$mp-3; + max-height: 80%; + overflow-y: auto; + + .a-sound-item:not(:hover) { + background-color: transparent; + } +} + +.a-player-progress { + height: 0.4em; + overflow: hidden; + + time { display: none; } + + &:hover, .a-player-panels.is-open + & { + background: var(--a-player-bar-bg); + height: 2em; + time { display: unset; } + } +} + +.a-player-bar { + display: flex; + flex-direction: row; + justify-content: center; + height: var(--a-player-bar-height); + + border-top: 1px v.$grey-light solid; + background: var(--a-player-bar-bg); + + > * { height: 100%; } + + .cover { height: 100%; } + .title { + font-size: v.$text-size; + margin: 0em; + + &:last-child { + font-size: var(--a-player-bar-title-alone-sz); + } + } + + .button { + font-size: v.$text-size-medium; + height: 100%; + padding: v.$mp-2 !important; + min-width: calc(var(--a-player-bar-height) + v.$mp-2 * 2); + border-radius: 0px; + + &.open { + background-color: var(--button-active-bg); + color: var(--button-active-fg); + } + } +} + + .a-player-bar-content { + display: flex; + flex-direction: vertical; + align-items: center; + flex-grow: 1; + padding: 0 v.$mp-3; + border-right: 1px black solid; + + .title { + max-height: calc( var(--a-player-bar-height) - v.$mp-3 ); + overflow: hidden; + } + } + + +/// ---- playlist editor +.a-tracklist-editor { + .dropdown { + display: unset !important; + } +} + +/// ---------------- +.a-select-file { + > *:not(:last-child) { + margin-bottom: v.$mp-3; + } + + .upload-preview { + max-width: 100%; + } + + .a-select-file-list { + display: grid; + grid-template-columns: 1fr 1fr 1fr 1fr; + gap: v.$mp-3; + } + + .file-preview { + width: 100%; + overflow: hidden; + + &:hover { + box-shadow: 0em 0em 1em rgba(0,0,0,0.2); + } + + &.active { + box-shadow: 0em 0em 1em rgba(0,0,0,0.4); + } + + img { + width: 100%; + max-height: 10rem; + } + } +} diff --git a/assets/src/styles/helpers.scss b/assets/src/styles/helpers.scss new file mode 100644 index 0000000..fea26cb --- /dev/null +++ b/assets/src/styles/helpers.scss @@ -0,0 +1,162 @@ +@use "./vars" as v; + +// ---- text +.text-light { font-weight: 400; color: var(--text-color-light); } + +.bigger { font-size: v.$text-size-bigger !important; } +.big { font-size: v.$text-size-big !important; } +.smaller { font-size: v.$text-size-smaller !important; } +.small { font-size: v.$text-size-small !important; } + +// ---- layout +.align-left { + text-align: left; + justify-content: left; + + &.x { padding-left: 0px !important; } +} +.align-right { + text-align: right; + justify-content: right; + + &.x { padding-right: 0px !important; } +} +.align-center { + text-align: center !important; + justify-content: center; +} + +.clear-left { clear: left !important } +.clear-right { clear: right !important } +.clear-both { clear: both !important } +.clear-unset { clear: unset !important } + +.d-inline { display: inline !important; } +.d-block { display: block !important; } +.d-inline-block { display: inline-block !important; } + +.p-relative { position: relative !important } +.p-absolute { position: absolute !important } +.p-fixed { position: fixed !important } +.p-sticky { position: sticky !important } +.p-static { position: static !important } + +.ws-nowrap { white-space: nowrap; } + + +.height-1 { height: 1em; } +.height-2 { height: 2em; } +.height-3 { height: 3em; } +.height-4 { height: 4em; } +.height-5 { height: 5em; } +.height-6 { height: 6em; } +.height-7 { height: 7em; } +.height-8 { height: 8em; } +.height-9 { height: 9em; } +.height-10 { height: 10em; } +.height-15 { height: 15em; } +.height-20 { height: 20em; } +.height-25 { height: 25em; } + +// ---- grid / flex + +.gap-1 { gap: v.$mp-1 !important; } +.gap-2 { gap: v.$mp-2 !important; } +.gap-3 { gap: v.$mp-3 !important; } +.gap-4 { gap: v.$mp-4 !important; } +.gap-5 { gap: v.$mp-5 !important; } + + +// ---- ---- grid +@mixin grid { + display: grid; + grid-template-columns: 1fr 1fr; + grid-auto-flow: dense; + gap: v.$mp-4; +} +@mixin grid-1 { grid-template-columns: 1fr; } +@mixin grid-2 { grid-template-columns: 1fr 1fr; } +@mixin grid-3 { grid-template-columns: 1fr 1fr 1fr; } + +.grid { @include grid; } +.grid-1 { @include grid; @include grid-1; } +.grid-2 { @include grid; @include grid-2; } +.grid-3 { @include grid; @include grid-3; } + +// ---- ---- flex +.flex-row { display: flex; flex-direction: row } +.flex-column { display: flex; flex-direction: column } +.flex-grow-0 { flex-grow: 0 !important; } +.flex-grow-1 { flex-grow: 1 !important; } +.flex-grow-2 { flex-grow: 2 !important; } +.flex-grow-3 { flex-grow: 3 !important; } +.flex-grow-4 { flex-grow: 4 !important; } +.flex-grow-5 { flex-grow: 5 !important; } +.flex-grow-6 { flex-grow: 6 !important; } + +.float-right { float: right } +.float-left { float: left } + +// ---- boxing +.is-fullwidth { width: 100%; } +.is-fullheight { height: 100%; } +.is-fixed-bottom { + position: fixed; + bottom: 0; + margin-bottom: 0px; + border-radius: 0; +} +.no-border { border: 0px !important; } + +.overflow-hidden { overflow: hidden } +.overflow-hidden.is-fullwidth { max-width: 100%; } + +.height-full { height: 100%; } + +*[draggable="true"] { + cursor: move; +} + + +// ---- animations +@keyframes blink { + from { opacity: 1; } + to { opacity: 0.4; } +} + +.blink { animation: 1s ease-in-out 3s infinite alternate blink; } +.loading { animation: 1s ease-in-out 1s infinite alternate blink; } + + +// -- colors +.main-color { color: var(--main-color); } +.secondary-color { color: var(--secondary-color); } + +.bg-main { background-color: var(--main-color); } +.bg-main-light { background-color: var(--main-color-light); } +.bg-secondary { background-color: var(--secondary-color); } +.bg-secondary-light { background-color: var(--secondary-color-light); } +.bg-transparent { background-color: transparent; } + +.border-bottom-main { border-bottom: 1px solid var(--main-color); } +.border-bottom-secondary { border-bottom: 1px solid var(--secondary-color); } + +.is-success { + background-color: v.$green !important; + border-color: v.$green-dark !important; +} +.is-danger { + background-color: v.$red !important; + border-color: v.$red-dark !important; +} + + +.box-shadow { + &:hover { + box-shadow: 0em 0em 1em rgba(0,0,0,0.2); + } + + &.active { + box-shadow: 0em 0em 1em rgba(0,0,0,0.4); + } +} diff --git a/assets/src/styles/public.scss b/assets/src/styles/public.scss new file mode 100644 index 0000000..55a46b4 --- /dev/null +++ b/assets/src/styles/public.scss @@ -0,0 +1,477 @@ +@use "./vars" as v; +@use "./components"; + + +// ---- main theme & layout + +.page { + padding-bottom: 5rem; + + a { + color: var(--link-fg); + text-decoration: underline; + + &:hover { + color: var(--link-hv-fg); + } + } + + section.container { + margin-top: v.$mp-3; + margin-bottom: v.$mp-4; + + &:not(:last-child) { + padding-bottom: calc(v.$mp-4 / 2); + border-bottom: 2px var(--break-color) solid; + } + + > .title, h3.title { + font-size: var(--title-2-sz); + clear: both; + margin: v.$mp-3 0; + } + + } + + *[data-oembed-url] { + clear: both; + } +} + + + +// ---- components +.dropdown-item { + font-size: unset !important +} + +.vc-weekday-1, .vc-weekday-7 { + color: var(--secondary-color) !important; +} + + +.schedules { + padding-top: 0; + margin-bottom: calc(0rem - v.$mp-3) !important; +} + +.schedule { + display: inline-block; + margin: v.$mp-3; + margin-left: 0rem; + padding: v.$mp-2; + border-bottom: 1px var(--main-color) solid; + + .heading { + padding: 0em; + } + + .day { + font-weight: v.$weight-bold; + margin-right: v.$mp-3; + } +} + + +// -- buttons, forms +@include components.button; + +.actions { + display: flex; + flex-direction: row; + gap: v.$mp-3; + justify-content: right; + + &.no-label label { + display: none; + } + + button, .action, a { + justify-content: center; + min-width: 2rem; + padding: v.$mp-2; + + .not-selected { opacity: 0.6; } + .icon { margin: 0em !important; } + label { margin-left: v.$mp-2; } + } + +} + +.label, .textarea, .input, .select { + font-size: v.$text-size; +} + +.field.is-horizontal { + display: flex; + flex-direction: horizontal; + + .label { min-width: 7rem } + .control { + flex: 1; + > * { + width: 100%; + } + + } +} + +@media screen and (min-width: v.$screen-small) { + comment.textarea { + height: calc( v.$text-size * 7 ) !important; + } +} + +.navbar-item.active, .table tr.is-selected { + color: var(--secondary-color); + background-color: var(--main-color); +} + + +// -- headings +.title { + text-transform: uppercase; + &.is-3 { margin-top: v.$mp-3; } +} + + +// ---- main navigation +.navs { + position: relative; +} + +.nav { + display: flex; + background-color: var(--nav-bg); + + &:empty { + display: none; + } + + .burger { + display: none; + background-color: var(--nav-bg); + } + + .nav-item { + padding: v.$mp-2; + flex-grow: 1; + flex-shrink: 1; + text-align: center; + + font-family: var(--heading-font-family); + text-transform: uppercase; + color: var(--nav-fg) !important; + + .icon:first-child, .icon + span { + text-align: center; + vertical-align: top; + display: inline-block; + } + + &:hover { + background-color: var(--nav-hv-bg); + color: var(--nav-hv-fg); + } + + &.active { + background-color: var(--nav-active-bg); + color: var(--nav-active-fg) !important; + } + } + + .nav-menu { + display: flex; + flex-grow: 1; + + .dropdown-content { + font-size: v.$text-size; + min-width: 15rem; + } + } + + &.primary { + height: var(--nav-primary-height); + + .nav-menu { + flex-grow: 1; + } + + .nav-brand { + display: inline-block; + padding: v.$mp-3; + flex-grow: 0; + flex-shrink: 1; + + img { + height: 100%; + } + } + + .nav-item { + font-size: var(--nav-fs); + font-weight: v.$weight-bold; + white-space: nowrap; + } + } + + &.secondary { + background-color: var(--nav-secondary-bg); + //position: absolute; + //width: 100%; + //box-shadow: 0em 0.5em 0.5em rgba(0, 0, 0, 0.05); + + justify-content: right; + //display: none; + + .nav.primary:hover + &, + &:hover { + display: flex; + top: var(--nav-primary-height); + left: 0rem; + } + + .nav-item { + font-size: var(--nav-2-fs); + } + } +} + +// ---- breadcrumbs +.breadcrumbs { + text-align: right; + padding: v.$mp-3 0rem; + font-size: v.$text-size-smaller; + padding-bottom: 0; + margin-bottom: 0; + + &:empty { display: none; } + + a + a { + padding-left: 0; + + &:before { + content: "/"; + margin: 0 v.$mp-2; + } + } +} + + +@media screen and (max-width: v.$screen-normal) { + .page { + margin-top: var(--nav-primary-height); + } + + .navs { + z-index: 100000; + position: fixed; + display: flex; + left: 0; + right: 0; + top: 0; + + .nav:first-child { + flex-grow: 1; + } + + .nav + .nav { + flex-grow: 0 !important; + } + } + + .nav { + justify-content: space-between; + + .burger { + display: unset; + margin-left: auto; + } + + .nav-menu { + display: block; + position: absolute; + background-color: var(--nav-secondary-bg); + left: 0; + top: 100%; + width: 100%; + box-shadow: 0em 0.5em 0.5em rgba(0,0,0,0.05); + + .nav-item { + display: block; + font-weight: v.$weight-normal; + font-size: var(--nav-fs); + } + } + + .nav-menu:not(.active) { + display: none !important + } + } +} + + +nav li { + list-style: none; + + a, .button { + font-size: v.$text-size-medium; + } +} + + +.nav-urls { + display: flex; + flex-direction: row; + + margin-top: v.$mp-3; + text-align: right; + + > a:only-child { + margin-left: auto; + } + + li { + list-style: none; + } + + .urls { + flex-grow: 1; + display: flex; + flex-direction: row; + gap: v.$mp-3; + justify-content: center; + + a:not(:last-child) { + margin-right: v.$mp-3; + } + + } + + .left { + flex-grow: 0; + text-align: left; + } + + .right { + flex-grow: 0; + text-align: right; + } +} + +// ---- page header +.header { + &.preview-header { + //display: flex; + align-items: start; + gap: v.$mp-3; + min-height: unset; + padding-top: v.$mp-3 !important; + + } + + .headings { + width: unset; + flex-grow: 1; + padding-top: 0 !important; + + display: flex; + flex-direction: column; + } + + &.has-cover { + min-height: calc( var(--header-height) / 3 ); + } +} + + +.header-cover:not(:only-child) { + float: right; + position: relative; + z-index: 30; + background-color: var(--body-bg); + margin: 0 0 v.$mp-4 v.$mp-4; + + .cover { + max-width: calc(var(--header-height) * 2); + height: var(--header-height); + } +} +.header-cover:only-child { + width: 100%; +} + +@media screen and (max-width: v.$screen-small) { + .container.header { + width: calc( 100% - v.$mp-2 ); + + .headings { + width: 100%; + clear: both; + } + + .header-cover { + float: none; + margin: 0; + text-align: center; + } + + .cover { + margin-left: auto; + margin-right: auto; + max-height: calc(var(--cover-h) * 1); + max-width: calc(var(--cover-w) * 2); + } + } +} + + +// ---- ---- detail +.page-content { + margin-top: v.$mp-6; + + &:not(:last-child) { + margin-bottom: v.$mp-6; + } +} + + +// ---- ---- list +.list-item { + &.logs { + .track { + margin-right: v.$mp-3; + .icon { + margin-right: v.$mp-2; + color: var(--secondary-color-dark); + } + } + } + + &:nth-child(3n):not(.wide) .media, + { + border-color: var(--main-color-dark) !important; + } + + &:nth-child(3n+1):not(.wide) .media, + { + border-color: var(--secondary-color-dark) !important; + } +} + + + +// ---- responsive +@media screen and (max-width: v.$screen-normal) { + .page .container { + margin-left: v.$mp-4; + margin-right: v.$mp-4; + } +} + +@media screen and (max-width: v.$screen-small) { + .page .container { + margin-left: v.$mp-2; + margin-right: v.$mp-2; + } +} diff --git a/assets/src/styles/vars.scss b/assets/src/styles/vars.scss new file mode 100644 index 0000000..7493c8a --- /dev/null +++ b/assets/src/styles/vars.scss @@ -0,0 +1,52 @@ +@charset "utf-8"; + +$black: #000; +$white: #fff; +$red: #e00; +$red-dark: #b00; +$green: #0e0; +$green-dark: #0b0; +$grey-light: #ddd; + +$mp-1: 0.2rem; +$mp-1e: 0.2em; +$mp-2: 0.4rem; +$mp-2e: 0.4em; +$mp-3: 0.6rem; +$mp-3e: 0.6em; +$mp-4: 1.2rem; +$mp-4e: 1.2em; +$mp-5: 1.6rem; +$mp-5e: 1.6em; +$mp-6: 2rem; +$mp-6e: 2em; +$mp-7: 4rem; +$mp-7e: 4em; + +$text-size-small: 0.6rem; +$text-size-smaller: 0.8rem; +$text-size: 1rem; +$text-size-2: 1.2rem; +$text-size-medium: 1.4rem; +$text-size-bigger: 1.6rem; +$text-size-big: 2rem; + +$h1-size: 40px; +$h2-size: 32px; +$h3-size: 28px; +$h4-size: 24px; +$h5-size: 20px; +$h6-size: 14px; + +$weight-light: 100; +$weight-lighter: 300; +$weight-normal: 400; +$weight-bolder: 500; +$weight-bold: 700; + +$screen-very-small: 400px; +$screen-small: 600px; +$screen-smaller: 900px; +$screen-normal: 1024px; +$screen-wider: 1280px; +$screen-wide: 1380px; diff --git a/assets/src/styles/vendor.scss b/assets/src/styles/vendor.scss new file mode 100644 index 0000000..32768b0 --- /dev/null +++ b/assets/src/styles/vendor.scss @@ -0,0 +1,35 @@ +@import 'v-calendar/style.css'; +// @import '@fortawesome/fontawesome-free/css/all.min.css'; + +// ---- bulma +$body-color: #000; +$title-color: #000; +$modal-content-width: 80%; + + +@import "bulma/sass/utilities/_all.sass"; + + +@import "bulma/sass/base/_all"; +@import "bulma/sass/components/dropdown"; +// @import "bulma/sass/components/card"; +@import "bulma/sass/components/media"; +@import "bulma/sass/components/message"; +@import "bulma/sass/components/modal"; +//@import "bulma/sass/components/pagination"; + +@import "bulma/sass/form/_all"; +@import "bulma/sass/grid/_all"; +@import "bulma/sass/helpers/_all"; +@import "bulma/sass/layout/_all"; +@import "bulma/sass/elements/box"; +// @import "bulma/sass/elements/button"; +@import "bulma/sass/elements/container"; +// @import "bulma/sass/elements/content"; +@import "bulma/sass/elements/icon"; +// @import "bulma/sass/elements/image"; +// @import "bulma/sass/elements/notification"; +// @import "bulma/sass/elements/progress"; +@import "bulma/sass/elements/table"; +@import "bulma/sass/elements/tag"; +//@import "bulma/sass/elements/title"; diff --git a/assets/src/track.js b/assets/src/track.js deleted file mode 100644 index 8eb67f9..0000000 --- a/assets/src/track.js +++ /dev/null @@ -1,5 +0,0 @@ -import Model from './model' - -export default class Track extends Model { - static getId(data) { return data.pk } -} diff --git a/assets/src/vueLoader.js b/assets/src/vueLoader.js new file mode 100644 index 0000000..0556c17 --- /dev/null +++ b/assets/src/vueLoader.js @@ -0,0 +1,47 @@ +import {createApp} from 'vue' + +import PageLoad from './pageLoad' + + +/** + * Handles loading Vue js app on page load. + */ +export default class VueLoader { + constructor({el=null, props={}, ...appConfig}={}, loaderOptions={}) { + this.appConfig = appConfig + this.appConfig.el = el + this.props = props + this.pageLoad = new PageLoad(el, loaderOptions) + + this.pageLoad.onPreMount = event => this.onPreMount(event) + this.pageLoad.onMount = event => this.onMount(event) + } + + enable(hotReload=true) { + hotReload && this.pageLoad.enable(document.body) + this.mount() + } + + mount() { + if(this.app) + this.unmount() + + const app = createApp(this.appConfig, this.props) + app.config.globalProperties.window = window + this.vm = app.mount(this.pageLoad.el) + this.app = app + } + + unmount() { + if(!this.app) + return + try { this.app.unmount() } + catch(_) { null } + this.app = null + this.vm = null + this.pageLoad.reset() + } + + onPreMount() { this.unmount() } + onMount() { this.mount() } +} diff --git a/assets/vite.config.js b/assets/vite.config.js new file mode 100644 index 0000000..0cf8b45 --- /dev/null +++ b/assets/vite.config.js @@ -0,0 +1,44 @@ +import { resolve } from 'path' +import { fileURLToPath, URL } from 'node:url' + +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import commonjs from '@rollup/plugin-commonjs'; + + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [ + vue(), + ], + build: { + outDir: "../aircox/static/aircox/", + sourcemap: true, + + rollupOptions: { + external: ['vue',], + input: { + public: "src/public.js", + admin: "src/admin.js", + }, + output: { + globals: { + vue: 'Vue', + }, + assetFileNames: "[name].[ext]", + chunkFileNames: "[name].js", + entryFileNames: "[name].js", + }, + plugins: [commonjs()], + }, + }, + css: { + devSourcemap: true, + }, + resolve: { + extensions: ['.js', '.ts', '.json', '.vue'], + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)) + } + } +}) diff --git a/assets/vue.config.js b/assets/vue.config.js deleted file mode 100644 index a44bd6e..0000000 --- a/assets/vue.config.js +++ /dev/null @@ -1,22 +0,0 @@ -const path = require('path'); - -const { defineConfig } = require('@vue/cli-service') -module.exports = defineConfig({ - transpileDependencies: true, - outputDir: path.resolve('../aircox/static/aircox'), - publicPath: './', - runtimeCompiler: true, - filenameHashing: false, - - css: { - extract: true, - loaderOptions: { - sass: { sourceMap: true }, - } - }, - - pages: { - core: { entry: 'src/core.js', }, - admin: { entry: 'src/admin.js' }, - } -}) diff --git a/instance/settings/base.py b/instance/settings/base.py index d18dbd6..51c5091 100755 --- a/instance/settings/base.py +++ b/instance/settings/base.py @@ -183,9 +183,9 @@ THUMBNAIL_PROCESSORS = ( # Enabled applications INSTALLED_APPS = ( - "aircox.apps.AircoxConfig", - "aircox.apps.AircoxAdminConfig", + "radiocampus", "aircox_streamer.apps.AircoxStreamerConfig", + "aircox.apps.AircoxConfig", # Aircox dependencies "rest_framework", "django_filters", @@ -204,6 +204,7 @@ INSTALLED_APPS = ( "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", + "django.contrib.admin", ) MIDDLEWARE = ( @@ -237,6 +238,7 @@ TEMPLATES = [ "django.template.context_processors.static", "django.template.context_processors.tz", "django.contrib.messages.context_processors.messages", + "aircox.context_processors.station", ), "loaders": ( "django.template.loaders.filesystem.Loader", @@ -248,3 +250,12 @@ TEMPLATES = [ WSGI_APPLICATION = "instance.wsgi.application" + +LOGOUT_REDIRECT_URL = "/" + + +REST_FRAMEWORK = { + "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.LimitOffsetPagination", + "PAGE_SIZE": 50, + "DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"], +} diff --git a/instance/urls.py b/instance/urls.py index af36db2..4efc5f9 100755 --- a/instance/urls.py +++ b/instance/urls.py @@ -20,9 +20,13 @@ from django.contrib import admin from django.urls import include, path import aircox.urls +import aircox_streamer.urls -urlpatterns = aircox.urls.urls + [ +urlpatterns = [ + *aircox.urls.urls, + path("streamer/", include((aircox_streamer.urls.urls, "aircox_streamer"), namespace="streamer")), path("admin/", admin.site.urls), + path("accounts/", include("django.contrib.auth.urls")), path("ckeditor/", include("ckeditor_uploader.urls")), path("filer/", include("filer.urls")), ] diff --git a/notes.md b/notes.md index d9dcba9..9553f88 100755 --- a/notes.md +++ b/notes.md @@ -1,3 +1,21 @@ +# TODO +- card: url +- page header: + - content inline + - responsive + +- remove vue-carousel +- statistics & monitor + + +# Proposals +- diffusion list view for a program + link on program page view +- add podcast list to playlist +- pause on "space" key + + +############## + This file is used as a reminder, can be used as crappy documentation too. - player diff --git a/radiocampus/__init__.py b/radiocampus/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/radiocampus/apps.py b/radiocampus/apps.py new file mode 100644 index 0000000..0d4399e --- /dev/null +++ b/radiocampus/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class RadiocampusConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "radiocampus" diff --git a/radiocampus/migrations/__init__.py b/radiocampus/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/radiocampus/static/radiocampus/fonts/CampusGroteskv11-Regular.otf b/radiocampus/static/radiocampus/fonts/CampusGroteskv11-Regular.otf new file mode 100644 index 0000000000000000000000000000000000000000..c59c3453e8f26c4da1881fef4a95f4c09800d599 GIT binary patch literal 11916 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 literal 0 HcmV?d00001 diff --git a/radiocampus/static/radiocampus/fonts/CampusGroteskv12-Regular.otf b/radiocampus/static/radiocampus/fonts/CampusGroteskv12-Regular.otf new file mode 100644 index 0000000000000000000000000000000000000000..cf6ce842e7eca16630f3b1b6bde837248871589c GIT binary patch literal 11996 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 ObH;BoJQ4cezyAZCl-qLv literal 0 HcmV?d00001 diff --git a/radiocampus/static/radiocampus/fonts/CampusGroteskv8-Regular.otf b/radiocampus/static/radiocampus/fonts/CampusGroteskv8-Regular.otf new file mode 100644 index 0000000000000000000000000000000000000000..8500aa32f92ceb1da9c3e798b6ca45f52d13a03f GIT binary patch literal 11860 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%0DYS6~DgI<y|h5LF{s!w)nz%9#6Ycoj(mv zGxrR>ZsBJY&nlT^3J$jN)PFl@J3$-cavoYQe)wejD`(xiAFP8BReI&$Fbe1Pi1Ke` zx%%q$RN~$Y39v=;t*JCkm7-;pEpQcYTHU>&aiikKTIE@D<&3;mBIEFk6p!Qr@T;H< zJV!r($GvySWBjf`V45-}rVG=Z3CGjZam*wpllhpL&6F@-GwYZg%oV1R`Iknc5jC>L zpmEcLXa;FwHIp<`G&3}FHD73!Xtry1YYu6SYHn+4H1{<MOV~#2`>cueVg1+v>}WQV zox{#&7qF|@^=uitpZ$sbnZ3YXW2@L2_7VGvRh;nKL#M`0?>n_}>g3eZskhTWrx>S^ zPGg-?oTfNUcberi&#BmHk=zQ;T*Jj^QTFm1&}oMGOeS}`?5DC5#w#mQlJH2$cOY#S zgBwVF4#eKPb@ABV4W`9kE&6J~VrQ+~MeHP+tJ{mgJ<35l*Q}U%=<WkL%njPKamRqr z53@igXbW7;sR!#!VVgMZ6P~WL1<*Dn^n2W*e!|y6CI{zvTKZ514Q%FC^58@_bGj=r z_h?hobxP&*Y0<GGjU#ft-~P+*`=H4&v$ycsyy-J7CY?-M+V#|3(R02$47PvnO&4-? zJO=J_IGtnj%v03j7H$D<;T~))I{6N~i4g#I{z7f-nE|*5!wm|-#g69F#B~<69>>y! zju`LdPoJG`8Y^5VTffr`9$NL_N||o8x5l%*w})3++3Kang>AbyQUkC1YHFZ$7+@0` zV6D14<fQHn`KXzI7BhMll}RgyfyDWUTFUet-!&>_#~l+~_S5<zC3Od48ty=pcM~yc z?}PYwGNg68104l4qAkxDv{bk<@Zt4St8VNuX&>Wpqzh=eT0L-T_Tg#YVg6{*zNFrU zu(9#6MJ9Tf*LjQ(wJfYTCwmRJ@%%Mz=w3LQJb117+yU;_H1B<WxC`6?cY)1XwBJ7B z6<#|~bZmKR!5hQV%!jb~kv324BWlB_K10+VUNO5M|Krc5Sqi@1ILjQL%Z=Ib!@MoV zAAbC9*OuH>qrWg~ab3TzU=OLw8aIMwswaQi@4j8S$y6>3$jF{(rXKb6+G|H+{l75N zUA(s6)QGHbqarwI<1AL_aSaT&v;iWtZ4n@9We<;fUQ0c1G%#*myEc%X=Pwjvefb&o zy_?_AYi)t@08#5!aCdZ`5_U@~<1<WoEiA@ewlp{`LzAc~%Jhky>Oan;ty_J0{{^FN zu&AqrtHOhd^DE1Z$A6d@Xr^asQSA0Y+*ifxv2?zp=pks|T0DO=cY)JH?Z=`YZX#*X zR?g=_Ra?1)!(;(%WuUgcUeL<bKb^JKj5zDxb7W*UzeACC%v$OiSPwm+_syqhnB8u7 zpuM&7q>S`Q6U)+f?A*L*=Z>^Z6U|yHI_hBqF)wPl*M#9Mw8+qoFQ`@+t9Esw$Y_%% zZaIB+XXy{Swr560rlcn!S(aDdS_?UtYrE3n7<p=_E6vg7*#a%vdT3j(Ev{$Q*R$*E zhlqHZ0oix7xYLEZV%kCKd4N_u1kr12&qSflHyboab#NzBQEZM~QdY}2HtSg}hnmox zw#g82|FI3H#YChBXT@}*2dB+z&<vkN8c576qV2zzi_MFf)ZeIMmfO0s+H&Z}`SD|E zKTb>IkRxU()U#T7yr{(}oYCTts^eIKcXQAfxI&bwc5kBV;D8o4`SgrhON_R+RVmam zr>v~w12p`~25qVdzgA+x!Xt;{*Dp>8A^268dWV^uoSmV55%ac(_cV^SGxj|lm`+5G q-+G$xn<s{t8)jmyX~@BPYQu3Af?s^$r%d>inYgK+^I-44e*XtnXR)FH literal 0 HcmV?d00001 diff --git a/radiocampus/templates/aircox/base.html b/radiocampus/templates/aircox/base.html new file mode 100644 index 0000000..b4f0e20 --- /dev/null +++ b/radiocampus/templates/aircox/base.html @@ -0,0 +1,23 @@ +{% extends "aircox/base.html" %} +{% load static %} + +{% block assets %} +{{ block.super }} +<style> +:root { + --heading-font-family: "campus_heading"; +} + +@font-face { + font-family: 'campus_heading'; + src: url('{% static "radiocampus/fonts/CampusGroteskv12-Regular.otf" %}'); +} +</style> +{% endblock %} + + +{% block header-container %} +{% if not page.attach_to %} +{{ block.super }} +{% endif %} +{% endblock %} diff --git a/requirements.txt b/requirements.txt index 7fcf089..a1e89c7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,10 @@ -Django~=4.1 -djangorestframework~=3.13 -django-model-utils>=4.2 +Django~=5.0 +djangorestframework~=3.14 +django-model-utils>=4.3 django-filter~=22.1 django-content-editor~=6.3 -django-filer~=2.2 +django-filer~=3.1 django-honeypot~=1.0 django-taggit~=3.0 django-admin-sortable2~=2.1