From d80322dd15c97c5a58cf940385bb50079c7f2a16 Mon Sep 17 00:00:00 2001 From: bkfox Date: Thu, 8 Feb 2018 06:46:42 +0100 Subject: [PATCH] work on player: integrate vuejs + noscript; remove TemplateMixin for Component and ExposedData; rewrite most of the player; clean up files; do lot of other things --- aircox/admin.py | 2 +- aircox/controllers.py | 2 +- aircox/management/commands/sounds_monitor.py | 1 + aircox/management/commands/streamer.py | 7 +- aircox/models.py | 55 +- aircox_cms/{models.py => models/__init__.py} | 54 +- aircox_cms/models/lists.py | 536 + aircox_cms/models/sections.py | 644 + aircox_cms/sections.py | 1092 -- aircox_cms/settings.py | 18 +- aircox_cms/signals.py | 6 +- aircox_cms/static/aircox_cms/js/bootstrap.js | 26 + aircox_cms/static/aircox_cms/js/player.js | 694 +- aircox_cms/static/lib/vue.js | 10798 ++++++++++++++++ aircox_cms/static/lib/vue.min.js | 6 + aircox_cms/template.py | 2 +- .../templates/aircox_cms/base_site.html | 23 +- .../templates/aircox_cms/diffusion_page.html | 33 +- .../templates/aircox_cms/publication.html | 5 - .../sections/{section_item.html => item.html} | 0 ...{section_link_list.html => link_list.html} | 2 +- .../sections/{section_list.html => list.html} | 2 +- ...{section_logs_list.html => logs_list.html} | 2 +- .../aircox_cms/sections/playlist.html | 54 + ...cation_info.html => publication_info.html} | 2 +- ...on_search_field.html => search_field.html} | 2 +- .../aircox_cms/sections/section_player.html | 25 - ...{section_timetable.html => timetable.html} | 2 +- .../templates/aircox_cms/snippets/player.html | 49 - .../templates/aircox_cms/vues/player.html | 82 + aircox_cms/templatetags/aircox_cms.py | 41 +- aircox_cms/utils.py | 36 + aircox_cms/views.py | 32 - aircox_cms/views/__init__.py | 0 aircox_cms/views/components.py | 111 + requirements.txt | 5 +- 36 files changed, 12711 insertions(+), 1740 deletions(-) rename aircox_cms/{models.py => models/__init__.py} (95%) create mode 100644 aircox_cms/models/lists.py create mode 100644 aircox_cms/models/sections.py delete mode 100755 aircox_cms/sections.py mode change 100755 => 100644 aircox_cms/static/aircox_cms/js/player.js create mode 100644 aircox_cms/static/lib/vue.js create mode 100644 aircox_cms/static/lib/vue.min.js rename aircox_cms/templates/aircox_cms/sections/{section_item.html => item.html} (100%) rename aircox_cms/templates/aircox_cms/sections/{section_link_list.html => link_list.html} (75%) rename aircox_cms/templates/aircox_cms/sections/{section_list.html => list.html} (72%) rename aircox_cms/templates/aircox_cms/sections/{section_logs_list.html => logs_list.html} (82%) create mode 100755 aircox_cms/templates/aircox_cms/sections/playlist.html rename aircox_cms/templates/aircox_cms/sections/{section_publication_info.html => publication_info.html} (98%) rename aircox_cms/templates/aircox_cms/sections/{section_search_field.html => search_field.html} (89%) delete mode 100755 aircox_cms/templates/aircox_cms/sections/section_player.html rename aircox_cms/templates/aircox_cms/sections/{section_timetable.html => timetable.html} (61%) delete mode 100755 aircox_cms/templates/aircox_cms/snippets/player.html create mode 100644 aircox_cms/templates/aircox_cms/vues/player.html create mode 100644 aircox_cms/views/__init__.py create mode 100644 aircox_cms/views/components.py diff --git a/aircox/admin.py b/aircox/admin.py index 7198724..95414b7 100755 --- a/aircox/admin.py +++ b/aircox/admin.py @@ -97,7 +97,7 @@ class ProgramAdmin(NameableAdmin): @admin.register(Diffusion) class DiffusionAdmin(admin.ModelAdmin): def archives(self, obj): - sounds = [ str(s) for s in obj.get_archives()] + sounds = [ str(s) for s in obj.get_sounds(archive=True)] return ', '.join(sounds) if sounds else '' def conflicts_(self, obj): diff --git a/aircox/controllers.py b/aircox/controllers.py index 93ec7e4..e066502 100755 --- a/aircox/controllers.py +++ b/aircox/controllers.py @@ -297,7 +297,7 @@ class Source: A playlist from a program uses all its available archives. """ if diffusion: - self.playlist = diffusion.playlist + self.playlist = diffusion.get_playlist(archive = True) return program = program or self.program diff --git a/aircox/management/commands/sounds_monitor.py b/aircox/management/commands/sounds_monitor.py index 3705313..efce5a9 100755 --- a/aircox/management/commands/sounds_monitor.py +++ b/aircox/management/commands/sounds_monitor.py @@ -1,3 +1,4 @@ +#! /usr/bin/env python3 """ Monitor sound files; For each program, check for: - new files; diff --git a/aircox/management/commands/streamer.py b/aircox/management/commands/streamer.py index 7bc581a..b9ce5b8 100755 --- a/aircox/management/commands/streamer.py +++ b/aircox/management/commands/streamer.py @@ -176,7 +176,7 @@ class Monitor: last_diff = self.last_diff_start diff = None if last_diff and not last_diff.is_expired(): - archives = last_diff.diffusion.get_archives() + archives = last_diff.diffusion.sounds(archive = True) if archives.filter(pk = sound.pk).exists(): diff = last_diff.diffusion @@ -294,7 +294,8 @@ class Monitor: .filter(source = log.source, pk__gt = log.pk) \ .exclude(sound__type = Sound.Type.removed) - remaining = log.diffusion.get_archives().exclude(pk__in = sounds) \ + remaining = log.diffusion.sounds(archive = True) \ + .exclude(pk__in = sounds) \ .values_list('path', flat = True) return log.diffusion, list(remaining) @@ -312,7 +313,7 @@ class Monitor: .filter(type = Diffusion.Type.normal, **kwargs) \ .distinct().order_by('start') diff = diff.first() - return (diff, diff and diff.playlist or []) + return (diff, diff and diff.get_playlist(archive = True) or []) def handle_pl_sync(self, source, playlist, diff = None, date = None): """ diff --git a/aircox/models.py b/aircox/models.py index a8613ba..7e1ed70 100755 --- a/aircox/models.py +++ b/aircox/models.py @@ -59,6 +59,7 @@ class Related(models.Model): related_type = models.ForeignKey( ContentType, blank = True, null = True, + on_delete=models.SET_NULL, ) related_id = models.PositiveIntegerField( blank = True, null = True, @@ -332,6 +333,7 @@ class Program(Nameable): station = models.ForeignKey( Station, verbose_name = _('station'), + on_delete=models.CASCADE, ) active = models.BooleanField( _('active'), @@ -438,6 +440,7 @@ class Stream(models.Model): program = models.ForeignKey( Program, verbose_name = _('related program'), + on_delete=models.CASCADE, ) delay = models.TimeField( _('delay'), @@ -483,6 +486,7 @@ class Schedule(models.Model): program = models.ForeignKey( Program, verbose_name = _('related program'), + on_delete=models.CASCADE, ) time = models.TimeField( _('time'), @@ -839,6 +843,7 @@ class Diffusion(models.Model): program = models.ForeignKey ( Program, verbose_name = _('program'), + on_delete=models.CASCADE, ) # specific type = models.SmallIntegerField( @@ -904,34 +909,31 @@ class Diffusion(models.Model): self._local_end = tz.localtime(self.end, tz.get_current_timezone()) return self._local_end - - @property - def playlist(self): - """ - List of archives' path; uses get_archives - """ - playlist = self.get_archives().values_list('path', flat = True) - return list(playlist) - def is_live(self): return self.type == self.Type.normal and \ - not self.get_archives().count() + not self.get_sounds(archive = True).count() - def get_archives(self): - """ - Return a list of available archives sounds for the given episode, - ordered by path. - """ - sounds = self.initial.sound_set if self.initial else self.sound_set - return sounds.filter(type = Sound.Type.archive).order_by('path') - def get_excerpts(self): + def get_playlist(self, **types): """ - Return a list of available archives sounds for the given episode, - ordered by path. + Returns sounds as a playlist (list of *local* file path). + The given arguments are passed to ``get_sounds``. """ - sounds = self.initial.sound_set if self.initial else self.sound_set - return sounds.filter(type = Sound.Type.excerpt).order_by('path') + return list(self.get_sounds(archives = True) \ + .filter(path__isnull = False) \ + .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` + """ + 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) def is_date_in_range(self, date = None): """ @@ -1012,6 +1014,7 @@ class Sound(Nameable): Program, verbose_name = _('program'), blank = True, null = True, + on_delete=models.SET_NULL, help_text = _('program related to it'), ) diffusion = models.ForeignKey( @@ -1026,11 +1029,13 @@ class Sound(Nameable): choices = [ (int(y), _(x)) for x,y in Type.__members__.items() ], blank = True, null = True ) + # FIXME: url() does not use the same directory than here + # should we use FileField for more reliability? path = models.FilePathField( _('file'), path = settings.AIRCOX_PROGRAMS_DIR, - match = r'(' + '|'.join(settings.AIRCOX_SOUND_FILE_EXT) \ - .replace('.', r'\.') + ')$', + match = r'(' + '|'.join(settings.AIRCOX_SOUND_FILE_EXT) + .replace('.', r'\.') + ')$', recursive = True, blank = True, null = True, unique = True, @@ -1229,6 +1234,7 @@ class Port (models.Model): station = models.ForeignKey( Station, verbose_name = _('station'), + on_delete=models.CASCADE, ) direction = models.SmallIntegerField( _('direction'), @@ -1464,6 +1470,7 @@ class Log(models.Model): station = models.ForeignKey( Station, verbose_name = _('station'), + on_delete=models.CASCADE, help_text = _('related station'), ) source = models.CharField( diff --git a/aircox_cms/models.py b/aircox_cms/models/__init__.py similarity index 95% rename from aircox_cms/models.py rename to aircox_cms/models/__init__.py index 2b923e3..ff1ec2b 100755 --- a/aircox_cms/models.py +++ b/aircox_cms/models/__init__.py @@ -30,9 +30,10 @@ import bleach import aircox.models import aircox_cms.settings as settings -from aircox_cms.utils import image_url +from aircox_cms.models.lists import * +from aircox_cms.models.sections import * from aircox_cms.template import TemplateMixin -from aircox_cms.sections import * +from aircox_cms.utils import image_url @register_setting @@ -300,6 +301,10 @@ class BasePage(Page): if self.allow_comments and \ WebsiteSettings.for_site(request.site).allow_comments: context['comment_form'] = CommentForm() + + context['settings'] = { + 'debug': settings.DEBUG + } return context def serve(self, request): @@ -331,7 +336,7 @@ class BasePage(Page): # # Publications # -class PublicationRelatedLink(RelatedLinkBase,TemplateMixin): +class PublicationRelatedLink(RelatedLinkBase,Component): template = 'aircox_cms/snippets/link.html' parent = ParentalKey('Publication', related_name='links') @@ -392,7 +397,6 @@ class Publication(BasePage): FieldPanel('tags'), FieldPanel('focus'), ], heading=_('Content')), - InlinePanel('links', label=_('Links')) ] + Page.promote_panels settings_panels = Page.settings_panels + [ FieldPanel('publish_as'), @@ -550,7 +554,6 @@ class DiffusionPage(Publication): FieldPanel('tags'), FieldPanel('focus'), ], heading=_('Content')), - InlinePanel('links', label=_('Links')) ] + Page.promote_panels settings_panels = Publication.settings_panels + [ FieldPanel('diffusion') @@ -605,37 +608,16 @@ class DiffusionPage(Publication): return item - def get_archive(self): - """ - Return the diffusion's archive as podcast - """ - if not self.publish_archive or not self.diffusion: - return - - sound = self.diffusion.get_archives() \ - .filter(public = True).first() - if sound: - sound.detail_url = self.url - return sound - - def get_podcasts(self): - """ - Return a list of podcasts, with archive as the first item of the - list when available. - """ - if not self.diffusion: - return - - podcasts = [] - archive = self.get_archive() - if archive: - podcasts.append(archive) - - qs = self.diffusion.get_excerpts().filter(public = True) - podcasts.extend(qs[:]) - for podcast in podcasts: - podcast.detail_url = self.url - return podcasts + def get_context(self, request, *args, **kwargs): + context = super().get_context(request, *args, **kwargs) + context['podcasts'] = self.diffusion and SectionPlaylist( + title=_('Podcasts'), + page = self, + sounds = self.diffusion.get_sounds( + archive = self.publish_archive, excerpt = True + ) + ) + return context def save(self, *args, **kwargs): if self.diffusion: diff --git a/aircox_cms/models/lists.py b/aircox_cms/models/lists.py new file mode 100644 index 0000000..4a97715 --- /dev/null +++ b/aircox_cms/models/lists.py @@ -0,0 +1,536 @@ +""" +Generic list manipulation used to render list of items + +Includes various usefull class and abstract models to make lists and +list items. +""" +import datetime +import re +from enum import IntEnum + +from django.db import models +from django.contrib.contenttypes.models import ContentType +from django.contrib.staticfiles.templatetags.staticfiles import static +from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger +from django.utils.translation import ugettext as _, ugettext_lazy +from django.utils import timezone as tz +from django.utils.functional import cached_property + +from wagtail.wagtailadmin.edit_handlers import * +from wagtail.wagtailcore.models import Page, Orderable +from wagtail.wagtailimages.models import Image +from wagtail.wagtailimages.edit_handlers import ImageChooserPanel + +from aircox_cms.utils import related_pages_filter + + +class ListItem: + """ + Generic normalized element to add item in lists that are not based + on Publication. + """ + title = '' + headline = '' + url = '' + cover = None + date = None + + def __init__(self, **kwargs): + self.__dict__.update(kwargs) + self.specific = self + + +class RelatedLinkBase(Orderable): + """ + Base model to make a link item. It can link to an url, or a page and + includes some common fields. + """ + url = models.URLField( + _('url'), + null=True, blank=True, + help_text = _('URL of the link'), + ) + page = models.ForeignKey( + Page, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name='+', + help_text = _('Use a page instead of a URL') + ) + icon = models.ForeignKey( + Image, + verbose_name = _('icon'), + null=True, blank=True, + on_delete=models.SET_NULL, + related_name='+', + help_text = _( + 'icon from the gallery' + ), + ) + icon_path = models.CharField( + _('icon path'), + null=True, blank=True, + max_length=128, + help_text = _( + 'icon from a given URL or path in the directory of static files' + ) + ) + text = models.CharField( + _('text'), + max_length = 64, + null = True, blank=True, + help_text = _('text of the link'), + ) + info = models.CharField( + _('info'), + max_length = 128, + null=True, blank=True, + help_text = _( + 'description displayed in a popup when the mouse hovers ' + 'the link' + ) + ) + + class Meta: + abstract = True + + panels = [ + MultiFieldPanel([ + FieldPanel('text'), + FieldPanel('info'), + ImageChooserPanel('icon'), + FieldPanel('icon_path'), + FieldPanel('url'), + PageChooserPanel('page'), + ], heading=_('link')) + ] + + def icon_url(self): + """ + Return icon_path as a complete url, since it can either be an + url or a path to static file. + """ + if self.icon_path.startswith('http://') or \ + self.icon_path.startswith('https://'): + return self.icon_path + return static(self.icon_path) + + def as_dict(self): + """ + Return compiled values from parameters as dict with + 'url', 'icon', 'text' + """ + if self.page: + url, text = self.page.url, self.text or self.page.title + else: + url, text = self.url, self.text or self.url + return { + 'url': url, + 'text': text, + 'info': self.info, + 'icon': self.icon, + 'icon_path': self.icon_path and self.icon_url(), + } + + +class BaseList(models.Model): + """ + Generic list + """ + class DateFilter(IntEnum): + none = 0x00 + previous = 0x01 + next = 0x02 + before_related = 0x03 + after_related = 0x04 + + class RelationFilter(IntEnum): + none = 0x00 + subpages = 0x01 + siblings = 0x02 + subpages_or_siblings = 0x03 + + # rendering + use_focus = models.BooleanField( + _('focus available'), + default = False, + help_text = _('if true, highlight the first focused article found') + ) + count = models.SmallIntegerField( + _('count'), + default = 30, + help_text = _('number of items to display in the list'), + ) + asc = models.BooleanField( + verbose_name = _('ascending order'), + default = True, + help_text = _('if selected sort list in the ascending order by date') + ) + + # selectors + date_filter = models.SmallIntegerField( + verbose_name = _('filter on date'), + choices = [ (int(y), _(x.replace('_', ' '))) + for x,y in DateFilter.__members__.items() ], + blank = True, null = True, + help_text = _( + 'select pages whose date follows the given constraint' + ) + ) + model = models.ForeignKey( + ContentType, + verbose_name = _('filter on page type'), + blank = True, null = True, + on_delete=models.SET_NULL, + help_text = _('if set, select only elements that are of this type'), + limit_choices_to = related_pages_filter, + ) + related = models.ForeignKey( + Page, + verbose_name = _('related page'), + blank = True, null = True, + on_delete=models.SET_NULL, + help_text = _( + 'if set, select children or siblings of this page' + ), + related_name = '+' + ) + relation = models.SmallIntegerField( + verbose_name = _('relation'), + choices = [ (int(y), _(x.replace('_', ' '))) + for x,y in RelationFilter.__members__.items() ], + default = 1, + help_text = _( + 'when the list is related to a page, only select pages that ' + 'correspond to this relationship' + ), + ) + search = models.CharField( + verbose_name = _('filter on search'), + blank = True, null = True, + max_length = 128, + help_text = _( + 'keep only pages that matches the given search' + ) + ) + tags = models.CharField( + verbose_name = _('filter on tag'), + blank = True, null = True, + max_length = 128, + help_text = _( + 'keep only pages with the given tags (separated by a colon)' + ) + ) + + panels = [ + MultiFieldPanel([ + FieldPanel('count'), + FieldPanel('use_focus'), + FieldPanel('asc'), + ], heading=_('rendering')), + MultiFieldPanel([ + FieldPanel('date_filter'), + FieldPanel('model'), + PageChooserPanel('related'), + FieldPanel('relation'), + FieldPanel('search'), + FieldPanel('tags'), + ], heading=_('filters')) + ] + + class Meta: + abstract = True + + def __get_related(self, qs): + related = self.related and self.related.specific + filter = self.RelationFilter + + if self.relation in (filter.subpages, filter.subpages_or_siblings): + qs_ = qs.descendant_of(related) + if self.relation == filter.subpages_or_siblings and \ + not qs.count(): + qs_ = qs.sibling_of(related) + qs = qs_ + else: + qs = qs.sibling_of(related) + + date = related.date if hasattr(related, 'date') else \ + related.first_published_at + if self.date_filter == self.DateFilter.before_related: + qs = qs.filter(date__lt = date) + elif self.date_filter == self.DateFilter.after_related: + qs = qs.filter(date__gte = date) + return qs + + def get_queryset(self): + """ + Get queryset based on the arguments. This class is intended to be + reusable by other classes if needed. + """ + # FIXME: check if related is published + from aircox_cms.models import Publication + # model + if self.model: + qs = self.model.model_class().objects.all() + else: + qs = Publication.objects.all() + qs = qs.live().not_in_menu() + + # related + if self.related: + qs = self.__get_related(qs) + + # date_filter + date = tz.now() + if self.date_filter == self.DateFilter.previous: + qs = qs.filter(date__lt = date) + elif self.date_filter == self.DateFilter.next: + qs = qs.filter(date__gte = date) + + # sort + qs = qs.order_by('date', 'pk') \ + if self.asc else qs.order_by('-date', '-pk') + + # tags + if self.tags: + qs = qs.filter(tags__name__in = ','.split(self.tags)) + + # search + if self.search: + # this qs.search does not return a queryset + qs = qs.search(self.search) + + return qs + + def get_context(self, request, qs = None, paginate = True): + """ + Return a context object using the given request and arguments. + @param paginate: paginate and include paginator into context + + Context arguments: + - object_list: queryset of the list's objects + - paginator: [if paginate] paginator object for this list + - list_url_args: GET arguments of the url as string + + ! Note: BaseList does not inherit from Wagtail.Page, and calling + this method won't call other super() get_context. + """ + qs = qs or self.get_queryset() + paginator = None + context = {} + if qs.count(): + if paginate: + context.update(self.paginate(request, qs)) + else: + context['object_list'] = qs[:self.count] + else: + # keep empty queryset + context['object_list'] = qs + context['list_url_args'] = self.to_url(full_url = False) + context['list_selector'] = self + return context + + def paginate(self, request, qs): + # paginator + paginator = Paginator(qs, self.count) + try: + qs = paginator.page(request.GET.get('page') or 1) + except PageNotAnInteger: + qs = paginator.page(1) + except EmptyPage: + qs = paginator.page(paginator.num_pages) + return { + 'paginator': paginator, + 'object_list': qs + } + + def to_url(self, page = None, **kwargs): + """ + Return a url to a given page with GET corresponding to this + list's parameters. + @param page: if given use it to prepend url with page's url instead of giving only + GET parameters + @param **kwargs: override list parameters + + If there is related field use it to get the page, otherwise use + the given list_page or the first BaseListPage it finds. + """ + params = { + 'asc': self.asc, + 'date_filter': self.get_date_filter_display(), + 'model': self.model and self.model.model, + 'relation': self.relation, + 'search': self.search, + 'tags': self.tags + } + params.update(kwargs) + + if self.related: + params['related'] = self.related.pk + + params = '&'.join([ + key if value == True else '{}={}'.format(key, value) + for key, value in params.items() if value + ]) + if not page: + return params + return page.url + '?' + params + + @classmethod + def from_request(cl, request, related = None): + """ + Return a context from the request's GET parameters. Context + can be used to update relative informations, more information + on this object from BaseList.get_context() + + @param request: get params from this request + @param related: reference page for a related list + @return context object from BaseList.get_context() + + This function can be used by other views if needed + + Parameters: + * asc: if present, sort ascending instead of descending + * date_filter: one of DateFilter attribute's key. + * model: ['program','diffusion','event'] type of the publication + * relation: one of RelationFilter attribute's key + * related: list is related to the method's argument `related`. + It can be a page id. + + * tag: tag to search for + * search: query to search in the publications + * page: page number + """ + date_filter = request.GET.get('date_filter') + model = request.GET.get('model') + + relation = request.GET.get('relation') + if relation is not None: + try: + relation = int(relation) + except: + relation = None + + related_= request.GET.get('related') + if related_: + try: + related_ = int(related_) + related_ = Page.objects.filter(pk = related_).first() + related_ = related_ and related_.specific + except: + related_ = None + + kwargs = { + 'asc': 'asc' in request.GET, + 'date_filter': + int(getattr(cl.DateFilter, date_filter)) + if date_filter and hasattr(cl.DateFilter, date_filter) + else None, + 'model': + ProgramPage if model == 'program' else + DiffusionPage if model == 'diffusion' else + EventPage if model == 'event' else None, + 'related': related_, + 'relation': relation, + 'tags': request.GET.get('tags'), + 'search': request.GET.get('search'), + } + + base_list = cl( + count = 30, **{ k:v for k,v in kwargs.items() if v } + ) + return base_list.get_context(request) + + +class DatedBaseList(models.Model): + """ + List that display items per days. Renders a navigation section on the + top. + """ + nav_days = models.SmallIntegerField( + _('navigation days count'), + default = 7, + help_text = _('number of days to display in the navigation header ' + 'when we use dates') + ) + nav_per_week = models.BooleanField( + _('navigation per week'), + default = False, + help_text = _('if selected, show dates navigation per weeks instead ' + 'of show days equally around the current date') + ) + hide_icons = models.BooleanField( + _('hide icons'), + default = False, + help_text = _('if selected, images of publications will not be ' + 'displayed in the list') + ) + + class Meta: + abstract = True + + panels = [ + MultiFieldPanel([ + FieldPanel('nav_days'), + FieldPanel('nav_per_week'), + FieldPanel('hide_icons'), + ], heading=_('Navigation')), + ] + + @staticmethod + def str_to_date(date): + """ + Parse a string and return a regular date or None. + Format is either "YYYY/MM/DD" or "YYYY-MM-DD" or "YYYYMMDD" + """ + try: + exp = r'(?P[0-9]{4})(-|\/)?(?P[0-9]{1,2})(-|\/)?' \ + r'(?P[0-9]{1,2})' + date = re.match(exp, date).groupdict() + return datetime.date( + year = int(date['year']), month = int(date['month']), + day = int(date['day']) + ) + except: + return None + + def get_nav_dates(self, date): + """ + Return a list of dates availables for the navigation + """ + if self.nav_per_week: + first = date.weekday() + else: + first = int((self.nav_days - 1) / 2) + first = date - tz.timedelta(days = first) + return [ first + tz.timedelta(days=i) + for i in range(0, self.nav_days) ] + + def get_date_context(self, date = None): + """ + Return a dict that can be added to the context to be used by + a date_list. + """ + today = tz.now().date() + if not date: + date = today + + # next/prev weeks/date bunch + dates = self.get_nav_dates(date) + next = date + tz.timedelta(days=self.nav_days) + prev = date - tz.timedelta(days=self.nav_days) + + # context dict + return { + 'nav_dates': { + 'today': today, + 'date': date, + 'next': next, + 'prev': prev, + 'dates': dates, + } + } + + + diff --git a/aircox_cms/models/sections.py b/aircox_cms/models/sections.py new file mode 100644 index 0000000..55800cd --- /dev/null +++ b/aircox_cms/models/sections.py @@ -0,0 +1,644 @@ +from enum import IntEnum + +from django.db import models +from django.contrib.contenttypes.models import ContentType +from django.template import Template, Context +from django.utils.translation import ugettext as _, ugettext_lazy +from django.utils.functional import cached_property +from django.urls import reverse + +from modelcluster.models import ClusterableModel +from modelcluster.fields import ParentalKey + +from wagtail.wagtailadmin.edit_handlers import * +from wagtail.wagtailimages.edit_handlers import ImageChooserPanel +from wagtail.wagtailcore.models import Page +from wagtail.wagtailcore.fields import RichTextField +from wagtail.wagtailsnippets.models import register_snippet + +import aircox.models +from aircox_cms.models.lists import * +from aircox_cms.views.components import Component, ExposedData +from aircox_cms.utils import related_pages_filter + + +@register_snippet +class Region(ClusterableModel): + """ + Region is a container of multiple items of different types + that are used to render extra content related or not the current + page. + + A section has an assigned position in the page, and can be restrained + to a given type of page. + """ + name = models.CharField( + _('name'), + max_length=32, + blank = True, null = True, + help_text=_('name of this section (not displayed)'), + ) + position = models.CharField( + _('position'), + max_length=16, + blank = True, null = True, + help_text = _('name of the template block in which the section must ' + 'be set'), + ) + order = models.IntegerField( + _('order'), + default = 100, + help_text = _('order of rendering, the higher the latest') + ) + model = models.ForeignKey( + ContentType, + verbose_name = _('model'), + blank = True, null = True, + help_text=_('this section is displayed only when the current ' + 'page or publication is of this type'), + limit_choices_to = related_pages_filter, + ) + page = models.ForeignKey( + Page, + verbose_name = _('page'), + blank = True, null = True, + help_text=_('this section is displayed only on this page'), + ) + + panels = [ + MultiFieldPanel([ + FieldPanel('name'), + FieldPanel('position'), + FieldPanel('model'), + FieldPanel('page'), + ], heading=_('General')), + # InlinePanel('items', label=_('Region Items')), + ] + + @classmethod + def get_sections_at (cl, position, page = None): + """ + Return a queryset of sections that are at the given position. + Filter out Region that are not for the given page. + """ + qs = Region.objects.filter(position = position) + if page: + qs = qs.filter( + models.Q(page__isnull = True) | + models.Q(page = page) + ) + qs = qs.filter( + models.Q(model__isnull = True) | + models.Q( + model = ContentType.objects.get_for_model(page).pk + ) + ) + return qs.order_by('order','pk') + + def add_item(self, item): + """ + Add an item to the section. Automatically save the item and + create the corresponding SectionPlace. + """ + item.section = self + item.save() + + def render(self, request, page = None, context = None, *args, **kwargs): + return ''.join([ + item.specific.render(request, page, context, *args, **kwargs) + for item in self.items.all().order_by('order','pk') + ]) + + def __str__(self): + return '{}: {}'.format(self.__class__.__name__, self.name or self.pk) + + +@register_snippet +class Section(Component, models.Model): + """ + Section is a widget configurable by user that can be rendered inside + Regions. + """ + template_name = 'aircox_cms/sections/section.html' + section = ParentalKey(Region, related_name='items') + order = models.IntegerField( + _('order'), + default = 100, + help_text = _('order of rendering, the higher the latest') + ) + real_type = models.CharField( + max_length=32, + blank = True, null = True, + ) + title = models.CharField( + _('title'), + max_length=32, + blank = True, null = True, + ) + show_title = models.BooleanField( + _('show title'), + default = False, + help_text=_('if set show a title at the head of the section'), + ) + css_class = models.CharField( + _('CSS class'), + max_length=64, + blank = True, null = True, + help_text=_('section container\'s "class" attribute') + ) + + template_name = 'aircox_cms/sections/item.html' + + panels = [ + MultiFieldPanel([ + FieldPanel('section'), + FieldPanel('title'), + FieldPanel('show_title'), + FieldPanel('order'), + FieldPanel('css_class'), + ], heading=_('General')), + ] + + # TODO make it reusable + @cached_property + def specific(self): + """ + Return a downcasted version of the model if it is from another + model, or itself + """ + if not self.real_type or type(self) != Section: + return self + return getattr(self, self.real_type) + + def save(self, *args, **kwargs): + if type(self) != Section and not self.real_type: + self.real_type = type(self).__name__.lower() + return super().save(*args, **kwargs) + + def __str__(self): + return '{}: {}'.format( + (self.real_type or 'section item').replace('section','section '), + self.title or self.pk + ) + +class SectionRelativeItem(Section): + is_related = models.BooleanField( + _('is related'), + default = False, + help_text=_( + 'if set, section is related to the page being processed ' + 'e.g rendering a list of links will use thoses of the ' + 'publication instead of an assigned one.' + ) + ) + + class Meta: + abstract=True + + panels = Section.panels.copy() + panels[-1] = MultiFieldPanel( + panels[-1].children + [ FieldPanel('is_related') ], + heading = panels[-1].heading + ) + + def related_attr(self, page, attr): + """ + Return an attribute from the given page if self.is_related, + otherwise retrieve the attribute from self. + """ + return self.is_related and hasattr(page, attr) \ + and getattr(page, attr) + +@register_snippet +class SectionText(Section): + template_name = 'aircox_cms/sections/text.html' + body = RichTextField() + panels = Section.panels + [ + FieldPanel('body'), + ] + + def get_context(self, request, page): + from wagtail.wagtailcore.rich_text import expand_db_html + context = super().get_context(request, page) + context['content'] = expand_db_html(self.body) + return context + +@register_snippet +class SectionImage(SectionRelativeItem): + class ResizeMode(IntEnum): + max = 0x00 + min = 0x01 + crop = 0x02 + + image = models.ForeignKey( + 'wagtailimages.Image', + verbose_name = _('image'), + related_name='+', + blank=True, null=True, + help_text=_( + 'If this item is related to the current page, this image will ' + 'be used only when the page has not a cover' + ) + ) + width = models.SmallIntegerField( + _('width'), + blank=True, null=True, + help_text=_('if set and > 0, sets a maximum width for the image'), + ) + height = models.SmallIntegerField( + _('height'), + blank=True, null=True, + help_text=_('if set 0 and > 0, sets a maximum height for the image'), + ) + resize_mode = models.SmallIntegerField( + verbose_name = _('resize mode'), + choices = [ (int(y), _(x)) for x,y in ResizeMode.__members__.items() ], + default = int(ResizeMode.max), + help_text=_('if the image is resized, set the resizing mode'), + ) + + panels = Section.panels + [ + ImageChooserPanel('image'), + MultiFieldPanel([ + FieldPanel('width'), + FieldPanel('height'), + FieldPanel('resize_mode'), + ], heading=_('Resizing')) + ] + + cache = "" + + + def get_filter(self): + return \ + 'original' if not (self.height or self.width) else \ + 'width-{}'.format(self.width) if not self.height else \ + 'height-{}'.format(self.height) if not self.width else \ + '{}-{}x{}'.format( + self.get_resize_mode_display(), + self.width, self.height + ) + + def ensure_cache(self, image): + """ + Ensure that we have a generated image and that it is put in cache. + We use this method since generating dynamic signatures don't generate + static images (and we need it). + """ + # Note: in order to put the generated image in db, we first need a way + # to get save events from related page or image. + if self.cache: + return self.cache + + if self.width or self.height: + template = Template( + '{% load wagtailimages_tags %}\n' + + '{{% image source {filter} as img %}}'.format( + filter = self.get_filter() + ) + + '' + ) + context = Context({ + "source": image + }) + self.cache = template.render(context) + else: + self.cache = ''.format(image.file.url) + return self.cache + + def get_context(self, request, page): + from wagtail.wagtailimages.views.serve import generate_signature + context = super().get_context(request, page) + + image = self.related_attr(page, 'cover') or self.image + if not image: + return context + + context['content'] = self.ensure_cache(image) + return context + + +@register_snippet +class SectionLinkList(ClusterableModel, Section): + template_name = 'aircox_cms/sections/link_list.html' + panels = Section.panels + [ + InlinePanel('links', label=_('Links')), + ] + + +@register_snippet +class SectionLink(RelatedLinkBase, Component): + """ + Render a link to a page or a given url. + Can either be used standalone or in a SectionLinkList + """ + template_name = 'aircox_cms/snippets/link.html' + parent = ParentalKey( + 'SectionLinkList', related_name = 'links', + null = True + ) + + def __str__(self): + return 'link: {} #{}'.format( + self.text or (self.page and self.page.title) or self.title, + self.pk + ) + + +@register_snippet +class SectionList(BaseList, SectionRelativeItem): + """ + This one is quite badass, but needed: render a list of pages + using given parameters (cf. BaseList). + + If focus_available, the first article in the list will be the last + article with a focus, and will be rendered in a bigger size. + """ + template_name = 'aircox_cms/sections/list.html' + # TODO/FIXME: focus, quid? + # TODO: logs in menu show headline??? + url_text = models.CharField( + _('text of the url'), + max_length=32, + blank = True, null = True, + help_text = _('use this text to display an URL to the complete ' + 'list. If empty, no link is displayed'), + ) + + panels = SectionRelativeItem.panels + [ + FieldPanel('url_text'), + ] + BaseList.panels + + def get_context(self, request, page): + import aircox_cms.models as cms + if self.is_related and not self.related: + # set current page if there is not yet a related page only + self.related = page + + context = BaseList.get_context(self, request, paginate = False) + if not context['object_list'].count(): + self.hide = True + return {} + + context.update(SectionRelativeItem.get_context(self, request, page)) + if self.url_text: + self.related = self.related and self.related.specific + target = None + if self.related and hasattr(self.related, 'get_list_page'): + target = self.related.get_list_page() + + if not target: + settings = cms.WebsiteSettings.for_site(request.site) + target = settings.list_page + context['url'] = self.to_url(page = target) + '&view=list' + return context + +SectionList._meta.get_field('count').default = 5 + + +@register_snippet +class SectionLogsList(Section): + template_name = 'aircox_cms/sections/logs_list.html' + station = models.ForeignKey( + aircox.models.Station, + verbose_name = _('station'), + null = True, + on_delete=models.SET_NULL, + help_text = _('(required) the station on which the logs happened') + ) + count = models.SmallIntegerField( + _('count'), + default = 5, + help_text = _('number of items to display in the list (max 100)'), + ) + + class Meta: + verbose_name = _('list of logs') + verbose_name_plural = _('lists of logs') + + panels = Section.panels + [ + FieldPanel('station'), + FieldPanel('count'), + ] + + @staticmethod + def as_item(log): + """ + Return a log object as a DiffusionPage or ListItem. + Supports: Log/Track, Diffusion + """ + from aircox_cms.models import DiffusionPage + if log.diffusion: + return DiffusionPage.as_item(log.diffusion) + + track = log.track + return ListItem( + title = '{artist} -- {title}'.format( + artist = track.artist, + title = track.title, + ), + headline = track.info, + date = log.date, + info = '♫', + css_class = 'track' + ) + + def get_context(self, request, page): + context = super().get_context(request, page) + context['object_list'] = [ + self.as_item(item) + for item in self.station.on_air(count = min(self.count, 100)) + ] + return context + + +@register_snippet +class SectionTimetable(Section,DatedBaseList): + template_name = 'aircox_cms/sections/timetable.html' + class Meta: + verbose_name = _('Section: Timetable') + verbose_name_plural = _('Sections: Timetable') + + station = models.ForeignKey( + aircox.models.Station, + verbose_name = _('station'), + help_text = _('(required) related station') + ) + target = models.ForeignKey( + 'aircox_cms.TimetablePage', + verbose_name = _('timetable page'), + blank = True, null = True, + help_text = _('select a timetable page used to show complete timetable'), + ) + nav_visible = models.BooleanField( + _('show date navigation'), + default = True, + help_text = _('if checked, navigation dates will be shown') + ) + + # TODO: put in multi-field panel of DatedBaseList + panels = Section.panels + DatedBaseList.panels + [ + MultiFieldPanel([ + FieldPanel('nav_visible'), + FieldPanel('station'), + FieldPanel('target'), + ], heading=_('Timetable')), + ] + + def get_queryset(self, context): + from aircox_cms.models import DiffusionPage + diffs = [] + for date in context['nav_dates']['dates']: + items = aircox.models.Diffusion.objects.at(self.station, date) + items = [ DiffusionPage.as_item(item) for item in items ] + diffs.append((date, items)) + return diffs + + def get_context(self, request, page): + context = super().get_context(request, page) + context.update(self.get_date_context()) + context['object_list'] = self.get_queryset(context) + context['target'] = self.target + if not self.nav_visible: + del context['nav_dates']['dates']; + return context + + +@register_snippet +class SectionPublicationInfo(Section): + template_name = 'aircox_cms/sections/publication_info.html' + class Meta: + verbose_name = _('Section: publication\'s info') + verbose_name_plural = _('Sections: publication\'s info') + +@register_snippet +class SectionSearchField(Section): + template_name = 'aircox_cms/sections/search_field.html' + default_text = models.CharField( + _('default text'), + max_length=32, + default=_('search'), + help_text=_('text to display when the search field is empty'), + ) + + class Meta: + verbose_name = _('Section: search field') + verbose_name_plural = _('Sections: search field') + + panels = Section.panels + [ + FieldPanel('default_text'), + ] + + + +@register_snippet +class SectionPlaylist(Section): + """ + User playlist. Can be used to add sounds in it -- there should + only be one for the moment. + """ + class Track(ExposedData): + """ + Class exposed to Javascript playlist manager as Track. + """ + fields = { + 'name': 'name', + 'duration': lambda e, o: ( + o.duration.hour, o.duration.minute, o.duration.second + ), + 'sources': lambda e, o: [ o.url() ], + 'detail_url': + lambda e, o: o.diffusion and hasattr(o.diffusion, 'page') \ + and o.diffusion.page.url + , + 'cover': + lambda e, o: o.diffusion and hasattr(o.diffusion, 'page') \ + and o.diffusion.page.icon + , + } + + user_playlist = models.BooleanField( + _('user playlist'), + default = False, + help_text = _( + 'if set, this playlist is to be editable by the user' + ) + ) + single_mode = models.BooleanField( + _('single_mode'), + default = False, + help_text = _( + 'enable single mode by default on this playlist' + ) + ) + + tracks = None + + template_name = 'aircox_cms/sections/playlist.html' + panels = Section.panels + [ + FieldPanel('user_playlist'), + FieldPanel('single_mode'), + ] + + def __init__(self, *args, sounds = None, tracks = None, page = None, **kwargs): + """ + Init playlist section. If ``sounds`` is given initialize playlist + tracks with it. If ``page`` is given use it for Track infos + related to a page (cover, detail_url, ...) + """ + self.tracks = (tracks or []) + [ + self.Track(object = sound, detail_url = page and page.url, + cover = page and page.icon) + for sound in sounds or [] + ] + super().__init__(*args, **kwargs) + + def get_context(self, request, page): + context = super().get_context(request, page) + context.update({ + 'is_default': self.user_playlist, + 'modifiable': self.user_playlist, + 'storage_key': self.user_playlist and str(self.pk), + 'tracks': self.tracks + }) + if not self.user_playlist and not self.tracks: + self.hide = True + return context + + +@register_snippet +class SectionPlayer(Section): + """ + Radio stream player. + """ + template_name = 'aircox_cms/sections/playlist.html' + live_title = models.CharField( + _('live title'), + max_length = 32, + help_text = _('text to display when it plays live'), + ) + streams = models.TextField( + _('audio streams'), + help_text = _('one audio stream per line'), + ) + + class Meta: + verbose_name = _('Section: Player') + + panels = Section.panels + [ + FieldPanel('live_title'), + FieldPanel('streams'), + ] + + def get_context(self, request, page): + context = super().get_context(request, page) + context['tracks'] = [SectionPlaylist.Track( + name = self.live_title, + sources = self.streams.split('\r\n'), + data_url = 'https://aircox.radiocampus.be/aircox/on_air', # reverse('aircox.on_air'), + interval = 10, + run = True, + )] + return context + + diff --git a/aircox_cms/sections.py b/aircox_cms/sections.py deleted file mode 100755 index d2d260f..0000000 --- a/aircox_cms/sections.py +++ /dev/null @@ -1,1092 +0,0 @@ -import datetime -import re -from enum import IntEnum - -from django.db import models -from django.utils.translation import ugettext as _, ugettext_lazy -from django.utils import timezone as tz -from django.utils.functional import cached_property -from django.core.urlresolvers import reverse -from django.template import Template, Context -from django.contrib.contenttypes.models import ContentType -from django.contrib.staticfiles.templatetags.staticfiles import static -from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger - -from wagtail.wagtailcore.models import Page, Orderable -from wagtail.wagtailcore.fields import RichTextField -from wagtail.wagtailimages.edit_handlers import ImageChooserPanel -from wagtail.wagtailadmin.edit_handlers import FieldPanel, FieldRowPanel, \ - MultiFieldPanel, InlinePanel, PageChooserPanel, StreamFieldPanel -from wagtail.wagtailsearch import index - -# snippets -from wagtail.wagtailsnippets.edit_handlers import SnippetChooserPanel -from wagtail.wagtailsnippets.models import register_snippet - -# tags -from modelcluster.models import ClusterableModel -from modelcluster.fields import ParentalKey -from modelcluster.tags import ClusterTaggableManager -from taggit.models import TaggedItemBase - -# aircox -import aircox.models -from aircox_cms.template import TemplateMixin - - -def related_pages_filter(reset_cache=False): - """ - Return a dict that can be used to filter foreignkey to pages' - subtype declared in aircox_cms.models. - - This value is stored in cache, but it is possible to reset the - cache using the `reset_cache` parameter. - """ - if not reset_cache and hasattr(related_pages_filter, 'cache'): - return related_pages_filter.cache - - import aircox_cms.models as cms - import inspect - related_pages_filter.cache = { - 'model__in': list(name.lower() for name, member in - inspect.getmembers(cms, - lambda x: inspect.isclass(x) and issubclass(x, Page) - ) - if member != Page - ), - } - return related_pages_filter.cache - - -class ListItem: - """ - Generic normalized element to add item in lists that are not based - on Publication. - """ - title = '' - headline = '' - url = '' - cover = None - date = None - - def __init__(self, **kwargs): - self.__dict__.update(kwargs) - self.specific = self - - -# -# Base -# -class RelatedLinkBase(Orderable): - url = models.URLField( - _('url'), - null=True, blank=True, - help_text = _('URL of the link'), - ) - page = models.ForeignKey( - 'wagtailcore.Page', - null=True, - blank=True, - on_delete=models.SET_NULL, - related_name='+', - help_text = _('Use a page instead of a URL') - ) - icon = models.ForeignKey( - 'wagtailimages.Image', - verbose_name = _('icon'), - null=True, blank=True, - on_delete=models.SET_NULL, - related_name='+', - help_text = _( - 'icon from the gallery' - ), - ) - icon_path = models.CharField( - _('icon path'), - null=True, blank=True, - max_length=128, - help_text = _( - 'icon from a given URL or path in the directory of static files' - ) - ) - text = models.CharField( - _('text'), - max_length = 64, - null = True, blank=True, - help_text = _('text of the link'), - ) - info = models.CharField( - _('info'), - max_length = 128, - null=True, blank=True, - help_text = _( - 'description displayed in a popup when the mouse hovers ' - 'the link' - ) - ) - - class Meta: - abstract = True - - panels = [ - MultiFieldPanel([ - FieldPanel('text'), - FieldPanel('info'), - ImageChooserPanel('icon'), - FieldPanel('icon_path'), - FieldPanel('url'), - PageChooserPanel('page'), - ], heading=_('link')) - ] - - def icon_url(self): - """ - Return icon_path as a complete url, since it can either be an - url or a path to static file. - """ - if self.icon_path.startswith('http://') or \ - self.icon_path.startswith('https://'): - return self.icon_path - return static(self.icon_path) - - def as_dict(self): - """ - Return compiled values from parameters as dict with - 'url', 'icon', 'text' - """ - if self.page: - url, text = self.page.url, self.text or self.page.title - else: - url, text = self.url, self.text or self.url - return { - 'url': url, - 'text': text, - 'info': self.info, - 'icon': self.icon, - 'icon_path': self.icon_path and self.icon_url(), - } - - -class BaseList(models.Model): - """ - Generic list - """ - class DateFilter(IntEnum): - none = 0x00 - previous = 0x01 - next = 0x02 - before_related = 0x03 - after_related = 0x04 - - class RelationFilter(IntEnum): - none = 0x00 - subpages = 0x01 - siblings = 0x02 - subpages_or_siblings = 0x03 - - # rendering - use_focus = models.BooleanField( - _('focus available'), - default = False, - help_text = _('if true, highlight the first focused article found') - ) - count = models.SmallIntegerField( - _('count'), - default = 30, - help_text = _('number of items to display in the list'), - ) - asc = models.BooleanField( - verbose_name = _('ascending order'), - default = True, - help_text = _('if selected sort list in the ascending order by date') - ) - - # selectors - date_filter = models.SmallIntegerField( - verbose_name = _('filter on date'), - choices = [ (int(y), _(x.replace('_', ' '))) - for x,y in DateFilter.__members__.items() ], - blank = True, null = True, - help_text = _( - 'select pages whose date follows the given constraint' - ) - ) - model = models.ForeignKey( - ContentType, - verbose_name = _('filter on page type'), - blank = True, null = True, - on_delete=models.SET_NULL, - help_text = _('if set, select only elements that are of this type'), - limit_choices_to = related_pages_filter, - ) - related = models.ForeignKey( - Page, - verbose_name = _('related page'), - blank = True, null = True, - on_delete=models.SET_NULL, - help_text = _( - 'if set, select children or siblings of this page' - ), - related_name = '+' - ) - relation = models.SmallIntegerField( - verbose_name = _('relation'), - choices = [ (int(y), _(x.replace('_', ' '))) - for x,y in RelationFilter.__members__.items() ], - default = 1, - help_text = _( - 'when the list is related to a page, only select pages that ' - 'correspond to this relationship' - ), - ) - search = models.CharField( - verbose_name = _('filter on search'), - blank = True, null = True, - max_length = 128, - help_text = _( - 'keep only pages that matches the given search' - ) - ) - tags = models.CharField( - verbose_name = _('filter on tag'), - blank = True, null = True, - max_length = 128, - help_text = _( - 'keep only pages with the given tags (separated by a colon)' - ) - ) - - panels = [ - MultiFieldPanel([ - FieldPanel('count'), - FieldPanel('use_focus'), - FieldPanel('asc'), - ], heading=_('rendering')), - MultiFieldPanel([ - FieldPanel('date_filter'), - FieldPanel('model'), - PageChooserPanel('related'), - FieldPanel('relation'), - FieldPanel('search'), - FieldPanel('tags'), - ], heading=_('filters')) - ] - - class Meta: - abstract = True - - def __get_related(self, qs): - related = self.related and self.related.specific - filter = self.RelationFilter - - if self.relation in (filter.subpages, filter.subpages_or_siblings): - qs_ = qs.descendant_of(related) - if self.relation == filter.subpages_or_siblings and \ - not qs.count(): - qs_ = qs.sibling_of(related) - qs = qs_ - else: - qs = qs.sibling_of(related) - - date = related.date if hasattr(related, 'date') else \ - related.first_published_at - if self.date_filter == self.DateFilter.before_related: - qs = qs.filter(date__lt = date) - elif self.date_filter == self.DateFilter.after_related: - qs = qs.filter(date__gte = date) - return qs - - def get_queryset(self): - """ - Get queryset based on the arguments. This class is intended to be - reusable by other classes if needed. - """ - # FIXME: check if related is published - - from aircox_cms.models import Publication - # model - if self.model: - qs = self.model.model_class().objects.all() - else: - qs = Publication.objects.all() - qs = qs.live().not_in_menu() - - # related - if self.related: - qs = self.__get_related(qs) - - # date_filter - date = tz.now() - if self.date_filter == self.DateFilter.previous: - qs = qs.filter(date__lt = date) - elif self.date_filter == self.DateFilter.next: - qs = qs.filter(date__gte = date) - - # sort - qs = qs.order_by('date', 'pk') \ - if self.asc else qs.order_by('-date', '-pk') - - # tags - if self.tags: - qs = qs.filter(tags__name__in = ','.split(self.tags)) - - # search - if self.search: - # this qs.search does not return a queryset - qs = qs.search(self.search) - - return qs - - def get_context(self, request, qs = None, paginate = True): - """ - Return a context object using the given request and arguments. - @param paginate: paginate and include paginator into context - - Context arguments: - - object_list: queryset of the list's objects - - paginator: [if paginate] paginator object for this list - - list_url_args: GET arguments of the url as string - - ! Note: BaseList does not inherit from Wagtail.Page, and calling - this method won't call other super() get_context. - """ - qs = qs or self.get_queryset() - paginator = None - context = {} - if qs.count(): - if paginate: - context.update(self.paginate(request, qs)) - else: - context['object_list'] = qs[:self.count] - else: - # keep empty queryset - context['object_list'] = qs - context['list_url_args'] = self.to_url(full_url = False) - context['list_selector'] = self - return context - - def paginate(self, request, qs): - # paginator - paginator = Paginator(qs, self.count) - try: - qs = paginator.page(request.GET.get('page') or 1) - except PageNotAnInteger: - qs = paginator.page(1) - except EmptyPage: - qs = paginator.page(paginator.num_pages) - return { - 'paginator': paginator, - 'object_list': qs - } - - def to_url(self, page = None, **kwargs): - """ - Return a url to a given page with GET corresponding to this - list's parameters. - @param page: if given use it to prepend url with page's url instead of giving only - GET parameters - @param **kwargs: override list parameters - - If there is related field use it to get the page, otherwise use - the given list_page or the first BaseListPage it finds. - """ - params = { - 'asc': self.asc, - 'date_filter': self.get_date_filter_display(), - 'model': self.model and self.model.model, - 'relation': self.relation, - 'search': self.search, - 'tags': self.tags - } - params.update(kwargs) - - if self.related: - params['related'] = self.related.pk - - params = '&'.join([ - key if value == True else '{}={}'.format(key, value) - for key, value in params.items() if value - ]) - if not page: - return params - return page.url + '?' + params - - @classmethod - def from_request(cl, request, related = None): - """ - Return a context from the request's GET parameters. Context - can be used to update relative informations, more information - on this object from BaseList.get_context() - - @param request: get params from this request - @param related: reference page for a related list - @return context object from BaseList.get_context() - - This function can be used by other views if needed - - Parameters: - * asc: if present, sort ascending instead of descending - * date_filter: one of DateFilter attribute's key. - * model: ['program','diffusion','event'] type of the publication - * relation: one of RelationFilter attribute's key - * related: list is related to the method's argument `related`. - It can be a page id. - - * tag: tag to search for - * search: query to search in the publications - * page: page number - """ - date_filter = request.GET.get('date_filter') - model = request.GET.get('model') - - relation = request.GET.get('relation') - if relation is not None: - try: - relation = int(relation) - except: - relation = None - - related_= request.GET.get('related') - if related_: - try: - related_ = int(related_) - related_ = Page.objects.filter(pk = related_).first() - related_ = related_ and related_.specific - except: - related_ = None - - kwargs = { - 'asc': 'asc' in request.GET, - 'date_filter': - int(getattr(cl.DateFilter, date_filter)) - if date_filter and hasattr(cl.DateFilter, date_filter) - else None, - 'model': - ProgramPage if model == 'program' else - DiffusionPage if model == 'diffusion' else - EventPage if model == 'event' else None, - 'related': related_, - 'relation': relation, - 'tags': request.GET.get('tags'), - 'search': request.GET.get('search'), - } - - base_list = cl( - count = 30, **{ k:v for k,v in kwargs.items() if v } - ) - return base_list.get_context(request) - - -class DatedBaseList(models.Model): - """ - List that display items per days. Renders a navigation section on the - top. - """ - nav_days = models.SmallIntegerField( - _('navigation days count'), - default = 7, - help_text = _('number of days to display in the navigation header ' - 'when we use dates') - ) - nav_per_week = models.BooleanField( - _('navigation per week'), - default = False, - help_text = _('if selected, show dates navigation per weeks instead ' - 'of show days equally around the current date') - ) - hide_icons = models.BooleanField( - _('hide icons'), - default = False, - help_text = _('if selected, images of publications will not be ' - 'displayed in the list') - ) - - class Meta: - abstract = True - - panels = [ - MultiFieldPanel([ - FieldPanel('nav_days'), - FieldPanel('nav_per_week'), - FieldPanel('hide_icons'), - ], heading=_('Navigation')), - ] - - @staticmethod - def str_to_date(date): - """ - Parse a string and return a regular date or None. - Format is either "YYYY/MM/DD" or "YYYY-MM-DD" or "YYYYMMDD" - """ - try: - exp = r'(?P[0-9]{4})(-|\/)?(?P[0-9]{1,2})(-|\/)?' \ - r'(?P[0-9]{1,2})' - date = re.match(exp, date).groupdict() - return datetime.date( - year = int(date['year']), month = int(date['month']), - day = int(date['day']) - ) - except: - return None - - def get_nav_dates(self, date): - """ - Return a list of dates availables for the navigation - """ - if self.nav_per_week: - first = date.weekday() - else: - first = int((self.nav_days - 1) / 2) - first = date - tz.timedelta(days = first) - return [ first + tz.timedelta(days=i) - for i in range(0, self.nav_days) ] - - def get_date_context(self, date = None): - """ - Return a dict that can be added to the context to be used by - a date_list. - """ - today = tz.now().date() - if not date: - date = today - - # next/prev weeks/date bunch - dates = self.get_nav_dates(date) - next = date + tz.timedelta(days=self.nav_days) - prev = date - tz.timedelta(days=self.nav_days) - - # context dict - return { - 'nav_dates': { - 'today': today, - 'date': date, - 'next': next, - 'prev': prev, - 'dates': dates, - } - } - - -# -# Sections -# -@register_snippet -class Section(ClusterableModel): - """ - Section is a container of multiple items of different types - that are used to render extra content related or not the current - page. - - A section has an assigned position in the page, and can be restrained - to a given type of page. - """ - name = models.CharField( - _('name'), - max_length=32, - blank = True, null = True, - help_text=_('name of this section (not displayed)'), - ) - position = models.CharField( - _('position'), - max_length=16, - blank = True, null = True, - help_text = _('name of the template block in which the section must ' - 'be set'), - ) - order = models.IntegerField( - _('order'), - default = 100, - help_text = _('order of rendering, the higher the latest') - ) - model = models.ForeignKey( - ContentType, - verbose_name = _('model'), - blank = True, null = True, - help_text=_('this section is displayed only when the current ' - 'page or publication is of this type'), - limit_choices_to = related_pages_filter, - ) - page = models.ForeignKey( - Page, - verbose_name = _('page'), - blank = True, null = True, - help_text=_('this section is displayed only on this page'), - ) - - panels = [ - MultiFieldPanel([ - FieldPanel('name'), - FieldPanel('position'), - FieldPanel('model'), - FieldPanel('page'), - ], heading=_('General')), - # InlinePanel('items', label=_('Section Items')), - ] - - @classmethod - def get_sections_at (cl, position, page = None): - """ - Return a queryset of sections that are at the given position. - Filter out Section that are not for the given page. - """ - qs = Section.objects.filter(position = position) - if page: - qs = qs.filter( - models.Q(page__isnull = True) | - models.Q(page = page) - ) - qs = qs.filter( - models.Q(model__isnull = True) | - models.Q( - model = ContentType.objects.get_for_model(page).pk - ) - ) - return qs.order_by('order','pk') - - def add_item(self, item): - """ - Add an item to the section. Automatically save the item and - create the corresponding SectionPlace. - """ - item.section = self - item.save() - - def render(self, request, page = None, context = None, *args, **kwargs): - return ''.join([ - item.specific.render(request, page, context, *args, **kwargs) - for item in self.items.all().order_by('order','pk') - ]) - - def __str__(self): - return '{}: {}'.format(self.__class__.__name__, self.name or self.pk) - - -@register_snippet -class SectionItem(TemplateMixin): - """ - Base class for a section item. - """ - section = ParentalKey(Section, related_name='items') - order = models.IntegerField( - _('order'), - default = 100, - help_text = _('order of rendering, the higher the latest') - ) - real_type = models.CharField( - max_length=32, - blank = True, null = True, - ) - title = models.CharField( - _('title'), - max_length=32, - blank = True, null = True, - ) - show_title = models.BooleanField( - _('show title'), - default = False, - help_text=_('if set show a title at the head of the section'), - ) - css_class = models.CharField( - _('CSS class'), - max_length=64, - blank = True, null = True, - help_text=_('section container\'s "class" attribute') - ) - panels = [ - MultiFieldPanel([ - FieldPanel('section'), - FieldPanel('title'), - FieldPanel('show_title'), - FieldPanel('order'), - FieldPanel('css_class'), - ], heading=_('General')), - ] - - @cached_property - def specific(self): - """ - Return a downcasted version of the post if it is from another - model, or itself - """ - if not self.real_type or type(self) != SectionItem: - return self - return getattr(self, self.real_type) - - def save(self, *args, **kwargs): - if type(self) != SectionItem and not self.real_type: - self.real_type = type(self).__name__.lower() - return super().save(*args, **kwargs) - - def __str__(self): - return '{}: {}'.format( - (self.real_type or 'section item').replace('section','section '), - self.title or self.pk - ) - -class SectionRelativeItem(SectionItem): - is_related = models.BooleanField( - _('is related'), - default = False, - help_text=_( - 'if set, section is related to the page being processed ' - 'e.g rendering a list of links will use thoses of the ' - 'publication instead of an assigned one.' - ) - ) - - class Meta: - abstract=True - - panels = SectionItem.panels.copy() - panels[-1] = MultiFieldPanel( - panels[-1].children + [ FieldPanel('is_related') ], - heading = panels[-1].heading - ) - - def related_attr(self, page, attr): - """ - Return an attribute from the given page if self.is_related, - otherwise retrieve the attribute from self. - """ - return self.is_related and hasattr(page, attr) \ - and getattr(page, attr) - -@register_snippet -class SectionText(SectionItem): - body = RichTextField() - panels = SectionItem.panels + [ - FieldPanel('body'), - ] - - def get_context(self, request, page): - from wagtail.wagtailcore.rich_text import expand_db_html - context = super().get_context(request, page) - context['content'] = expand_db_html(self.body) - return context - -@register_snippet -class SectionImage(SectionRelativeItem): - class ResizeMode(IntEnum): - max = 0x00 - min = 0x01 - crop = 0x02 - - image = models.ForeignKey( - 'wagtailimages.Image', - verbose_name = _('image'), - related_name='+', - blank=True, null=True, - help_text=_( - 'If this item is related to the current page, this image will ' - 'be used only when the page has not a cover' - ) - ) - width = models.SmallIntegerField( - _('width'), - blank=True, null=True, - help_text=_('if set and > 0, sets a maximum width for the image'), - ) - height = models.SmallIntegerField( - _('height'), - blank=True, null=True, - help_text=_('if set 0 and > 0, sets a maximum height for the image'), - ) - resize_mode = models.SmallIntegerField( - verbose_name = _('resize mode'), - choices = [ (int(y), _(x)) for x,y in ResizeMode.__members__.items() ], - default = int(ResizeMode.max), - help_text=_('if the image is resized, set the resizing mode'), - ) - - panels = SectionItem.panels + [ - ImageChooserPanel('image'), - MultiFieldPanel([ - FieldPanel('width'), - FieldPanel('height'), - FieldPanel('resize_mode'), - ], heading=_('Resizing')) - ] - - cache = "" - - - def get_filter(self): - return \ - 'original' if not (self.height or self.width) else \ - 'width-{}'.format(self.width) if not self.height else \ - 'height-{}'.format(self.height) if not self.width else \ - '{}-{}x{}'.format( - self.get_resize_mode_display(), - self.width, self.height - ) - - def ensure_cache(self, image): - """ - Ensure that we have a generated image and that it is put in cache. - We use this method since generating dynamic signatures don't generate - static images (and we need it). - """ - # Note: in order to put the generated image in db, we first need a way - # to get save events from related page or image. - if self.cache: - return self.cache - - if self.width or self.height: - template = Template( - '{% load wagtailimages_tags %}\n' + - '{{% image source {filter} as img %}}'.format( - filter = self.get_filter() - ) + - '' - ) - context = Context({ - "source": image - }) - self.cache = template.render(context) - else: - self.cache = ''.format(image.file.url) - return self.cache - - def get_context(self, request, page): - from wagtail.wagtailimages.views.serve import generate_signature - context = super().get_context(request, page) - - image = self.related_attr(page, 'cover') or self.image - if not image: - return context - - context['content'] = self.ensure_cache(image) - return context - - -@register_snippet -class SectionLinkList(ClusterableModel, SectionItem): - panels = SectionItem.panels + [ - InlinePanel('links', label=_('Links')), - ] - - -@register_snippet -class SectionLink(RelatedLinkBase,TemplateMixin): - """ - Render a link to a page or a given url. - Can either be used standalone or in a SectionLinkList - """ - template_name = 'aircox_cms/snippets/link.html' - parent = ParentalKey( - 'SectionLinkList', related_name = 'links', - null = True - ) - - def __str__(self): - return 'link: {} #{}'.format( - self.text or (self.page and self.page.title) or self.title, - self.pk - ) - - -@register_snippet -class SectionList(BaseList, SectionRelativeItem): - """ - This one is quite badass, but needed: render a list of pages - using given parameters (cf. BaseList). - - If focus_available, the first article in the list will be the last - article with a focus, and will be rendered in a bigger size. - """ - # TODO/FIXME: focus, quid? - # TODO: logs in menu show headline??? - url_text = models.CharField( - _('text of the url'), - max_length=32, - blank = True, null = True, - help_text = _('use this text to display an URL to the complete ' - 'list. If empty, no link is displayed'), - ) - - panels = SectionRelativeItem.panels + [ - FieldPanel('url_text'), - ] + BaseList.panels - - def get_context(self, request, page): - import aircox_cms.models as cms - if self.is_related and not self.related: - # set current page if there is not yet a related page only - self.related = page - - context = BaseList.get_context(self, request, paginate = False) - if not context['object_list'].count(): - return { 'hide': True } - - context.update(SectionRelativeItem.get_context(self, request, page)) - if self.url_text: - self.related = self.related and self.related.specific - target = None - if self.related and hasattr(self.related, 'get_list_page'): - target = self.related.get_list_page() - - if not target: - settings = cms.WebsiteSettings.for_site(request.site) - target = settings.list_page - context['url'] = self.to_url(page = target) + '&view=list' - return context - -SectionList._meta.get_field('count').default = 5 - - -@register_snippet -class SectionLogsList(SectionItem): - station = models.ForeignKey( - aircox.models.Station, - verbose_name = _('station'), - null = True, - on_delete=models.SET_NULL, - help_text = _('(required) the station on which the logs happened') - ) - count = models.SmallIntegerField( - _('count'), - default = 5, - help_text = _('number of items to display in the list (max 100)'), - ) - - class Meta: - verbose_name = _('list of logs') - verbose_name_plural = _('lists of logs') - - panels = SectionItem.panels + [ - FieldPanel('station'), - FieldPanel('count'), - ] - - @staticmethod - def as_item(log): - """ - Return a log object as a DiffusionPage or ListItem. - Supports: Log/Track, Diffusion - """ - from aircox_cms.models import DiffusionPage - if log.diffusion: - return DiffusionPage.as_item(log.diffusion) - - track = log.track - return ListItem( - title = '{artist} -- {title}'.format( - artist = track.artist, - title = track.title, - ), - headline = track.info, - date = log.date, - info = '♫', - css_class = 'track' - ) - - def get_context(self, request, page): - context = super().get_context(request, page) - context['object_list'] = [ - self.as_item(item) - for item in self.station.on_air(count = min(self.count, 100)) - ] - return context - - -@register_snippet -class SectionTimetable(SectionItem,DatedBaseList): - class Meta: - verbose_name = _('Section: Timetable') - verbose_name_plural = _('Sections: Timetable') - - station = models.ForeignKey( - aircox.models.Station, - verbose_name = _('station'), - help_text = _('(required) related station') - ) - target = models.ForeignKey( - 'aircox_cms.TimetablePage', - verbose_name = _('timetable page'), - blank = True, null = True, - help_text = _('select a timetable page used to show complete timetable'), - ) - nav_visible = models.BooleanField( - _('show date navigation'), - default = True, - help_text = _('if checked, navigation dates will be shown') - ) - - # TODO: put in multi-field panel of DatedBaseList - panels = SectionItem.panels + DatedBaseList.panels + [ - MultiFieldPanel([ - FieldPanel('nav_visible'), - FieldPanel('station'), - FieldPanel('target'), - ], heading=_('Timetable')), - ] - - def get_queryset(self, context): - from aircox_cms.models import DiffusionPage - diffs = [] - for date in context['nav_dates']['dates']: - items = aircox.models.Diffusion.objects.at(self.station, date) - items = [ DiffusionPage.as_item(item) for item in items ] - diffs.append((date, items)) - return diffs - - def get_context(self, request, page): - context = super().get_context(request, page) - context.update(self.get_date_context()) - context['object_list'] = self.get_queryset(context) - context['target'] = self.target - if not self.nav_visible: - del context['nav_dates']['dates']; - return context - - -@register_snippet -class SectionPublicationInfo(SectionItem): - class Meta: - verbose_name = _('Section: publication\'s info') - verbose_name_plural = _('Sections: publication\'s info') - -@register_snippet -class SectionSearchField(SectionItem): - default_text = models.CharField( - _('default text'), - max_length=32, - default=_('search'), - help_text=_('text to display when the search field is empty'), - ) - - class Meta: - verbose_name = _('Section: search field') - verbose_name_plural = _('Sections: search field') - - panels = SectionItem.panels + [ - FieldPanel('default_text'), - ] - - -@register_snippet -class SectionPlayer(SectionItem): - live_title = models.CharField( - _('live title'), - max_length = 32, - help_text = _('text to display when it plays live'), - ) - streams = models.TextField( - _('audio streams'), - help_text = _('one audio stream per line'), - ) - - class Meta: - verbose_name = _('Section: Player') - - panels = SectionItem.panels + [ - FieldPanel('live_title'), - FieldPanel('streams'), - ] - - def get_context(self, request, page): - context = super().get_context(request, page) - context['streams'] = self.streams.split('\r\n') - return context - - diff --git a/aircox_cms/settings.py b/aircox_cms/settings.py index ef9ee9f..88908ce 100755 --- a/aircox_cms/settings.py +++ b/aircox_cms/settings.py @@ -2,19 +2,21 @@ import os from django.conf import settings -def ensure (key, default): - globals()[key] = getattr(settings, key, default) - - -ensure('AIRCOX_CMS_BLEACH_COMMENT_TAGS', [ +AIRCOX_CMS_BLEACH_COMMENT_TAGS = [ 'i', 'emph', 'b', 'strong', 'strike', 's', 'p', 'span', 'quote','blockquote','code', 'sup', 'sub', 'a', -]) +] -ensure('AIRCOX_CMS_BLEACH_COMMENT_ATTRS', { +AIRCOX_CMS_BLEACH_COMMENT_ATTRS = { '*': ['title'], 'a': ['href', 'rel'], -}) +} + + +# import settings +for k, v in settings.__dict__.items(): + if not k.startswith('__') and k not in globals(): + globals()[k] = v diff --git a/aircox_cms/signals.py b/aircox_cms/signals.py index 43b2f62..f136374 100755 --- a/aircox_cms/signals.py +++ b/aircox_cms/signals.py @@ -1,15 +1,15 @@ +from django.contrib.contenttypes.models import ContentType from django.db.models import Q from django.db.models.signals import post_save, pre_delete from django.dispatch import receiver from django.utils import timezone as tz from django.utils.translation import ugettext as _, ugettext_lazy -from django.contrib.contenttypes.models import ContentType from wagtail.wagtailcore.models import Page, Site, PageRevision import aircox.models as aircox import aircox_cms.models as models -import aircox_cms.sections as sections +import aircox_cms.models.sections as sections import aircox_cms.utils as utils # on a new diffusion @@ -88,7 +88,7 @@ def station_post_saved(sender, instance, created, *args, **kwargs): ) homepage.add_child(instance = programs) - section = sections.Section( + section = sections.Region( name = _('programs'), position = 'post_content', page = programs, diff --git a/aircox_cms/static/aircox_cms/js/bootstrap.js b/aircox_cms/static/aircox_cms/js/bootstrap.js index b21e48c..e36eb3e 100644 --- a/aircox_cms/static/aircox_cms/js/bootstrap.js +++ b/aircox_cms/static/aircox_cms/js/bootstrap.js @@ -13,3 +13,29 @@ window.addEventListener('scroll', function(e) { }); + +/// TODO: later get rid of it in order to use Vue stuff +/// Helper to provide a tab+panel functionnality; the tab and the selected +/// element will have an attribute "selected". +/// We assume a common ancestor between tab and panel at a maximum level +/// of 2. +/// * tab: corresponding tab +/// * panel_selector is used to select the right panel object. +function select_tab(tab, panel_selector) { + var parent = tab.parentNode.parentNode; + var panel = parent.querySelector(panel_selector); + + // unselect + var qs = parent.querySelectorAll('*[selected]'); + for(var i = 0; i < qs.length; i++) + if(qs[i] != tab && qs[i] != panel) + qs[i].removeAttribute('selected'); + + panel.setAttribute('selected', 'true'); + tab.setAttribute('selected', 'true'); +} + + + + + diff --git a/aircox_cms/static/aircox_cms/js/player.js b/aircox_cms/static/aircox_cms/js/player.js old mode 100755 new mode 100644 index 4e86c5f..78fb905 --- a/aircox_cms/static/aircox_cms/js/player.js +++ b/aircox_cms/static/aircox_cms/js/player.js @@ -1,450 +1,314 @@ -// TODO -// - live streams as item; -// - add to playlist button -// - -/// Return a human-readable string from seconds -function duration_str(seconds) { - seconds = Math.floor(seconds); - var hours = Math.floor(seconds / 3600); - seconds -= hours * 3600; - var minutes = Math.floor(seconds / 60); - seconds -= minutes * 60; - - var str = hours ? (hours < 10 ? '0' + hours : hours) + ':' : ''; - str += (minutes < 10 ? '0' + minutes : minutes) + ':'; - str += (seconds < 10 ? '0' + seconds : seconds); - return str; -} +/* Implementation status: -- TODO + * - actions: + * - add to user playlist + * - go to detail + * - remove from playlist: for user playlist + * - save sound infos: + * - while playing: save current position + * - otherwise: remove from localstorage + * - save playlist in localstorage + * - proper design + * - mini-button integration in lists (list of diffusion articles) + */ -function Sound(title, detail, duration, streams, cover, on_air) { - this.title = title; - this.detail = detail; - this.duration = duration; - this.streams = streams.splice ? streams.sort() : [streams]; - this.cover = cover; - this.on_air = on_air; -} - -Sound.prototype = { - title: '', - detail: '', - streams: undefined, - duration: undefined, - cover: undefined, - on_air: false, - - item: undefined, - position_item: undefined, - - get seekable() { - return this.duration != undefined; - }, - - make_item: function(playlist, base_item) { - if(this.item) - return; - - var item = base_item.cloneNode(true); - item.removeAttribute('style'); - - item.querySelector('.title').innerHTML = this.title; - if(this.seekable) - item.querySelector('.duration').innerHTML = - duration_str(this.duration); - if(this.detail) - item.querySelector('.detail').href = this.detail; - if(playlist.player.show_cover && this.cover) - item.querySelector('img.cover').src = this.cover; - - item.sound = this; - this.item = item; - this.position_item = item.querySelector('.position'); - - // events - var self = this; - item.querySelector('.action.remove').addEventListener( - 'click', function(event) { playlist.remove(self); }, false - ); - - item.querySelector('.action.add').addEventListener( - 'click', function(event) { - player.playlist.add(new Sound( - title = self.title, - detail = self.detail, - duration = self.duration, - streams = self.streams, - cover = self.cover, - on_air = self.on_air - )); - }, false - ); - - item.addEventListener('click', function(event) { - if(event.target.className.indexOf('action') != -1) - return; - playlist.select(self, true) - }, false); - }, -} +var State = Object.freeze({ + Stop: Symbol('Stop'), + Loading: Symbol('Loading'), + Play: Symbol('Play'), +}); -function Playlist(player) { - this.player = player; - this.playlist = player.player.querySelector('.playlist'); - this.item_ = player.player.querySelector('.playlist .item'); - this.sounds = [] -} +class Track { + // Create a track with the given data. + // If url and interval are given, use them to retrieve regularely + // the track informations + constructor(data) { + Object.assign(this, data); -Playlist.prototype = { - on_air: undefined, - sounds: undefined, - sound: undefined, - - /// Find a sound by its streams, and return it if found - find: function(streams) { - streams = streams.splice ? streams.sort() : streams; - - return this.sounds.find(function(sound) { - // comparing array - if(!sound.streams || sound.streams.length != streams.length) - return false; - - for(var i = 0; i < streams.length; i++) - if(sound.streams[i] != streams[i]) - return false; - return true - }); - }, - - add: function(sound, container, position) { - var sound_ = this.find(sound.streams); - if(sound_) - return sound_; - - if(sound.on_air) - this.on_air = sound; - - sound.make_item(this, this.item_); - - container = container || this.playlist; - if(position != undefined) { - container.insertBefore(sound.item, container.children[position]); - this.sounds.splice(position, 0, sound); - } - else { - container.appendChild(sound.item); - this.sounds.push(sound); - } - this.save(); - return sound; - }, - - remove: function(sound) { - var index = this.sounds.indexOf(sound); - if(index != -1) - this.sounds.splice(index,1); - this.playlist.removeChild(sound.item); - this.save(); - - if(this.sound == sound) { - this.player.stop() - this.next(false); - } - }, - - select: function(sound, play = true) { - this.player.playlist = this; - if(this.sound == sound) { - if(play) - this.player.play(); - return; - } - - if(this.sound) - this.unselect(this.sound); - this.sound = sound; - - // audio - this.player.load_sound(this.sound); - - // attributes - var container = this.player.player; - sound.item.setAttribute('selected', 'true'); - - if(!sound.on_air) - sound.item.querySelector('.content').parentNode.appendChild( - this.player.progress.item //, - // sound.item.querySelector('.content .duration') - ) - - if(sound.seekable) - container.setAttribute('seekable', 'true'); - else - container.removeAttribute('seekable'); - - // play - if(play) - this.player.play(); - }, - - unselect: function(sound) { - sound.item.removeAttribute('selected'); - }, - - next: function(play = true) { - var index = this.sounds.indexOf(this.sound); - if(index < 0) - return; - - index++; - if(index < this.sounds.length) - this.select(this.sounds[index]); - }, - - // storage - save: function() { - var list = []; - for(var i in this.sounds) { - var sound = Object.assign({}, this.sounds[i]) - if(sound.on_air) - continue; - delete sound.item; - list.push(sound); - } - this.player.store.set('playlist', list); - }, - - load: function() { - var list = this.player.store.get('playlist'); - var container = document.createDocumentFragment(); - for(var i in list) { - var sound = list[i]; - sound = new Sound(sound.title, sound.detail, sound.duration, - sound.streams, sound.cover, sound.on_air) - this.add(sound, container) - } - this.playlist.appendChild(container); - }, -} - -var ActivePlayer = null; - -function Player(id, on_air_url, show_cover) { - this.id = id; - this.on_air_url = on_air_url; - this.show_cover = show_cover; - - this.store = new Store('player_' + id); - - // html sounds - this.player = document.getElementById(id); - this.audio = this.player.querySelector('audio'); - this.on_air = this.player.querySelector('.on_air'); - this.progress = { - item: this.player.querySelector('.controls .progress'), - bar: this.player.querySelector('.controls .progress progress'), - duration: this.player.querySelector('.controls .progress .duration') - } - - this.controls = { - single: this.player.querySelector('input.single'), - } - - this.playlist = new Playlist(this); - this.playlist.load(); - - this.init_events(); - this.load(); -} - -Player.prototype = { - /// current item being played - sound: undefined, - on_air_url: undefined, - - get sound() { - return this.playlist.sound; - }, - - init_events: function() { - var self = this; - - function time_from_progress(event) { - bounding = self.progress.bar.getBoundingClientRect() - offset = (event.clientX - bounding.left); - return offset * self.audio.duration / bounding.width; - } - - function update_info() { - var progress = self.progress; - var pos = self.audio.currentTime; - var position = self.sound.position_item; - - // progress - if(!self.audio || !self.audio.seekable || - !pos || self.audio.duration == Infinity) - { - position.innerHTML = ''; - progress.bar.value = 0; - return; + if(this.data_url) { + if(!this.interval) + this.data_url = undefined; + if(this.run) { + this.run = false; + this.start(); } - - progress.bar.value = pos; - progress.bar.max = self.audio.duration; - position.innerHTML = duration_str(pos); } + } - // audio - this.audio.addEventListener('playing', function() { - self.player.setAttribute('state', 'playing'); - }, false); + start() { + if(this.run || !this.interval || !this.data_url) + return; + this.run = true; + this.fetch_data(); + } - this.audio.addEventListener('pause', function() { - self.player.setAttribute('state', 'paused'); - }, false); + stop() { + this.run = false; + } - this.audio.addEventListener('loadstart', function() { - self.player.setAttribute('state', 'loading'); - }, false); - - this.audio.addEventListener('loadeddata', function() { - self.player.removeAttribute('state'); - }, false); - - this.audio.addEventListener('timeupdate', update_info, false); - - this.audio.addEventListener('ended', function() { - self.player.removeAttribute('state'); - if(!self.controls.single.checked) - self.playlist.next(true); - }, false); - - // progress - progress = this.progress.bar; - progress.addEventListener('click', function(event) { - self.audio.currentTime = time_from_progress(event); - event.preventDefault(); - event.stopImmediatePropagation(); - }, false); - - progress.addEventListener('mouseout', update_info, false); - - progress.addEventListener('mousemove', function(event) { - if(self.audio.duration == Infinity || isNaN(self.audio.duration)) - return; - - var pos = time_from_progress(event); - var position = self.sound.position_item; - position.innerHTML = duration_str(pos); - }, false); - }, - - update_on_air: function() { - if(!this.on_air_url) + fetch_data() { + if(!this.run || !this.interval || !this.data_url) return; var self = this; - window.setTimeout(function() { - self.update_on_air(); - }, 60*5000); - - if(!this.playlist.on_air) - return; - var req = new XMLHttpRequest(); - req.open('GET', this.on_air_url, true); + req.open('GET', this.data_url, true); req.onreadystatechange = function() { - if(req.readyState != 4 || (req.status != 200 && - req.status != 0)) + if(req.readyState != 4 || (req.status && req.status != 200)) return; - if(!req.responseText.length) return; - var data = JSON.parse(req.responseText) + // TODO: more consistent API + var data = JSON.parse(req.responseText); if(data.type == 'track') data = { - title: '♫ ' + (data.artist ? data.artist + ' — ' : '') + + name: '♫ ' + (data.artist ? data.artist + ' — ' : '') + data.title, - url: '' + data_url: '' } else data = { title: data.title, - info: '', - url: data.url + data_url: data.url } - - var on_air = self.playlist.on_air; - on_air = on_air.item.querySelector('.content'); - - if(data.url) - on_air.innerHTML = - '' + data.title + ''; - else - on_air.innerHTML = data.title; + Object.assign(self, data); }; req.send(); - }, - play: function() { - if(ActivePlayer && ActivePlayer != this) { - ActivePlayer.stop(); - } - ActivePlayer = this; + if(this.run && this.interval) + this._trigger_fetch(); + } - if(this.audio.paused) - this.audio.play(); - else - this.audio.pause(); - }, - - stop: function() { - this.audio.pause(); - this.player.removeAttribute('state'); - }, - - __mime_type: function(path) { - ext = path.substr(path.lastIndexOf('.')+1); - return 'audio/' + ext; - }, - - load_sound: function(sound) { - var audio = this.audio; - audio.pause(); - - var sources = audio.querySelectorAll('source'); - for(var i = 0; i < sources.length; i++) - audio.removeChild(sources[i]); - - streams = sound.streams; - for(var i = 0; i < streams.length; i++) { - var source = document.createElement('source'); - source.src = streams[i]; - source.type = this.__mime_type(source.src); - audio.appendChild(source); - } - audio.load(); - }, - - save: function() { - // TODO: move stored sound into playlist - this.store.set('player', { - single: this.controls.single.checked, - sound: this.playlist.sound && this.playlist.sound.streams, - }); - }, - - load: function() { - var data = this.store.get('player'); - if(!data) + _trigger_fetch() { + if(!this.run || !this.data_url) return; - this.controls.single.checked = data.single; - if(data.sound) - this.playlist.sound = this.playlist.find(data.sound); - }, + + var self = this; + if(this.interval) + window.setTimeout(function() { + self.fetch_data(); + }, this.interval*1000); + else + this.fetch_data(); + } } +/// Current selected sound (being played) +var CurrentSound = null; + +var Sound = Vue.extend({ + template: '#template-sound', + delimiters: ['[[', ']]'], + + data: function() { + return { + mounted: false, + // sound state, + state: State.Stop, + // current position in playing sound + position: 0, + // estimated position when user mouse over progress bar + seek_position: null, + // url to the page related to the sound + detail_url: '', + }; + }, + + computed: { + // sound can be seeked + seekable: function() { + // seekable: for the moment only when we have a podcast file + // note: need mounted because $refs is not reactive + return this.mounted && this.duration && this.$refs.audio.seekable; + }, + + // sound duration in seconds + duration: function() { + if(this.track.duration) + return this.track.duration[0] * 3600 + + this.track.duration[1] * 60 + + this.track.duration[2]; + return null; + }, + }, + + props: { + track: { type: Object, required: true }, + }, + + mounted() { + this.mounted = true; + console.log(this.track, this.track.detail_url); + this.detail_url = this.track.detail_url; + this.storage_key = "sound." + this.track.sources[0]; + + var pos = localStorage.getItem(this.storage_key) + if(pos) try { + // go back of 5 seconds + pos = parseFloat(pos) - 5; + if(pos > 0) + this.$refs.audio.currentTime = pos; + } catch (e) {} + }, + + methods: { + // + // Common methods + // + stop() { + this.$refs.audio.pause(); + CurrentSound = null; + }, + + play(reset = false) { + if(CurrentSound && CurrentSound != this) + CurrentSound.stop(); + CurrentSound = this; + if(reset) + this.$refs.audio.currentTime = 0; + this.$refs.audio.play(); + }, + + play_stop() { + if(this.state == State.Stop) + this.play(); + else + this.stop(); + }, + + add_to_playlist() { + if(!DefaultPlaylist) + return; + var tracks = DefaultPlaylist.tracks; + if(tracks.indexOf(this.track) == -1) + DefaultPlaylist.tracks.push(this.track); + }, + + remove() { + this.stop(); + var tracks = this.$parent.tracks; + var i = tracks.indexOf(this.track); + if(i == -1) + return; + tracks.splice(i, 1); + }, + + // + // Events + // + timeUpdate() { + this.position = this.$refs.audio.currentTime; + if(this.state == State.Play) + localStorage.setItem( + this.storage_key, this.$refs.audio.currentTime + ); + }, + + ended() { + this.state = State.Stop; + this.$refs.audio.currentTime = 0; + localStorage.removeItem(this.storage_key); + this.$emit('ended', this); + }, + + _as_progress_time(event) { + bounding = this.$refs.progress.getBoundingClientRect() + offset = (event.clientX - bounding.left); + return offset * this.$refs.audio.duration / bounding.width; + }, + + progress_mouse_out(event) { + this.seek_position = null; + }, + + progress_mouse_move(event) { + if(this.$refs.audio.duration == Infinity || + isNaN(this.$refs.audio.duration)) + return; + this.seek_position = this._as_progress_time(event); + }, + + progress_clicked(event) { + this.$refs.audio.currentTime = this._as_progress_time(event); + this.play(); + event.stopImmediatePropagation(); + }, + } +}); + + +/// User's default playlist +DefaultPlaylist = null; + +var Playlist = Vue.extend({ + template: '#template-playlist', + delimiters: ['[[', ']]'], + data() { + return { + // if true, use this playlist as user's default playlist + default: false, + // single mode enabled + single_mode: false, + // playlist can be modified by user + modifiable: false, + // if set, save items into localstorage using this root key + storage_key: null, + // sounds info + tracks: [], + }; + }, + + mounted() { + // set default + if(this.default) { + if(DefaultPlaylist) + this.tracks = DefaultPlaylist.tracks; + else + DefaultPlaylist = this; + } + + // storage_key + if(this.storage_key) { + tracks = localStorage.getItem('playlist.' + this.storage_key); + if(tracks) + this.tracks = JSON.parse(tracks); + } + }, + + methods: { + sound_ended(sound) { + // ensure sound is stopped (beforeDestroy()) + sound.stop(); + + // next only when single mode + if(this.single_mode) + return; + + var sounds = this.$refs.sounds; + var id = sounds.findIndex(s => s == sound); + if(id < 0 || id+1 >= sounds.length) + return + id++; + sounds[id].play(true); + }, + }, + + watch: { + tracks: { + handler() { + if(!this.storage_key) + return; + localStorage.setItem('playlist.' + this.storage_key, + JSON.stringify(this.tracks)); + }, + deep: true, + } + } +}); + +Vue.component('a-sound', Sound); +Vue.component('a-playlist', Playlist); + diff --git a/aircox_cms/static/lib/vue.js b/aircox_cms/static/lib/vue.js new file mode 100644 index 0000000..9d84c53 --- /dev/null +++ b/aircox_cms/static/lib/vue.js @@ -0,0 +1,10798 @@ +/*! + * Vue.js v2.5.13 + * (c) 2014-2017 Evan You + * Released under the MIT License. + */ +(function (global, factory) { + typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : + typeof define === 'function' && define.amd ? define(factory) : + (global.Vue = factory()); +}(this, (function () { 'use strict'; + +/* */ + +var emptyObject = Object.freeze({}); + +// these helpers produces better vm code in JS engines due to their +// explicitness and function inlining +function isUndef (v) { + return v === undefined || v === null +} + +function isDef (v) { + return v !== undefined && v !== null +} + +function isTrue (v) { + return v === true +} + +function isFalse (v) { + return v === false +} + +/** + * Check if value is primitive + */ +function isPrimitive (value) { + return ( + typeof value === 'string' || + typeof value === 'number' || + // $flow-disable-line + typeof value === 'symbol' || + typeof value === 'boolean' + ) +} + +/** + * Quick object check - this is primarily used to tell + * Objects from primitive values when we know the value + * is a JSON-compliant type. + */ +function isObject (obj) { + return obj !== null && typeof obj === 'object' +} + +/** + * Get the raw type string of a value e.g. [object Object] + */ +var _toString = Object.prototype.toString; + +function toRawType (value) { + return _toString.call(value).slice(8, -1) +} + +/** + * Strict object type check. Only returns true + * for plain JavaScript objects. + */ +function isPlainObject (obj) { + return _toString.call(obj) === '[object Object]' +} + +function isRegExp (v) { + return _toString.call(v) === '[object RegExp]' +} + +/** + * Check if val is a valid array index. + */ +function isValidArrayIndex (val) { + var n = parseFloat(String(val)); + return n >= 0 && Math.floor(n) === n && isFinite(val) +} + +/** + * Convert a value to a string that is actually rendered. + */ +function toString (val) { + return val == null + ? '' + : typeof val === 'object' + ? JSON.stringify(val, null, 2) + : String(val) +} + +/** + * Convert a input value to a number for persistence. + * If the conversion fails, return original string. + */ +function toNumber (val) { + var n = parseFloat(val); + return isNaN(n) ? val : n +} + +/** + * Make a map and return a function for checking if a key + * is in that map. + */ +function makeMap ( + str, + expectsLowerCase +) { + var map = Object.create(null); + var list = str.split(','); + for (var i = 0; i < list.length; i++) { + map[list[i]] = true; + } + return expectsLowerCase + ? function (val) { return map[val.toLowerCase()]; } + : function (val) { return map[val]; } +} + +/** + * Check if a tag is a built-in tag. + */ +var isBuiltInTag = makeMap('slot,component', true); + +/** + * Check if a attribute is a reserved attribute. + */ +var isReservedAttribute = makeMap('key,ref,slot,slot-scope,is'); + +/** + * Remove an item from an array + */ +function remove (arr, item) { + if (arr.length) { + var index = arr.indexOf(item); + if (index > -1) { + return arr.splice(index, 1) + } + } +} + +/** + * Check whether the object has the property. + */ +var hasOwnProperty = Object.prototype.hasOwnProperty; +function hasOwn (obj, key) { + return hasOwnProperty.call(obj, key) +} + +/** + * Create a cached version of a pure function. + */ +function cached (fn) { + var cache = Object.create(null); + return (function cachedFn (str) { + var hit = cache[str]; + return hit || (cache[str] = fn(str)) + }) +} + +/** + * Camelize a hyphen-delimited string. + */ +var camelizeRE = /-(\w)/g; +var camelize = cached(function (str) { + return str.replace(camelizeRE, function (_, c) { return c ? c.toUpperCase() : ''; }) +}); + +/** + * Capitalize a string. + */ +var capitalize = cached(function (str) { + return str.charAt(0).toUpperCase() + str.slice(1) +}); + +/** + * Hyphenate a camelCase string. + */ +var hyphenateRE = /\B([A-Z])/g; +var hyphenate = cached(function (str) { + return str.replace(hyphenateRE, '-$1').toLowerCase() +}); + +/** + * Simple bind, faster than native + */ +function bind (fn, ctx) { + function boundFn (a) { + var l = arguments.length; + return l + ? l > 1 + ? fn.apply(ctx, arguments) + : fn.call(ctx, a) + : fn.call(ctx) + } + // record original fn length + boundFn._length = fn.length; + return boundFn +} + +/** + * Convert an Array-like object to a real Array. + */ +function toArray (list, start) { + start = start || 0; + var i = list.length - start; + var ret = new Array(i); + while (i--) { + ret[i] = list[i + start]; + } + return ret +} + +/** + * Mix properties into target object. + */ +function extend (to, _from) { + for (var key in _from) { + to[key] = _from[key]; + } + return to +} + +/** + * Merge an Array of Objects into a single Object. + */ +function toObject (arr) { + var res = {}; + for (var i = 0; i < arr.length; i++) { + if (arr[i]) { + extend(res, arr[i]); + } + } + return res +} + +/** + * Perform no operation. + * Stubbing args to make Flow happy without leaving useless transpiled code + * with ...rest (https://flow.org/blog/2017/05/07/Strict-Function-Call-Arity/) + */ +function noop (a, b, c) {} + +/** + * Always return false. + */ +var no = function (a, b, c) { return false; }; + +/** + * Return same value + */ +var identity = function (_) { return _; }; + +/** + * Generate a static keys string from compiler modules. + */ +function genStaticKeys (modules) { + return modules.reduce(function (keys, m) { + return keys.concat(m.staticKeys || []) + }, []).join(',') +} + +/** + * Check if two values are loosely equal - that is, + * if they are plain objects, do they have the same shape? + */ +function looseEqual (a, b) { + if (a === b) { return true } + var isObjectA = isObject(a); + var isObjectB = isObject(b); + if (isObjectA && isObjectB) { + try { + var isArrayA = Array.isArray(a); + var isArrayB = Array.isArray(b); + if (isArrayA && isArrayB) { + return a.length === b.length && a.every(function (e, i) { + return looseEqual(e, b[i]) + }) + } else if (!isArrayA && !isArrayB) { + var keysA = Object.keys(a); + var keysB = Object.keys(b); + return keysA.length === keysB.length && keysA.every(function (key) { + return looseEqual(a[key], b[key]) + }) + } else { + /* istanbul ignore next */ + return false + } + } catch (e) { + /* istanbul ignore next */ + return false + } + } else if (!isObjectA && !isObjectB) { + return String(a) === String(b) + } else { + return false + } +} + +function looseIndexOf (arr, val) { + for (var i = 0; i < arr.length; i++) { + if (looseEqual(arr[i], val)) { return i } + } + return -1 +} + +/** + * Ensure a function is called only once. + */ +function once (fn) { + var called = false; + return function () { + if (!called) { + called = true; + fn.apply(this, arguments); + } + } +} + +var SSR_ATTR = 'data-server-rendered'; + +var ASSET_TYPES = [ + 'component', + 'directive', + 'filter' +]; + +var LIFECYCLE_HOOKS = [ + 'beforeCreate', + 'created', + 'beforeMount', + 'mounted', + 'beforeUpdate', + 'updated', + 'beforeDestroy', + 'destroyed', + 'activated', + 'deactivated', + 'errorCaptured' +]; + +/* */ + +var config = ({ + /** + * Option merge strategies (used in core/util/options) + */ + // $flow-disable-line + optionMergeStrategies: Object.create(null), + + /** + * Whether to suppress warnings. + */ + silent: false, + + /** + * Show production mode tip message on boot? + */ + productionTip: "development" !== 'production', + + /** + * Whether to enable devtools + */ + devtools: "development" !== 'production', + + /** + * Whether to record perf + */ + performance: false, + + /** + * Error handler for watcher errors + */ + errorHandler: null, + + /** + * Warn handler for watcher warns + */ + warnHandler: null, + + /** + * Ignore certain custom elements + */ + ignoredElements: [], + + /** + * Custom user key aliases for v-on + */ + // $flow-disable-line + keyCodes: Object.create(null), + + /** + * Check if a tag is reserved so that it cannot be registered as a + * component. This is platform-dependent and may be overwritten. + */ + isReservedTag: no, + + /** + * Check if an attribute is reserved so that it cannot be used as a component + * prop. This is platform-dependent and may be overwritten. + */ + isReservedAttr: no, + + /** + * Check if a tag is an unknown element. + * Platform-dependent. + */ + isUnknownElement: no, + + /** + * Get the namespace of an element + */ + getTagNamespace: noop, + + /** + * Parse the real tag name for the specific platform. + */ + parsePlatformTagName: identity, + + /** + * Check if an attribute must be bound using property, e.g. value + * Platform-dependent. + */ + mustUseProp: no, + + /** + * Exposed for legacy reasons + */ + _lifecycleHooks: LIFECYCLE_HOOKS +}); + +/* */ + +/** + * Check if a string starts with $ or _ + */ +function isReserved (str) { + var c = (str + '').charCodeAt(0); + return c === 0x24 || c === 0x5F +} + +/** + * Define a property. + */ +function def (obj, key, val, enumerable) { + Object.defineProperty(obj, key, { + value: val, + enumerable: !!enumerable, + writable: true, + configurable: true + }); +} + +/** + * Parse simple path. + */ +var bailRE = /[^\w.$]/; +function parsePath (path) { + if (bailRE.test(path)) { + return + } + var segments = path.split('.'); + return function (obj) { + for (var i = 0; i < segments.length; i++) { + if (!obj) { return } + obj = obj[segments[i]]; + } + return obj + } +} + +/* */ + + +// can we use __proto__? +var hasProto = '__proto__' in {}; + +// Browser environment sniffing +var inBrowser = typeof window !== 'undefined'; +var inWeex = typeof WXEnvironment !== 'undefined' && !!WXEnvironment.platform; +var weexPlatform = inWeex && WXEnvironment.platform.toLowerCase(); +var UA = inBrowser && window.navigator.userAgent.toLowerCase(); +var isIE = UA && /msie|trident/.test(UA); +var isIE9 = UA && UA.indexOf('msie 9.0') > 0; +var isEdge = UA && UA.indexOf('edge/') > 0; +var isAndroid = (UA && UA.indexOf('android') > 0) || (weexPlatform === 'android'); +var isIOS = (UA && /iphone|ipad|ipod|ios/.test(UA)) || (weexPlatform === 'ios'); +var isChrome = UA && /chrome\/\d+/.test(UA) && !isEdge; + +// Firefox has a "watch" function on Object.prototype... +var nativeWatch = ({}).watch; + +var supportsPassive = false; +if (inBrowser) { + try { + var opts = {}; + Object.defineProperty(opts, 'passive', ({ + get: function get () { + /* istanbul ignore next */ + supportsPassive = true; + } + })); // https://github.com/facebook/flow/issues/285 + window.addEventListener('test-passive', null, opts); + } catch (e) {} +} + +// this needs to be lazy-evaled because vue may be required before +// vue-server-renderer can set VUE_ENV +var _isServer; +var isServerRendering = function () { + if (_isServer === undefined) { + /* istanbul ignore if */ + if (!inBrowser && typeof global !== 'undefined') { + // detect presence of vue-server-renderer and avoid + // Webpack shimming the process + _isServer = global['process'].env.VUE_ENV === 'server'; + } else { + _isServer = false; + } + } + return _isServer +}; + +// detect devtools +var devtools = inBrowser && window.__VUE_DEVTOOLS_GLOBAL_HOOK__; + +/* istanbul ignore next */ +function isNative (Ctor) { + return typeof Ctor === 'function' && /native code/.test(Ctor.toString()) +} + +var hasSymbol = + typeof Symbol !== 'undefined' && isNative(Symbol) && + typeof Reflect !== 'undefined' && isNative(Reflect.ownKeys); + +var _Set; +/* istanbul ignore if */ // $flow-disable-line +if (typeof Set !== 'undefined' && isNative(Set)) { + // use native Set when available. + _Set = Set; +} else { + // a non-standard Set polyfill that only works with primitive keys. + _Set = (function () { + function Set () { + this.set = Object.create(null); + } + Set.prototype.has = function has (key) { + return this.set[key] === true + }; + Set.prototype.add = function add (key) { + this.set[key] = true; + }; + Set.prototype.clear = function clear () { + this.set = Object.create(null); + }; + + return Set; + }()); +} + +/* */ + +var warn = noop; +var tip = noop; +var generateComponentTrace = (noop); // work around flow check +var formatComponentName = (noop); + +{ + var hasConsole = typeof console !== 'undefined'; + var classifyRE = /(?:^|[-_])(\w)/g; + var classify = function (str) { return str + .replace(classifyRE, function (c) { return c.toUpperCase(); }) + .replace(/[-_]/g, ''); }; + + warn = function (msg, vm) { + var trace = vm ? generateComponentTrace(vm) : ''; + + if (config.warnHandler) { + config.warnHandler.call(null, msg, vm, trace); + } else if (hasConsole && (!config.silent)) { + console.error(("[Vue warn]: " + msg + trace)); + } + }; + + tip = function (msg, vm) { + if (hasConsole && (!config.silent)) { + console.warn("[Vue tip]: " + msg + ( + vm ? generateComponentTrace(vm) : '' + )); + } + }; + + formatComponentName = function (vm, includeFile) { + if (vm.$root === vm) { + return '' + } + var options = typeof vm === 'function' && vm.cid != null + ? vm.options + : vm._isVue + ? vm.$options || vm.constructor.options + : vm || {}; + var name = options.name || options._componentTag; + var file = options.__file; + if (!name && file) { + var match = file.match(/([^/\\]+)\.vue$/); + name = match && match[1]; + } + + return ( + (name ? ("<" + (classify(name)) + ">") : "") + + (file && includeFile !== false ? (" at " + file) : '') + ) + }; + + var repeat = function (str, n) { + var res = ''; + while (n) { + if (n % 2 === 1) { res += str; } + if (n > 1) { str += str; } + n >>= 1; + } + return res + }; + + generateComponentTrace = function (vm) { + if (vm._isVue && vm.$parent) { + var tree = []; + var currentRecursiveSequence = 0; + while (vm) { + if (tree.length > 0) { + var last = tree[tree.length - 1]; + if (last.constructor === vm.constructor) { + currentRecursiveSequence++; + vm = vm.$parent; + continue + } else if (currentRecursiveSequence > 0) { + tree[tree.length - 1] = [last, currentRecursiveSequence]; + currentRecursiveSequence = 0; + } + } + tree.push(vm); + vm = vm.$parent; + } + return '\n\nfound in\n\n' + tree + .map(function (vm, i) { return ("" + (i === 0 ? '---> ' : repeat(' ', 5 + i * 2)) + (Array.isArray(vm) + ? ((formatComponentName(vm[0])) + "... (" + (vm[1]) + " recursive calls)") + : formatComponentName(vm))); }) + .join('\n') + } else { + return ("\n\n(found in " + (formatComponentName(vm)) + ")") + } + }; +} + +/* */ + + +var uid = 0; + +/** + * A dep is an observable that can have multiple + * directives subscribing to it. + */ +var Dep = function Dep () { + this.id = uid++; + this.subs = []; +}; + +Dep.prototype.addSub = function addSub (sub) { + this.subs.push(sub); +}; + +Dep.prototype.removeSub = function removeSub (sub) { + remove(this.subs, sub); +}; + +Dep.prototype.depend = function depend () { + if (Dep.target) { + Dep.target.addDep(this); + } +}; + +Dep.prototype.notify = function notify () { + // stabilize the subscriber list first + var subs = this.subs.slice(); + for (var i = 0, l = subs.length; i < l; i++) { + subs[i].update(); + } +}; + +// the current target watcher being evaluated. +// this is globally unique because there could be only one +// watcher being evaluated at any time. +Dep.target = null; +var targetStack = []; + +function pushTarget (_target) { + if (Dep.target) { targetStack.push(Dep.target); } + Dep.target = _target; +} + +function popTarget () { + Dep.target = targetStack.pop(); +} + +/* */ + +var VNode = function VNode ( + tag, + data, + children, + text, + elm, + context, + componentOptions, + asyncFactory +) { + this.tag = tag; + this.data = data; + this.children = children; + this.text = text; + this.elm = elm; + this.ns = undefined; + this.context = context; + this.fnContext = undefined; + this.fnOptions = undefined; + this.fnScopeId = undefined; + this.key = data && data.key; + this.componentOptions = componentOptions; + this.componentInstance = undefined; + this.parent = undefined; + this.raw = false; + this.isStatic = false; + this.isRootInsert = true; + this.isComment = false; + this.isCloned = false; + this.isOnce = false; + this.asyncFactory = asyncFactory; + this.asyncMeta = undefined; + this.isAsyncPlaceholder = false; +}; + +var prototypeAccessors = { child: { configurable: true } }; + +// DEPRECATED: alias for componentInstance for backwards compat. +/* istanbul ignore next */ +prototypeAccessors.child.get = function () { + return this.componentInstance +}; + +Object.defineProperties( VNode.prototype, prototypeAccessors ); + +var createEmptyVNode = function (text) { + if ( text === void 0 ) text = ''; + + var node = new VNode(); + node.text = text; + node.isComment = true; + return node +}; + +function createTextVNode (val) { + return new VNode(undefined, undefined, undefined, String(val)) +} + +// optimized shallow clone +// used for static nodes and slot nodes because they may be reused across +// multiple renders, cloning them avoids errors when DOM manipulations rely +// on their elm reference. +function cloneVNode (vnode, deep) { + var componentOptions = vnode.componentOptions; + var cloned = new VNode( + vnode.tag, + vnode.data, + vnode.children, + vnode.text, + vnode.elm, + vnode.context, + componentOptions, + vnode.asyncFactory + ); + cloned.ns = vnode.ns; + cloned.isStatic = vnode.isStatic; + cloned.key = vnode.key; + cloned.isComment = vnode.isComment; + cloned.fnContext = vnode.fnContext; + cloned.fnOptions = vnode.fnOptions; + cloned.fnScopeId = vnode.fnScopeId; + cloned.isCloned = true; + if (deep) { + if (vnode.children) { + cloned.children = cloneVNodes(vnode.children, true); + } + if (componentOptions && componentOptions.children) { + componentOptions.children = cloneVNodes(componentOptions.children, true); + } + } + return cloned +} + +function cloneVNodes (vnodes, deep) { + var len = vnodes.length; + var res = new Array(len); + for (var i = 0; i < len; i++) { + res[i] = cloneVNode(vnodes[i], deep); + } + return res +} + +/* + * not type checking this file because flow doesn't play well with + * dynamically accessing methods on Array prototype + */ + +var arrayProto = Array.prototype; +var arrayMethods = Object.create(arrayProto);[ + 'push', + 'pop', + 'shift', + 'unshift', + 'splice', + 'sort', + 'reverse' +].forEach(function (method) { + // cache original method + var original = arrayProto[method]; + def(arrayMethods, method, function mutator () { + var args = [], len = arguments.length; + while ( len-- ) args[ len ] = arguments[ len ]; + + var result = original.apply(this, args); + var ob = this.__ob__; + var inserted; + switch (method) { + case 'push': + case 'unshift': + inserted = args; + break + case 'splice': + inserted = args.slice(2); + break + } + if (inserted) { ob.observeArray(inserted); } + // notify change + ob.dep.notify(); + return result + }); +}); + +/* */ + +var arrayKeys = Object.getOwnPropertyNames(arrayMethods); + +/** + * By default, when a reactive property is set, the new value is + * also converted to become reactive. However when passing down props, + * we don't want to force conversion because the value may be a nested value + * under a frozen data structure. Converting it would defeat the optimization. + */ +var observerState = { + shouldConvert: true +}; + +/** + * Observer class that are attached to each observed + * object. Once attached, the observer converts target + * object's property keys into getter/setters that + * collect dependencies and dispatches updates. + */ +var Observer = function Observer (value) { + this.value = value; + this.dep = new Dep(); + this.vmCount = 0; + def(value, '__ob__', this); + if (Array.isArray(value)) { + var augment = hasProto + ? protoAugment + : copyAugment; + augment(value, arrayMethods, arrayKeys); + this.observeArray(value); + } else { + this.walk(value); + } +}; + +/** + * Walk through each property and convert them into + * getter/setters. This method should only be called when + * value type is Object. + */ +Observer.prototype.walk = function walk (obj) { + var keys = Object.keys(obj); + for (var i = 0; i < keys.length; i++) { + defineReactive(obj, keys[i], obj[keys[i]]); + } +}; + +/** + * Observe a list of Array items. + */ +Observer.prototype.observeArray = function observeArray (items) { + for (var i = 0, l = items.length; i < l; i++) { + observe(items[i]); + } +}; + +// helpers + +/** + * Augment an target Object or Array by intercepting + * the prototype chain using __proto__ + */ +function protoAugment (target, src, keys) { + /* eslint-disable no-proto */ + target.__proto__ = src; + /* eslint-enable no-proto */ +} + +/** + * Augment an target Object or Array by defining + * hidden properties. + */ +/* istanbul ignore next */ +function copyAugment (target, src, keys) { + for (var i = 0, l = keys.length; i < l; i++) { + var key = keys[i]; + def(target, key, src[key]); + } +} + +/** + * Attempt to create an observer instance for a value, + * returns the new observer if successfully observed, + * or the existing observer if the value already has one. + */ +function observe (value, asRootData) { + if (!isObject(value) || value instanceof VNode) { + return + } + var ob; + if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) { + ob = value.__ob__; + } else if ( + observerState.shouldConvert && + !isServerRendering() && + (Array.isArray(value) || isPlainObject(value)) && + Object.isExtensible(value) && + !value._isVue + ) { + ob = new Observer(value); + } + if (asRootData && ob) { + ob.vmCount++; + } + return ob +} + +/** + * Define a reactive property on an Object. + */ +function defineReactive ( + obj, + key, + val, + customSetter, + shallow +) { + var dep = new Dep(); + + var property = Object.getOwnPropertyDescriptor(obj, key); + if (property && property.configurable === false) { + return + } + + // cater for pre-defined getter/setters + var getter = property && property.get; + var setter = property && property.set; + + var childOb = !shallow && observe(val); + Object.defineProperty(obj, key, { + enumerable: true, + configurable: true, + get: function reactiveGetter () { + var value = getter ? getter.call(obj) : val; + if (Dep.target) { + dep.depend(); + if (childOb) { + childOb.dep.depend(); + if (Array.isArray(value)) { + dependArray(value); + } + } + } + return value + }, + set: function reactiveSetter (newVal) { + var value = getter ? getter.call(obj) : val; + /* eslint-disable no-self-compare */ + if (newVal === value || (newVal !== newVal && value !== value)) { + return + } + /* eslint-enable no-self-compare */ + if ("development" !== 'production' && customSetter) { + customSetter(); + } + if (setter) { + setter.call(obj, newVal); + } else { + val = newVal; + } + childOb = !shallow && observe(newVal); + dep.notify(); + } + }); +} + +/** + * Set a property on an object. Adds the new property and + * triggers change notification if the property doesn't + * already exist. + */ +function set (target, key, val) { + if (Array.isArray(target) && isValidArrayIndex(key)) { + target.length = Math.max(target.length, key); + target.splice(key, 1, val); + return val + } + if (key in target && !(key in Object.prototype)) { + target[key] = val; + return val + } + var ob = (target).__ob__; + if (target._isVue || (ob && ob.vmCount)) { + "development" !== 'production' && warn( + 'Avoid adding reactive properties to a Vue instance or its root $data ' + + 'at runtime - declare it upfront in the data option.' + ); + return val + } + if (!ob) { + target[key] = val; + return val + } + defineReactive(ob.value, key, val); + ob.dep.notify(); + return val +} + +/** + * Delete a property and trigger change if necessary. + */ +function del (target, key) { + if (Array.isArray(target) && isValidArrayIndex(key)) { + target.splice(key, 1); + return + } + var ob = (target).__ob__; + if (target._isVue || (ob && ob.vmCount)) { + "development" !== 'production' && warn( + 'Avoid deleting properties on a Vue instance or its root $data ' + + '- just set it to null.' + ); + return + } + if (!hasOwn(target, key)) { + return + } + delete target[key]; + if (!ob) { + return + } + ob.dep.notify(); +} + +/** + * Collect dependencies on array elements when the array is touched, since + * we cannot intercept array element access like property getters. + */ +function dependArray (value) { + for (var e = (void 0), i = 0, l = value.length; i < l; i++) { + e = value[i]; + e && e.__ob__ && e.__ob__.dep.depend(); + if (Array.isArray(e)) { + dependArray(e); + } + } +} + +/* */ + +/** + * Option overwriting strategies are functions that handle + * how to merge a parent option value and a child option + * value into the final value. + */ +var strats = config.optionMergeStrategies; + +/** + * Options with restrictions + */ +{ + strats.el = strats.propsData = function (parent, child, vm, key) { + if (!vm) { + warn( + "option \"" + key + "\" can only be used during instance " + + 'creation with the `new` keyword.' + ); + } + return defaultStrat(parent, child) + }; +} + +/** + * Helper that recursively merges two data objects together. + */ +function mergeData (to, from) { + if (!from) { return to } + var key, toVal, fromVal; + var keys = Object.keys(from); + for (var i = 0; i < keys.length; i++) { + key = keys[i]; + toVal = to[key]; + fromVal = from[key]; + if (!hasOwn(to, key)) { + set(to, key, fromVal); + } else if (isPlainObject(toVal) && isPlainObject(fromVal)) { + mergeData(toVal, fromVal); + } + } + return to +} + +/** + * Data + */ +function mergeDataOrFn ( + parentVal, + childVal, + vm +) { + if (!vm) { + // in a Vue.extend merge, both should be functions + if (!childVal) { + return parentVal + } + if (!parentVal) { + return childVal + } + // when parentVal & childVal are both present, + // we need to return a function that returns the + // merged result of both functions... no need to + // check if parentVal is a function here because + // it has to be a function to pass previous merges. + return function mergedDataFn () { + return mergeData( + typeof childVal === 'function' ? childVal.call(this, this) : childVal, + typeof parentVal === 'function' ? parentVal.call(this, this) : parentVal + ) + } + } else { + return function mergedInstanceDataFn () { + // instance merge + var instanceData = typeof childVal === 'function' + ? childVal.call(vm, vm) + : childVal; + var defaultData = typeof parentVal === 'function' + ? parentVal.call(vm, vm) + : parentVal; + if (instanceData) { + return mergeData(instanceData, defaultData) + } else { + return defaultData + } + } + } +} + +strats.data = function ( + parentVal, + childVal, + vm +) { + if (!vm) { + if (childVal && typeof childVal !== 'function') { + "development" !== 'production' && warn( + 'The "data" option should be a function ' + + 'that returns a per-instance value in component ' + + 'definitions.', + vm + ); + + return parentVal + } + return mergeDataOrFn(parentVal, childVal) + } + + return mergeDataOrFn(parentVal, childVal, vm) +}; + +/** + * Hooks and props are merged as arrays. + */ +function mergeHook ( + parentVal, + childVal +) { + return childVal + ? parentVal + ? parentVal.concat(childVal) + : Array.isArray(childVal) + ? childVal + : [childVal] + : parentVal +} + +LIFECYCLE_HOOKS.forEach(function (hook) { + strats[hook] = mergeHook; +}); + +/** + * Assets + * + * When a vm is present (instance creation), we need to do + * a three-way merge between constructor options, instance + * options and parent options. + */ +function mergeAssets ( + parentVal, + childVal, + vm, + key +) { + var res = Object.create(parentVal || null); + if (childVal) { + "development" !== 'production' && assertObjectType(key, childVal, vm); + return extend(res, childVal) + } else { + return res + } +} + +ASSET_TYPES.forEach(function (type) { + strats[type + 's'] = mergeAssets; +}); + +/** + * Watchers. + * + * Watchers hashes should not overwrite one + * another, so we merge them as arrays. + */ +strats.watch = function ( + parentVal, + childVal, + vm, + key +) { + // work around Firefox's Object.prototype.watch... + if (parentVal === nativeWatch) { parentVal = undefined; } + if (childVal === nativeWatch) { childVal = undefined; } + /* istanbul ignore if */ + if (!childVal) { return Object.create(parentVal || null) } + { + assertObjectType(key, childVal, vm); + } + if (!parentVal) { return childVal } + var ret = {}; + extend(ret, parentVal); + for (var key$1 in childVal) { + var parent = ret[key$1]; + var child = childVal[key$1]; + if (parent && !Array.isArray(parent)) { + parent = [parent]; + } + ret[key$1] = parent + ? parent.concat(child) + : Array.isArray(child) ? child : [child]; + } + return ret +}; + +/** + * Other object hashes. + */ +strats.props = +strats.methods = +strats.inject = +strats.computed = function ( + parentVal, + childVal, + vm, + key +) { + if (childVal && "development" !== 'production') { + assertObjectType(key, childVal, vm); + } + if (!parentVal) { return childVal } + var ret = Object.create(null); + extend(ret, parentVal); + if (childVal) { extend(ret, childVal); } + return ret +}; +strats.provide = mergeDataOrFn; + +/** + * Default strategy. + */ +var defaultStrat = function (parentVal, childVal) { + return childVal === undefined + ? parentVal + : childVal +}; + +/** + * Validate component names + */ +function checkComponents (options) { + for (var key in options.components) { + validateComponentName(key); + } +} + +function validateComponentName (name) { + if (!/^[a-zA-Z][\w-]*$/.test(name)) { + warn( + 'Invalid component name: "' + name + '". Component names ' + + 'can only contain alphanumeric characters and the hyphen, ' + + 'and must start with a letter.' + ); + } + if (isBuiltInTag(name) || config.isReservedTag(name)) { + warn( + 'Do not use built-in or reserved HTML elements as component ' + + 'id: ' + name + ); + } +} + +/** + * Ensure all props option syntax are normalized into the + * Object-based format. + */ +function normalizeProps (options, vm) { + var props = options.props; + if (!props) { return } + var res = {}; + var i, val, name; + if (Array.isArray(props)) { + i = props.length; + while (i--) { + val = props[i]; + if (typeof val === 'string') { + name = camelize(val); + res[name] = { type: null }; + } else { + warn('props must be strings when using array syntax.'); + } + } + } else if (isPlainObject(props)) { + for (var key in props) { + val = props[key]; + name = camelize(key); + res[name] = isPlainObject(val) + ? val + : { type: val }; + } + } else { + warn( + "Invalid value for option \"props\": expected an Array or an Object, " + + "but got " + (toRawType(props)) + ".", + vm + ); + } + options.props = res; +} + +/** + * Normalize all injections into Object-based format + */ +function normalizeInject (options, vm) { + var inject = options.inject; + if (!inject) { return } + var normalized = options.inject = {}; + if (Array.isArray(inject)) { + for (var i = 0; i < inject.length; i++) { + normalized[inject[i]] = { from: inject[i] }; + } + } else if (isPlainObject(inject)) { + for (var key in inject) { + var val = inject[key]; + normalized[key] = isPlainObject(val) + ? extend({ from: key }, val) + : { from: val }; + } + } else { + warn( + "Invalid value for option \"inject\": expected an Array or an Object, " + + "but got " + (toRawType(inject)) + ".", + vm + ); + } +} + +/** + * Normalize raw function directives into object format. + */ +function normalizeDirectives (options) { + var dirs = options.directives; + if (dirs) { + for (var key in dirs) { + var def = dirs[key]; + if (typeof def === 'function') { + dirs[key] = { bind: def, update: def }; + } + } + } +} + +function assertObjectType (name, value, vm) { + if (!isPlainObject(value)) { + warn( + "Invalid value for option \"" + name + "\": expected an Object, " + + "but got " + (toRawType(value)) + ".", + vm + ); + } +} + +/** + * Merge two option objects into a new one. + * Core utility used in both instantiation and inheritance. + */ +function mergeOptions ( + parent, + child, + vm +) { + { + checkComponents(child); + } + + if (typeof child === 'function') { + child = child.options; + } + + normalizeProps(child, vm); + normalizeInject(child, vm); + normalizeDirectives(child); + var extendsFrom = child.extends; + if (extendsFrom) { + parent = mergeOptions(parent, extendsFrom, vm); + } + if (child.mixins) { + for (var i = 0, l = child.mixins.length; i < l; i++) { + parent = mergeOptions(parent, child.mixins[i], vm); + } + } + var options = {}; + var key; + for (key in parent) { + mergeField(key); + } + for (key in child) { + if (!hasOwn(parent, key)) { + mergeField(key); + } + } + function mergeField (key) { + var strat = strats[key] || defaultStrat; + options[key] = strat(parent[key], child[key], vm, key); + } + return options +} + +/** + * Resolve an asset. + * This function is used because child instances need access + * to assets defined in its ancestor chain. + */ +function resolveAsset ( + options, + type, + id, + warnMissing +) { + /* istanbul ignore if */ + if (typeof id !== 'string') { + return + } + var assets = options[type]; + // check local registration variations first + if (hasOwn(assets, id)) { return assets[id] } + var camelizedId = camelize(id); + if (hasOwn(assets, camelizedId)) { return assets[camelizedId] } + var PascalCaseId = capitalize(camelizedId); + if (hasOwn(assets, PascalCaseId)) { return assets[PascalCaseId] } + // fallback to prototype chain + var res = assets[id] || assets[camelizedId] || assets[PascalCaseId]; + if ("development" !== 'production' && warnMissing && !res) { + warn( + 'Failed to resolve ' + type.slice(0, -1) + ': ' + id, + options + ); + } + return res +} + +/* */ + +function validateProp ( + key, + propOptions, + propsData, + vm +) { + var prop = propOptions[key]; + var absent = !hasOwn(propsData, key); + var value = propsData[key]; + // handle boolean props + if (isType(Boolean, prop.type)) { + if (absent && !hasOwn(prop, 'default')) { + value = false; + } else if (!isType(String, prop.type) && (value === '' || value === hyphenate(key))) { + value = true; + } + } + // check default value + if (value === undefined) { + value = getPropDefaultValue(vm, prop, key); + // since the default value is a fresh copy, + // make sure to observe it. + var prevShouldConvert = observerState.shouldConvert; + observerState.shouldConvert = true; + observe(value); + observerState.shouldConvert = prevShouldConvert; + } + { + assertProp(prop, key, value, vm, absent); + } + return value +} + +/** + * Get the default value of a prop. + */ +function getPropDefaultValue (vm, prop, key) { + // no default, return undefined + if (!hasOwn(prop, 'default')) { + return undefined + } + var def = prop.default; + // warn against non-factory defaults for Object & Array + if ("development" !== 'production' && isObject(def)) { + warn( + 'Invalid default value for prop "' + key + '": ' + + 'Props with type Object/Array must use a factory function ' + + 'to return the default value.', + vm + ); + } + // the raw prop value was also undefined from previous render, + // return previous default value to avoid unnecessary watcher trigger + if (vm && vm.$options.propsData && + vm.$options.propsData[key] === undefined && + vm._props[key] !== undefined + ) { + return vm._props[key] + } + // call factory function for non-Function types + // a value is Function if its prototype is function even across different execution context + return typeof def === 'function' && getType(prop.type) !== 'Function' + ? def.call(vm) + : def +} + +/** + * Assert whether a prop is valid. + */ +function assertProp ( + prop, + name, + value, + vm, + absent +) { + if (prop.required && absent) { + warn( + 'Missing required prop: "' + name + '"', + vm + ); + return + } + if (value == null && !prop.required) { + return + } + var type = prop.type; + var valid = !type || type === true; + var expectedTypes = []; + if (type) { + if (!Array.isArray(type)) { + type = [type]; + } + for (var i = 0; i < type.length && !valid; i++) { + var assertedType = assertType(value, type[i]); + expectedTypes.push(assertedType.expectedType || ''); + valid = assertedType.valid; + } + } + if (!valid) { + warn( + "Invalid prop: type check failed for prop \"" + name + "\"." + + " Expected " + (expectedTypes.map(capitalize).join(', ')) + + ", got " + (toRawType(value)) + ".", + vm + ); + return + } + var validator = prop.validator; + if (validator) { + if (!validator(value)) { + warn( + 'Invalid prop: custom validator check failed for prop "' + name + '".', + vm + ); + } + } +} + +var simpleCheckRE = /^(String|Number|Boolean|Function|Symbol)$/; + +function assertType (value, type) { + var valid; + var expectedType = getType(type); + if (simpleCheckRE.test(expectedType)) { + var t = typeof value; + valid = t === expectedType.toLowerCase(); + // for primitive wrapper objects + if (!valid && t === 'object') { + valid = value instanceof type; + } + } else if (expectedType === 'Object') { + valid = isPlainObject(value); + } else if (expectedType === 'Array') { + valid = Array.isArray(value); + } else { + valid = value instanceof type; + } + return { + valid: valid, + expectedType: expectedType + } +} + +/** + * Use function string name to check built-in types, + * because a simple equality check will fail when running + * across different vms / iframes. + */ +function getType (fn) { + var match = fn && fn.toString().match(/^\s*function (\w+)/); + return match ? match[1] : '' +} + +function isType (type, fn) { + if (!Array.isArray(fn)) { + return getType(fn) === getType(type) + } + for (var i = 0, len = fn.length; i < len; i++) { + if (getType(fn[i]) === getType(type)) { + return true + } + } + /* istanbul ignore next */ + return false +} + +/* */ + +function handleError (err, vm, info) { + if (vm) { + var cur = vm; + while ((cur = cur.$parent)) { + var hooks = cur.$options.errorCaptured; + if (hooks) { + for (var i = 0; i < hooks.length; i++) { + try { + var capture = hooks[i].call(cur, err, vm, info) === false; + if (capture) { return } + } catch (e) { + globalHandleError(e, cur, 'errorCaptured hook'); + } + } + } + } + } + globalHandleError(err, vm, info); +} + +function globalHandleError (err, vm, info) { + if (config.errorHandler) { + try { + return config.errorHandler.call(null, err, vm, info) + } catch (e) { + logError(e, null, 'config.errorHandler'); + } + } + logError(err, vm, info); +} + +function logError (err, vm, info) { + { + warn(("Error in " + info + ": \"" + (err.toString()) + "\""), vm); + } + /* istanbul ignore else */ + if ((inBrowser || inWeex) && typeof console !== 'undefined') { + console.error(err); + } else { + throw err + } +} + +/* */ +/* globals MessageChannel */ + +var callbacks = []; +var pending = false; + +function flushCallbacks () { + pending = false; + var copies = callbacks.slice(0); + callbacks.length = 0; + for (var i = 0; i < copies.length; i++) { + copies[i](); + } +} + +// Here we have async deferring wrappers using both micro and macro tasks. +// In < 2.4 we used micro tasks everywhere, but there are some scenarios where +// micro tasks have too high a priority and fires in between supposedly +// sequential events (e.g. #4521, #6690) or even between bubbling of the same +// event (#6566). However, using macro tasks everywhere also has subtle problems +// when state is changed right before repaint (e.g. #6813, out-in transitions). +// Here we use micro task by default, but expose a way to force macro task when +// needed (e.g. in event handlers attached by v-on). +var microTimerFunc; +var macroTimerFunc; +var useMacroTask = false; + +// Determine (macro) Task defer implementation. +// Technically setImmediate should be the ideal choice, but it's only available +// in IE. The only polyfill that consistently queues the callback after all DOM +// events triggered in the same loop is by using MessageChannel. +/* istanbul ignore if */ +if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) { + macroTimerFunc = function () { + setImmediate(flushCallbacks); + }; +} else if (typeof MessageChannel !== 'undefined' && ( + isNative(MessageChannel) || + // PhantomJS + MessageChannel.toString() === '[object MessageChannelConstructor]' +)) { + var channel = new MessageChannel(); + var port = channel.port2; + channel.port1.onmessage = flushCallbacks; + macroTimerFunc = function () { + port.postMessage(1); + }; +} else { + /* istanbul ignore next */ + macroTimerFunc = function () { + setTimeout(flushCallbacks, 0); + }; +} + +// Determine MicroTask defer implementation. +/* istanbul ignore next, $flow-disable-line */ +if (typeof Promise !== 'undefined' && isNative(Promise)) { + var p = Promise.resolve(); + microTimerFunc = function () { + p.then(flushCallbacks); + // in problematic UIWebViews, Promise.then doesn't completely break, but + // it can get stuck in a weird state where callbacks are pushed into the + // microtask queue but the queue isn't being flushed, until the browser + // needs to do some other work, e.g. handle a timer. Therefore we can + // "force" the microtask queue to be flushed by adding an empty timer. + if (isIOS) { setTimeout(noop); } + }; +} else { + // fallback to macro + microTimerFunc = macroTimerFunc; +} + +/** + * Wrap a function so that if any code inside triggers state change, + * the changes are queued using a Task instead of a MicroTask. + */ +function withMacroTask (fn) { + return fn._withTask || (fn._withTask = function () { + useMacroTask = true; + var res = fn.apply(null, arguments); + useMacroTask = false; + return res + }) +} + +function nextTick (cb, ctx) { + var _resolve; + callbacks.push(function () { + if (cb) { + try { + cb.call(ctx); + } catch (e) { + handleError(e, ctx, 'nextTick'); + } + } else if (_resolve) { + _resolve(ctx); + } + }); + if (!pending) { + pending = true; + if (useMacroTask) { + macroTimerFunc(); + } else { + microTimerFunc(); + } + } + // $flow-disable-line + if (!cb && typeof Promise !== 'undefined') { + return new Promise(function (resolve) { + _resolve = resolve; + }) + } +} + +/* */ + +var mark; +var measure; + +{ + var perf = inBrowser && window.performance; + /* istanbul ignore if */ + if ( + perf && + perf.mark && + perf.measure && + perf.clearMarks && + perf.clearMeasures + ) { + mark = function (tag) { return perf.mark(tag); }; + measure = function (name, startTag, endTag) { + perf.measure(name, startTag, endTag); + perf.clearMarks(startTag); + perf.clearMarks(endTag); + perf.clearMeasures(name); + }; + } +} + +/* not type checking this file because flow doesn't play well with Proxy */ + +var initProxy; + +{ + var allowedGlobals = makeMap( + 'Infinity,undefined,NaN,isFinite,isNaN,' + + 'parseFloat,parseInt,decodeURI,decodeURIComponent,encodeURI,encodeURIComponent,' + + 'Math,Number,Date,Array,Object,Boolean,String,RegExp,Map,Set,JSON,Intl,' + + 'require' // for Webpack/Browserify + ); + + var warnNonPresent = function (target, key) { + warn( + "Property or method \"" + key + "\" is not defined on the instance but " + + 'referenced during render. Make sure that this property is reactive, ' + + 'either in the data option, or for class-based components, by ' + + 'initializing the property. ' + + 'See: https://vuejs.org/v2/guide/reactivity.html#Declaring-Reactive-Properties.', + target + ); + }; + + var hasProxy = + typeof Proxy !== 'undefined' && + Proxy.toString().match(/native code/); + + if (hasProxy) { + var isBuiltInModifier = makeMap('stop,prevent,self,ctrl,shift,alt,meta,exact'); + config.keyCodes = new Proxy(config.keyCodes, { + set: function set (target, key, value) { + if (isBuiltInModifier(key)) { + warn(("Avoid overwriting built-in modifier in config.keyCodes: ." + key)); + return false + } else { + target[key] = value; + return true + } + } + }); + } + + var hasHandler = { + has: function has (target, key) { + var has = key in target; + var isAllowed = allowedGlobals(key) || key.charAt(0) === '_'; + if (!has && !isAllowed) { + warnNonPresent(target, key); + } + return has || !isAllowed + } + }; + + var getHandler = { + get: function get (target, key) { + if (typeof key === 'string' && !(key in target)) { + warnNonPresent(target, key); + } + return target[key] + } + }; + + initProxy = function initProxy (vm) { + if (hasProxy) { + // determine which proxy handler to use + var options = vm.$options; + var handlers = options.render && options.render._withStripped + ? getHandler + : hasHandler; + vm._renderProxy = new Proxy(vm, handlers); + } else { + vm._renderProxy = vm; + } + }; +} + +/* */ + +var seenObjects = new _Set(); + +/** + * Recursively traverse an object to evoke all converted + * getters, so that every nested property inside the object + * is collected as a "deep" dependency. + */ +function traverse (val) { + _traverse(val, seenObjects); + seenObjects.clear(); +} + +function _traverse (val, seen) { + var i, keys; + var isA = Array.isArray(val); + if ((!isA && !isObject(val)) || Object.isFrozen(val)) { + return + } + if (val.__ob__) { + var depId = val.__ob__.dep.id; + if (seen.has(depId)) { + return + } + seen.add(depId); + } + if (isA) { + i = val.length; + while (i--) { _traverse(val[i], seen); } + } else { + keys = Object.keys(val); + i = keys.length; + while (i--) { _traverse(val[keys[i]], seen); } + } +} + +/* */ + +var normalizeEvent = cached(function (name) { + var passive = name.charAt(0) === '&'; + name = passive ? name.slice(1) : name; + var once$$1 = name.charAt(0) === '~'; // Prefixed last, checked first + name = once$$1 ? name.slice(1) : name; + var capture = name.charAt(0) === '!'; + name = capture ? name.slice(1) : name; + return { + name: name, + once: once$$1, + capture: capture, + passive: passive + } +}); + +function createFnInvoker (fns) { + function invoker () { + var arguments$1 = arguments; + + var fns = invoker.fns; + if (Array.isArray(fns)) { + var cloned = fns.slice(); + for (var i = 0; i < cloned.length; i++) { + cloned[i].apply(null, arguments$1); + } + } else { + // return handler return value for single handlers + return fns.apply(null, arguments) + } + } + invoker.fns = fns; + return invoker +} + +function updateListeners ( + on, + oldOn, + add, + remove$$1, + vm +) { + var name, def, cur, old, event; + for (name in on) { + def = cur = on[name]; + old = oldOn[name]; + event = normalizeEvent(name); + /* istanbul ignore if */ + if (isUndef(cur)) { + "development" !== 'production' && warn( + "Invalid handler for event \"" + (event.name) + "\": got " + String(cur), + vm + ); + } else if (isUndef(old)) { + if (isUndef(cur.fns)) { + cur = on[name] = createFnInvoker(cur); + } + add(event.name, cur, event.once, event.capture, event.passive, event.params); + } else if (cur !== old) { + old.fns = cur; + on[name] = old; + } + } + for (name in oldOn) { + if (isUndef(on[name])) { + event = normalizeEvent(name); + remove$$1(event.name, oldOn[name], event.capture); + } + } +} + +/* */ + +function mergeVNodeHook (def, hookKey, hook) { + if (def instanceof VNode) { + def = def.data.hook || (def.data.hook = {}); + } + var invoker; + var oldHook = def[hookKey]; + + function wrappedHook () { + hook.apply(this, arguments); + // important: remove merged hook to ensure it's called only once + // and prevent memory leak + remove(invoker.fns, wrappedHook); + } + + if (isUndef(oldHook)) { + // no existing hook + invoker = createFnInvoker([wrappedHook]); + } else { + /* istanbul ignore if */ + if (isDef(oldHook.fns) && isTrue(oldHook.merged)) { + // already a merged invoker + invoker = oldHook; + invoker.fns.push(wrappedHook); + } else { + // existing plain hook + invoker = createFnInvoker([oldHook, wrappedHook]); + } + } + + invoker.merged = true; + def[hookKey] = invoker; +} + +/* */ + +function extractPropsFromVNodeData ( + data, + Ctor, + tag +) { + // we are only extracting raw values here. + // validation and default values are handled in the child + // component itself. + var propOptions = Ctor.options.props; + if (isUndef(propOptions)) { + return + } + var res = {}; + var attrs = data.attrs; + var props = data.props; + if (isDef(attrs) || isDef(props)) { + for (var key in propOptions) { + var altKey = hyphenate(key); + { + var keyInLowerCase = key.toLowerCase(); + if ( + key !== keyInLowerCase && + attrs && hasOwn(attrs, keyInLowerCase) + ) { + tip( + "Prop \"" + keyInLowerCase + "\" is passed to component " + + (formatComponentName(tag || Ctor)) + ", but the declared prop name is" + + " \"" + key + "\". " + + "Note that HTML attributes are case-insensitive and camelCased " + + "props need to use their kebab-case equivalents when using in-DOM " + + "templates. You should probably use \"" + altKey + "\" instead of \"" + key + "\"." + ); + } + } + checkProp(res, props, key, altKey, true) || + checkProp(res, attrs, key, altKey, false); + } + } + return res +} + +function checkProp ( + res, + hash, + key, + altKey, + preserve +) { + if (isDef(hash)) { + if (hasOwn(hash, key)) { + res[key] = hash[key]; + if (!preserve) { + delete hash[key]; + } + return true + } else if (hasOwn(hash, altKey)) { + res[key] = hash[altKey]; + if (!preserve) { + delete hash[altKey]; + } + return true + } + } + return false +} + +/* */ + +// The template compiler attempts to minimize the need for normalization by +// statically analyzing the template at compile time. +// +// For plain HTML markup, normalization can be completely skipped because the +// generated render function is guaranteed to return Array. There are +// two cases where extra normalization is needed: + +// 1. When the children contains components - because a functional component +// may return an Array instead of a single root. In this case, just a simple +// normalization is needed - if any child is an Array, we flatten the whole +// thing with Array.prototype.concat. It is guaranteed to be only 1-level deep +// because functional components already normalize their own children. +function simpleNormalizeChildren (children) { + for (var i = 0; i < children.length; i++) { + if (Array.isArray(children[i])) { + return Array.prototype.concat.apply([], children) + } + } + return children +} + +// 2. When the children contains constructs that always generated nested Arrays, +// e.g.