diff --git a/REQUIREMENTS.txt b/REQUIREMENTS.txt index 783d71a7..f02fdf0e 100644 --- a/REQUIREMENTS.txt +++ b/REQUIREMENTS.txt @@ -13,8 +13,12 @@ Markdown==2.3.1 #PIL==1.1.7 Pillow Pygments==2.7.4 -git+https://github.com/Xpirix/whoosh.git + +# Updates for Django 2 & Python 3.7 +git+https://github.com/Xpirix/whoosh.git@a306553 pickle5==0.0.12 +django-haystack==3.2.1 + argparse==1.2.1 #cab==0.2.0 # Not used anymore..- #distribute==0.7.3 @@ -25,7 +29,6 @@ django-debug-toolbar==3.2.4 django-endless-pagination==2.0 django-extensions==1.2.0 django-generic-aggregation==0.3.2 -django-haystack==2.4.0 #django-olwidget==0.61.0 unmaintained, use this fork git+https://github.com/Christophe31/olwidget.git django-pagination==1.0.7 @@ -37,7 +40,7 @@ git+https://github.com/gelo-zhukov/django-ratings.git django-simple-ratings==0.3.2 # SIMPLEMENU git+https://github.com/elpaso/django-simplemenu.git -django-taggit==0.14.0 +django-taggit==2.0.0 django-taggit-autosuggest==0.2.7 django-taggit-templatetags==0.4.6dev0 django-templatetag-sugar==0.1 @@ -60,5 +63,8 @@ django-bootstrap-pagination==1.7.1 django-sortable-listview==0.43 django-user-map djangorestframework==3.12.2 +pyjwt==1.7.1 +djangorestframework-simplejwt==4.4 django-rest-auth==0.9.5 drf-yasg +django-matomo==0.1.6 \ No newline at end of file diff --git a/dockerize/docker-compose.yml b/dockerize/docker-compose.yml index 72c1dc14..102bb6cb 100644 --- a/dockerize/docker-compose.yml +++ b/dockerize/docker-compose.yml @@ -1,4 +1,4 @@ -version: '3' +version: '3.8' volumes: backups-data: static-data: diff --git a/dockerize/docker/Dockerfile b/dockerize/docker/Dockerfile index f7f77638..9e43d497 100644 --- a/dockerize/docker/Dockerfile +++ b/dockerize/docker/Dockerfile @@ -16,7 +16,8 @@ ADD dockerize/docker/uwsgi.conf /uwsgi.conf ADD qgis-app /home/web/django_project ADD dockerize/docker/REQUIREMENTS.txt /REQUIREMENTS.txt RUN pip install --upgrade pip && pip install -r /REQUIREMENTS.txt -RUN pip install uwsgi +RUN pip install uwsgi freezegun==1.3.1 + # Open port 8080 as we will be running our uwsgi socket on that EXPOSE 8080 diff --git a/dockerize/docker/REQUIREMENTS.txt b/dockerize/docker/REQUIREMENTS.txt index 85e59976..5fafb8b6 100644 --- a/dockerize/docker/REQUIREMENTS.txt +++ b/dockerize/docker/REQUIREMENTS.txt @@ -1,7 +1,7 @@ django==3.2.11 django-auth-ldap python-ldap -django-taggit +django-taggit==2.0.0 django-tinymce==3.4.0 psycopg2 # Updates for Django 2 @@ -22,12 +22,13 @@ django-bootstrap-pagination django-sortable-listview sorl-thumbnail django-extensions + django-debug-toolbar==3.2.4 -# Updates for Django 3 & Python 3.7 -git+https://github.com/Xpirix/whoosh.git +# Updates for Django 2 & Python 3.7 +git+https://github.com/Xpirix/whoosh.git@a306553 pickle5==0.0.12 -django-haystack +django-haystack==3.2.1 # Feedjack==0.9.18 # So use George's fork rather @@ -50,6 +51,9 @@ requests==2.23.0 markdown==3.2.1 djangorestframework==3.11.2 +pyjwt==1.7.1 +djangorestframework-simplejwt==4.4 + sorl-thumbnail-serializer-field==0.2.1 django-rest-auth==0.9.5 drf-yasg==1.17.1 @@ -57,3 +61,4 @@ django-rest-multiple-models==2.1.3 django-preferences==1.0.0 PyWavefront==1.3.3 +django-matomo==0.1.6 diff --git a/qgis-app/REQUIREMENTS_plugins.txt b/qgis-app/REQUIREMENTS_plugins.txt index ab719d94..40e69142 100644 --- a/qgis-app/REQUIREMENTS_plugins.txt +++ b/qgis-app/REQUIREMENTS_plugins.txt @@ -1,7 +1,7 @@ django==3.2.11 django-auth-ldap python-ldap -django-taggit +django-taggit==2.0.0 django-tinymce==3.4.0 psycopg2 # Updates for Django 2 @@ -24,7 +24,7 @@ sorl-thumbnail django-extensions django-debug-toolbar==3.2.4 -# Updates for Django 3 & Python 3.7 -git+https://github.com/Xpirix/whoosh.git +# Updates for Django 2 & Python 3.7 +git+https://github.com/Xpirix/whoosh.git@a306553 pickle5==0.0.12 -django-haystack +django-haystack==3.2.1 diff --git a/qgis-app/base/views/processing_view.py b/qgis-app/base/views/processing_view.py index c0fcbde3..bddb4fb5 100644 --- a/qgis-app/base/views/processing_view.py +++ b/qgis-app/base/views/processing_view.py @@ -28,6 +28,7 @@ View, ) from django.views.generic.base import ContextMixin +from django.utils.encoding import escape_uri_path GROUP_NAME = "Style Managers" @@ -306,6 +307,7 @@ def get_context_data(self, **kwargs): context["reviewer"] = reviewer if user.is_staff or is_resources_manager(user): context["form"] = ResourceBaseReviewForm(resource_name=self.resource_name) + context["is_style_manager"] = is_resources_manager(user) if self.is_3d_model: context["url_viewer"] = "%s_viewer" % self.resource_name_url_base return context @@ -484,9 +486,8 @@ def get(self, request, *args, **kwargs): response = HttpResponse( zipfile.getvalue(), content_type="application/x-zip-compressed" ) - response["Content-Disposition"] = "attachment; filename=%s.zip" % ( - slugify(object.name, allow_unicode=True) - ) + zip_name = slugify(object.name, allow_unicode=True) + response["Content-Disposition"] = f"attachment; filename*=utf-8''{escape_uri_path(zip_name)}.zip" return response diff --git a/qgis-app/fixtures/auth.json b/qgis-app/fixtures/auth.json index 16588237..24af50b9 100644 --- a/qgis-app/fixtures/auth.json +++ b/qgis-app/fixtures/auth.json @@ -12,7 +12,7 @@ "last_login": "2010-11-24 07:56:12", "groups": [], "user_permissions": [], - "password": "sha1$d6c11$4f3f04e104dc8bbe7950234f0cd8406a65df0bdf", + "password": "pbkdf2_sha256$150000$foQAQGi54z25$AQelhq+oBE3TOBJRT9F9UsEP5K1PSWQnQeozkmyc3fs=", "email": "", "date_joined": "2010-11-24 07:56:12" } @@ -30,7 +30,7 @@ "last_login": "2010-11-25 07:35:07", "groups": [], "user_permissions": [], - "password": "sha1$9ba9f$6088ef8abc2243a55e777e937159c8f2fd4920bb", + "password": "pbkdf2_sha256$150000$BBba4NloaWZO$XN4lzpxcvFSrLl1QqiwQz/0ZLiEH/JTgEJE/uRRXWto=", "email": "admin@admin.it", "date_joined": "2009-10-06 18:04:20" } @@ -48,7 +48,7 @@ "last_login": "2010-11-25 07:35:20", "groups": [], "user_permissions": [], - "password": "sha1$cb97a$221727796b3f551e342dca9d00112f072e399182", + "password": "pbkdf2_sha256$150000$GJga5YEinaWz$zJAjCXccvWHNPGmoZEjvBNgm1DGkjZGA3BmTVaNAxP4=", "email": "", "date_joined": "2010-11-25 07:35:20" } diff --git a/qgis-app/plugins/decorators.py b/qgis-app/plugins/decorators.py new file mode 100644 index 00000000..69509b70 --- /dev/null +++ b/qgis-app/plugins/decorators.py @@ -0,0 +1,39 @@ +from functools import wraps +from django.http import HttpResponseForbidden +from rest_framework_simplejwt.authentication import JWTAuthentication +from plugins.models import Plugin, PluginOutstandingToken +from rest_framework_simplejwt.exceptions import InvalidToken, TokenError +from rest_framework_simplejwt.token_blacklist.models import BlacklistedToken, OutstandingToken +import datetime + +def has_valid_token(function): + @wraps(function) + def wrap(request, *args, **kwargs): + auth_token = request.META.get("HTTP_AUTHORIZATION") + package_name = kwargs.get('package_name') + if not str(auth_token).startswith('Bearer'): + raise InvalidToken("Invalid token") + + # Validate JWT token + authentication = JWTAuthentication() + try: + validated_token = authentication.get_validated_token(auth_token[7:]) + plugin_id = validated_token.payload.get('plugin_id') + jti = validated_token.payload.get('refresh_jti') + token_id = OutstandingToken.objects.get(jti=jti).pk + is_blacklisted = BlacklistedToken.objects.filter(token_id=token_id).exists() + if not plugin_id or is_blacklisted: + raise InvalidToken("Invalid token") + + plugin = Plugin.objects.get(pk=plugin_id) + if not plugin or plugin.package_name != package_name: + raise InvalidToken("Invalid token") + plugin_token = PluginOutstandingToken.objects.get(token__pk=token_id, plugin=plugin) + plugin_token.last_used_on = datetime.datetime.now() + plugin_token.save() + request.plugin_token = plugin_token + return function(request, *args, **kwargs) + except (InvalidToken, TokenError) as e: + return HttpResponseForbidden(str(e)) + + return wrap diff --git a/qgis-app/plugins/forms.py b/qgis-app/plugins/forms.py index 600986de..40c8ab5a 100644 --- a/qgis-app/plugins/forms.py +++ b/qgis-app/plugins/forms.py @@ -6,7 +6,7 @@ from django.forms import CharField, ModelForm, ValidationError from django.utils.safestring import mark_safe from django.utils.translation import ugettext_lazy as _ -from plugins.models import Plugin, PluginVersion, PluginVersionFeedback +from plugins.models import Plugin, PluginOutstandingToken, PluginVersion, PluginVersionFeedback from plugins.validator import validator from taggit.forms import * @@ -43,10 +43,25 @@ class Meta: "tracker", "repository", "owners", + "maintainer", + "display_created_by", "tags", "server", ) + def __init__(self, *args, **kwargs): + super(PluginForm, self).__init__(*args, **kwargs) + self.fields['owners'].label = "Collaborators" + + choices = ( + (self.instance.created_by.pk, self.instance.created_by.username + " (Plugin creator)"), + ) + for owner in self.instance.owners.exclude(pk=self.instance.created_by.pk): + choices += ((owner.pk, owner.username + " (Collaborator)"),) + + self.fields['maintainer'].choices = choices + self.fields['maintainer'].label = "Maintainer" + def clean(self): """ Check author @@ -244,3 +259,14 @@ def clean(self): self.cleaned_data['tasks'] = tasks return self.cleaned_data + +class PluginTokenForm(ModelForm): + """ + Form for token description editing + """ + + class Meta: + model = PluginOutstandingToken + fields = ( + "description", + ) \ No newline at end of file diff --git a/qgis-app/plugins/management/commands/cleanmediafolder.py b/qgis-app/plugins/management/commands/cleanmediafolder.py new file mode 100644 index 00000000..da18bda9 --- /dev/null +++ b/qgis-app/plugins/management/commands/cleanmediafolder.py @@ -0,0 +1,38 @@ +import os +from shutil import rmtree +from django.conf import settings +from django.core.management import call_command +from django.core.management.base import BaseCommand + +class Command(BaseCommand): + help = 'Run collectstatic and delete folders from media that exist in static' + + def handle(self, *args, **options): + confirm = input("Do you want to run 'collectstatic' first? (yes/no): ") + if confirm.lower() == 'yes': + # Run collectstatic command + call_command('collectstatic', interactive=False) + + # Get the paths of static and media folders + static_root = settings.STATIC_ROOT + media_root = settings.MEDIA_ROOT + + # Iterate over each directory in the static folder + for static_dir in os.listdir(static_root): + static_path = os.path.join(static_root, static_dir) + + # Check if the path is a directory and exists in the media folder + if os.path.isdir(static_path) and os.path.exists(os.path.join(media_root, static_dir)): + confirm = input(f"Are you sure you want to delete '{static_dir}' from the media folder? (yes/no): ") + + if confirm.lower() == 'yes': + try: + # Delete the corresponding folder in the media folder + rmtree(os.path.join(media_root, static_dir)) + self.stdout.write(self.style.SUCCESS(f'Deleted {static_dir} from media folder.')) + except Exception as e: + self.stderr.write(self.style.ERROR(f'Error deleting {static_dir}: {str(e)}')) + else: + self.stdout.write(self.style.WARNING(f'Skipped deletion of {static_dir}.')) + + self.stdout.write(self.style.SUCCESS('The media folder has been cleaned.')) diff --git a/qgis-app/plugins/management/commands/organize_old_package.py b/qgis-app/plugins/management/commands/organize_old_package.py new file mode 100644 index 00000000..991ed958 --- /dev/null +++ b/qgis-app/plugins/management/commands/organize_old_package.py @@ -0,0 +1,40 @@ +# myapp/management/commands/organize_packages.py +import os +import shutil +from django.core.management.base import BaseCommand +from django.conf import settings +from plugins.models import PluginVersion + +PLUGINS_STORAGE_PATH = getattr(settings, "PLUGINS_STORAGE_PATH", "packages") +class Command(BaseCommand): + help = 'Organize packages created before 2014 into folders by year' + + def handle(self, *args, **options): + packages_dir = os.path.join(settings.MEDIA_ROOT, PLUGINS_STORAGE_PATH) + + # Some of the packages created on 2014 also need to be organized + versions = PluginVersion.objects.filter(created_on__lt='2014-12-31').exclude(package__icontains='2014/') + self.stdout.write(self.style.NOTICE(f'{versions.count()} packages will be organized.')) + + for version in versions: + year_folder = os.path.join(packages_dir, str(version.created_on.year)) + + # Create the year folder if it doesn't exist + os.makedirs(year_folder, exist_ok=True) + + # Copy the package file to the year folder + old_path = version.package.path + if os.path.exists(old_path): + new_path = os.path.join(year_folder, os.path.basename(old_path)) + if not os.path.exists(new_path): + shutil.copy(old_path, year_folder) + + # Update the model with the new package path + version.package.name = os.path.relpath(new_path, settings.MEDIA_ROOT) + version.save() + else: + self.stdout.write(self.style.WARNING(f'Plugin version id {version.pk} ignored: {new_path} already exists.')) + else: + self.stdout.write(self.style.WARNING(f'Plugin version id {version.pk} ignored: {old_path} is not found.')) + + self.stdout.write(self.style.SUCCESS('Packages organized successfully')) diff --git a/qgis-app/plugins/middleware.py b/qgis-app/plugins/middleware.py index 059a7848..17adb828 100644 --- a/qgis-app/plugins/middleware.py +++ b/qgis-app/plugins/middleware.py @@ -2,8 +2,7 @@ # Author: A. Pasotti from django.contrib import auth -from django.contrib.auth.models import User - +from rest_framework_simplejwt.authentication import JWTAuthentication def HttpAuthMiddleware(get_response): """ @@ -12,7 +11,7 @@ def HttpAuthMiddleware(get_response): def middleware(request): auth_basic = request.META.get("HTTP_AUTHORIZATION") - if auth_basic: + if auth_basic and not str(auth_basic).startswith('Bearer'): import base64 username, dummy, password = base64.decodestring( @@ -27,7 +26,6 @@ def middleware(request): # by logging the user in. request.user = user auth.login(request, user) - response = get_response(request) # Code to be executed for each request/response after diff --git a/qgis-app/plugins/migrations/0004_merge_20231122_0223.py b/qgis-app/plugins/migrations/0004_merge_20231122_0223.py new file mode 100644 index 00000000..8251bc87 --- /dev/null +++ b/qgis-app/plugins/migrations/0004_merge_20231122_0223.py @@ -0,0 +1,14 @@ +# Generated by Django 2.2.25 on 2023-11-30 05:13 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('plugins', '0002_plugins_feedback'), + ('plugins', '0003_plugin_allow_update_name'), + ] + + operations = [ + ] diff --git a/qgis-app/plugins/migrations/0005_plugin_maintainer.py b/qgis-app/plugins/migrations/0005_plugin_maintainer.py new file mode 100644 index 00000000..dfeffa0b --- /dev/null +++ b/qgis-app/plugins/migrations/0005_plugin_maintainer.py @@ -0,0 +1,29 @@ +# Generated by Django 2.2.25 on 2023-11-29 22:45 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + +def populate_maintainer(apps, schema_editor): + Plugin = apps.get_model('plugins', 'Plugin') + + # Set the maintainer as the plugin creator by default + for obj in Plugin.objects.all(): + obj.maintainer = obj.created_by + obj.save() + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('plugins', '0004_merge_20231122_0223'), + ] + + operations = [ + migrations.AddField( + model_name='plugin', + name='maintainer', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='plugins_maintainer', to=settings.AUTH_USER_MODEL, verbose_name='Maintainer'), + ), + migrations.RunPython(populate_maintainer), + ] diff --git a/qgis-app/plugins/migrations/0005_pluginoutstandingtoken.py b/qgis-app/plugins/migrations/0005_pluginoutstandingtoken.py new file mode 100644 index 00000000..4e406433 --- /dev/null +++ b/qgis-app/plugins/migrations/0005_pluginoutstandingtoken.py @@ -0,0 +1,24 @@ +# Generated by Django 2.2.25 on 2023-12-11 23:36 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('token_blacklist', '0007_auto_20171017_2214'), + ('plugins', '0004_merge_20231122_0223'), + ] + + operations = [ + migrations.CreateModel( + name='PluginOutstandingToken', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('is_blacklisted', models.BooleanField(default=False)), + ('plugin', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='plugins.Plugin')), + ('token', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='token_blacklist.OutstandingToken')), + ], + ), + ] diff --git a/qgis-app/plugins/migrations/0006_auto_20231218_0225.py b/qgis-app/plugins/migrations/0006_auto_20231218_0225.py new file mode 100644 index 00000000..60d0af5f --- /dev/null +++ b/qgis-app/plugins/migrations/0006_auto_20231218_0225.py @@ -0,0 +1,28 @@ +# Generated by Django 2.2.25 on 2023-12-18 02:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('plugins', '0005_pluginoutstandingtoken'), + ] + + operations = [ + migrations.AddField( + model_name='pluginoutstandingtoken', + name='description', + field=models.CharField(blank=True, help_text="Describe this token so that it's easier to remember where you're using it.", max_length=512, null=True, verbose_name='Description'), + ), + migrations.AddField( + model_name='pluginoutstandingtoken', + name='is_newly_created', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='pluginoutstandingtoken', + name='last_used_on', + field=models.DateTimeField(blank=True, null=True, verbose_name='Last used on'), + ), + ] diff --git a/qgis-app/plugins/migrations/0006_plugin_display_created_by.py b/qgis-app/plugins/migrations/0006_plugin_display_created_by.py new file mode 100644 index 00000000..85af8484 --- /dev/null +++ b/qgis-app/plugins/migrations/0006_plugin_display_created_by.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.25 on 2023-11-29 23:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('plugins', '0005_plugin_maintainer'), + ] + + operations = [ + migrations.AddField( + model_name='plugin', + name='display_created_by', + field=models.BooleanField(default=False, verbose_name='Display "Created by" in plugin details'), + ), + ] diff --git a/qgis-app/plugins/migrations/0007_auto_20240109_0428.py b/qgis-app/plugins/migrations/0007_auto_20240109_0428.py new file mode 100644 index 00000000..6d6af2da --- /dev/null +++ b/qgis-app/plugins/migrations/0007_auto_20240109_0428.py @@ -0,0 +1,30 @@ +# Generated by Django 2.2.25 on 2024-01-09 04:28 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('plugins', '0006_auto_20231218_0225'), + ] + + operations = [ + migrations.AddField( + model_name='pluginversion', + name='is_from_token', + field=models.BooleanField(default=False, verbose_name='Is uploaded using token'), + ), + migrations.AddField( + model_name='pluginversion', + name='token', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='plugins.PluginOutstandingToken', verbose_name='Token used'), + ), + migrations.AlterField( + model_name='pluginversion', + name='created_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Created by'), + ), + ] diff --git a/qgis-app/plugins/models.py b/qgis-app/plugins/models.py index eeba37d2..9f9cc209 100644 --- a/qgis-app/plugins/models.py +++ b/qgis-app/plugins/models.py @@ -12,6 +12,7 @@ from django.utils import timezone from djangoratings.fields import AnonymousRatingField from taggit_autosuggest.managers import TaggableManager +from rest_framework_simplejwt.token_blacklist.models import OutstandingToken PLUGINS_STORAGE_PATH = getattr(settings, "PLUGINS_STORAGE_PATH", "packages/%Y") PLUGINS_FRESH_DAYS = getattr(settings, "PLUGINS_FRESH_DAYS", 30) @@ -322,6 +323,22 @@ class Plugin(models.Model): related_name="plugins_created_by", on_delete=models.CASCADE, ) + + # maintainer + maintainer = models.ForeignKey( + User, + verbose_name=_("Maintainer"), + related_name="plugins_maintainer", + on_delete=models.CASCADE, + blank=True, + null=True + ) + + display_created_by = models.BooleanField( + _('Display "Created by" in plugin details'), + default=False + ) + author = models.CharField( _("Author"), help_text=_( @@ -529,6 +546,7 @@ def save(self, keep_date=False, *args, **kwargs): """ Soft triggers: * updates modified_on if keep_date is not set + * set maintainer to the plugin creator when not specified """ if self.pk and not keep_date: import logging @@ -537,6 +555,8 @@ def save(self, keep_date=False, *args, **kwargs): self.modified_on = datetime.datetime.now() if not self.pk: self.modified_on = datetime.datetime.now() + if not self.maintainer: + self.maintainer = self.created_by super(Plugin, self).save(*args, **kwargs) @@ -652,6 +672,33 @@ def from_db_value(self, value, expression, connection): return self.to_python(value) +class PluginOutstandingToken(models.Model): + """ + Plugin outstanding token + """ + plugin = models.ForeignKey( + Plugin, + on_delete=models.CASCADE + ) + token = models.ForeignKey( + OutstandingToken, + on_delete=models.CASCADE + ) + is_blacklisted = models.BooleanField(default=False) + is_newly_created = models.BooleanField(default=False) + description = models.CharField( + verbose_name=_("Description"), + help_text=_("Describe this token so that it's easier to remember where you're using it."), + max_length=512, + blank=True, + null=True, + ) + last_used_on = models.DateTimeField( + verbose_name=_("Last used on"), + blank=True, + null=True + ) + class PluginVersion(models.Model): """ Plugin versions @@ -667,7 +714,7 @@ class PluginVersion(models.Model): downloads = models.IntegerField(_("Downloads"), default=0, editable=False) # owners created_by = models.ForeignKey( - User, verbose_name=_("Created by"), on_delete=models.CASCADE + User, verbose_name=_("Created by"), on_delete=models.CASCADE, null=True, blank=True ) # version info, the first should be read from plugin min_qg_version = QGVersionZeroForcedField( @@ -703,6 +750,14 @@ class PluginVersion(models.Model): blank=False, null=True, ) + is_from_token = models.BooleanField( + _("Is uploaded using token"), + default=False + ) + # Link to the token if upload is using token + token = models.ForeignKey( + PluginOutstandingToken, verbose_name=_("Token used"), on_delete=models.CASCADE, null=True, blank=True + ) # Managers, used in xml output objects = models.Manager() diff --git a/qgis-app/plugins/templates/plugins/plugin_detail.html b/qgis-app/plugins/templates/plugins/plugin_detail.html index 951862d0..82ac70c9 100644 --- a/qgis-app/plugins/templates/plugins/plugin_detail.html +++ b/qgis-app/plugins/templates/plugins/plugin_detail.html @@ -1,5 +1,6 @@ {% extends 'plugins/plugin_base.html' %}{% load i18n static thumbnail %} {% load local_timezone %} +{% load plugin_utils %} {% block extrajs %} {{ block.super }} @@ -38,11 +39,97 @@ }); }); }); + + // Handle URL anchor for tabs + $(window).load(function() { + + // Store the current scroll position + var scrollPosition = 0; + + // Handle tab clicks + $('.nav-tabs a').on('click', function (e) { + e.preventDefault(); + + // Store the current scroll position + scrollPosition = $(window).scrollTop(); + + // Update the URL without triggering a reload + window.location.hash = this.hash; + + // Show the tab + $(this).tab('show'); + }); + + // Restore the scroll position on tab change + $('.nav-tabs a').on('shown.bs.tab', function (e) { + $(window).scrollTop(scrollPosition); + }); + + // Activate the tab based on the URL fragment + var hash = window.location.hash; + if (hash) { + $('.nav-tabs a[href="' + hash + '"]').tab('show'); + } + + // Scroll to the top when the page loads + setTimeout(() => { + $(window).scrollTop(0); + }) + + }); + + function copyToClipBoard(plugin_id) { + navigator.clipboard.writeText(plugin_id); + + var tooltip = document.getElementById("copyTooltip"); + tooltip.innerHTML = "Plugin ID copied!"; + } {% endblock %} {% block extracss %} {{ block.super }} + {% endblock %} {% block content %}
@@ -58,11 +145,26 @@

{% endif %}

{{ object.name }} {% if object.icon and object.icon.file %} - {% thumbnail object.icon "128x128" upscale=False format="PNG" as im %} - {% trans - {% endthumbnail %} + {% with image_extension=object.icon.name|file_extension %} + {% if image_extension == 'svg' %} + {% trans + {% else %} + {% thumbnail object.icon "128x128" upscale=False format="PNG" as im %} + {% trans + {% endthumbnail %} + {% endif %} + {% endwith %} {% endif %}

+
+ {% trans "Plugin ID:" %} {{ object.pk }} +
+ +
+
@@ -126,9 +228,16 @@

{{ object.name }}
{% trans "Author's email"%}
{{ object.email }}
{% endif %} + {% if object.display_created_by %} +
{% trans "Created by"%}
+
+ {{ object.created_by }} +
+ + {% endif %}
{% trans "Maintainer"%}
- {{ object.created_by }} + {{ object.maintainer }}
{% if object.owners.count %}
{% trans "Collaborators"%}
@@ -167,6 +276,18 @@

{{ object.name }}
{% trans "Latest experimental version"%}:
{{ object.experimental.version }}
{% endif %} + {% if object.pk %} +
{% trans "Plugin ID"%}
+
+ {{ object.pk }} +
+ +
+
+ {% endif %}

@@ -197,7 +318,11 @@

{{ object.name }} {{ version.min_qg_version }} {{ version.max_qg_version }} {{ version.downloads }} + {% if version.is_from_token %} + Token {{ version.token.description|default:"" }} + {% else %} {{ version.created_by }} + {% endif %} {{ version.created_on|local_timezone }} {% if user.is_staff or user in version.plugin.approvers or user in version.plugin.editors %}
{% csrf_token %} {% if user.is_staff or user in version.plugin.approvers %} @@ -225,6 +350,7 @@

{{ object.name }}
{% trans "Edit" %} {% trans "Add version" %} + {% trans "Tokens" %} {% if user.is_staff %} {% if object.featured %} {% else %} diff --git a/qgis-app/plugins/templates/plugins/plugin_form.html b/qgis-app/plugins/templates/plugins/plugin_form.html index 4555a745..86edcd27 100644 --- a/qgis-app/plugins/templates/plugins/plugin_form.html +++ b/qgis-app/plugins/templates/plugins/plugin_form.html @@ -66,7 +66,7 @@

{{ form_title }} {{ plugin }}

let element = document.getElementById('id_owners'); if(element) { $('#id_owners').chosen({ - placeholder_text_multiple: "Select Some Owners", + placeholder_text_multiple: "Select Some Collaborators", no_results_text: "Oops, nothing found!" }); clearInterval(checkElement); diff --git a/qgis-app/plugins/templates/plugins/plugin_list.html b/qgis-app/plugins/templates/plugins/plugin_list.html index 513a8d09..788053d6 100644 --- a/qgis-app/plugins/templates/plugins/plugin_list.html +++ b/qgis-app/plugins/templates/plugins/plugin_list.html @@ -1,4 +1,6 @@ {% extends 'plugins/plugin_base.html' %}{% load i18n bootstrap_pagination humanize static sort_anchor range_filter thumbnail %} +{% load local_timezone %} +{% load plugin_utils %} {% block extrajs %} +{% endblock %} \ No newline at end of file diff --git a/qgis-app/plugins/templates/plugins/plugin_token_form.html b/qgis-app/plugins/templates/plugins/plugin_token_form.html new file mode 100644 index 00000000..c3a4908a --- /dev/null +++ b/qgis-app/plugins/templates/plugins/plugin_token_form.html @@ -0,0 +1,26 @@ +{% extends 'plugins/plugin_base.html' %}{% load i18n %} +{% load local_timezone %} +{% block content %} +

{% trans "Edit token description " %} {{ token.jti }}

+ +{% if form.errors %} +
+ +

{% trans "The form contains errors and cannot be submitted, please check the fields highlighted in red." %}

+
+{% endif %} +{% if form.non_field_errors %} +
+ + {% for error in form.non_field_errors %} +

{{ error }}

+ {% endfor %} +
+{% endif %} +{% csrf_token %} + {% include "plugins/form_snippet.html" %} +
+ +
+ +{% endblock %} \ No newline at end of file diff --git a/qgis-app/plugins/templates/plugins/plugin_token_invalid_or_expired.html b/qgis-app/plugins/templates/plugins/plugin_token_invalid_or_expired.html new file mode 100644 index 00000000..3f6b286a --- /dev/null +++ b/qgis-app/plugins/templates/plugins/plugin_token_invalid_or_expired.html @@ -0,0 +1,4 @@ +{% extends 'plugins/plugin_base.html' %}{% load i18n %} +{% block content %} +
{% trans "Token is invalid or expired." %}
+{% endblock %} diff --git a/qgis-app/plugins/templates/plugins/plugin_token_list.html b/qgis-app/plugins/templates/plugins/plugin_token_list.html new file mode 100644 index 00000000..530042b4 --- /dev/null +++ b/qgis-app/plugins/templates/plugins/plugin_token_list.html @@ -0,0 +1,77 @@ +{% extends 'plugins/plugin_base.html' %}{% load i18n %} +{% load local_timezone %} +{% block content %} +

{% trans "Tokens for" %} {{ plugin.name }}

+
{% csrf_token %} +
+

+ +

+
+
+{% if object_list.count %} +
+ + + + + + + + + + + + + {% for plugin_token in object_list %} + + + + + + + + + {% endfor %} + +
{% trans "User" %}{% trans "Description" %}{% trans "Jti" %}{% trans "Created at" %}{% trans "Last used at" %}{% trans "Manage" %}
{{ plugin_token.token.user }}{{ plugin_token.description|default:"-" }} + + {{ plugin_token.token.jti }} + + {{ plugin_token.token.created_at|local_timezone }}{{ plugin_token.last_used_on|default:"-"|local_timezone }} +   + + +
+
+{% else %} +
+ + {% trans "This list is empty!" %} +
+{% endif %} + +{% endblock %} + +{% block extracss %} +{{ block.super }} + +{% endblock %} \ No newline at end of file diff --git a/qgis-app/plugins/templates/plugins/plugin_token_permission_deny.html b/qgis-app/plugins/templates/plugins/plugin_token_permission_deny.html new file mode 100644 index 00000000..7850ee82 --- /dev/null +++ b/qgis-app/plugins/templates/plugins/plugin_token_permission_deny.html @@ -0,0 +1,4 @@ +{% extends 'plugins/plugin_base.html' %}{% load i18n %} +{% block content %} +
{% trans "You cannot see tokens for this plugin." %}
+{% endblock %} diff --git a/qgis-app/plugins/templates/plugins/plugin_upload.html b/qgis-app/plugins/templates/plugins/plugin_upload.html index 495bc00d..e2b0f67f 100644 --- a/qgis-app/plugins/templates/plugins/plugin_upload.html +++ b/qgis-app/plugins/templates/plugins/plugin_upload.html @@ -13,6 +13,30 @@

{% trans "Upload a plugin" %}

{% endif %}
{% csrf_token %} {% include "plugins/form_snippet.html" %} +
+ + {% blocktrans %} + Please note that by uploading a plugin to the official QGIS plugin repository, + you agree that we will use your email to contact you. We will only contact + you for matters relating to the management of plugins and will not make + your email available to third parties for marketing purposes. + {% endblocktrans %} +
+
+ + {% blocktrans %} + By uploading your plugin to the QGIS plugin repository, + you agree to keep your email address current and to be + responsive to any correspondence we may send you + regarding the management of your plugin. We require + this in order to be able to provide our users assurance + that the plugins we host are well maintained and will be + fixed should serious issues arise during their use. + We reserve the right to hide or remove plugins in cases + where the plugin author is not contactable and there + are issues reported about a plugin. + {% endblocktrans %} +
diff --git a/qgis-app/plugins/templates/plugins/version_detail.html b/qgis-app/plugins/templates/plugins/version_detail.html index 21100e7c..b9cd4874 100644 --- a/qgis-app/plugins/templates/plugins/version_detail.html +++ b/qgis-app/plugins/templates/plugins/version_detail.html @@ -1,9 +1,10 @@ {% extends 'plugins/plugin_base.html' %}{% load i18n %} +{% load local_timezone %} {% block content %}

{% trans "Version" %}: {{ version }}

- {% if not version.created_by.is_active %} + {% if not version.created_by.is_active and not version.is_from_token %}
{% trans "The plugin author has been blocked." %}
@@ -20,8 +21,14 @@

{% trans "Version" %}: {{ version }}

{% if version.changelog %}
{% trans "Changelog" %}
{{ version.changelog|wordwrap:80 }}
{% endif %}
{% trans "Approved" %}
{{ version.approved|yesno }}
-
{% trans "Author" %}
{{ version.created_by }}
-
{% trans "Uploaded" %}
{{ version.created_on }}
+
{% trans "Author" %}
+ {% if version.is_from_token %} + Token {{ version.token.description|default:"" }} + {% else %} + {{ version.created_by }} + {% endif %} +
+
{% trans "Uploaded" %}
{{ version.created_on|local_timezone }}
{% trans "Minimum QGIS version" %}
{{ version.min_qg_version }}
{% trans "Maximum QGIS version" %}
{{ version.max_qg_version }}
{% trans "External dependencies (PIP install string)" %}
{{ version.external_deps }}
diff --git a/qgis-app/plugins/templatetags/local_timezone.py b/qgis-app/plugins/templatetags/local_timezone.py index 4658b2de..8463a9be 100644 --- a/qgis-app/plugins/templatetags/local_timezone.py +++ b/qgis-app/plugins/templatetags/local_timezone.py @@ -6,10 +6,15 @@ @register.filter(name="local_timezone", is_safe=True) -def local_timezone(date): +def local_timezone(date, args="LONG"): try: utcdate = date.astimezone(pytz.utc).isoformat() - result = '%s' % (utcdate,) + if args and str(args) == "SHORT": + result = '%s' % (utcdate,) + elif args and str(args) == "SHORT_NATURAL_DAY": + result = '%s' % (utcdate,) + else: + result = '%s' % (utcdate,) except AttributeError: result = date return mark_safe(result) diff --git a/qgis-app/plugins/templatetags/plugin_utils.py b/qgis-app/plugins/templatetags/plugin_utils.py index 9eb8fbec..887eee83 100755 --- a/qgis-app/plugins/templatetags/plugin_utils.py +++ b/qgis-app/plugins/templatetags/plugin_utils.py @@ -24,3 +24,7 @@ def plugin_title(context): if "page_title" in context: title = context["page_title"] return title + +@register.filter +def file_extension(value): + return value.split('.')[-1].lower() \ No newline at end of file diff --git a/qgis-app/plugins/tests/test_change_maintainer.py b/qgis-app/plugins/tests/test_change_maintainer.py new file mode 100644 index 00000000..a9357f6f --- /dev/null +++ b/qgis-app/plugins/tests/test_change_maintainer.py @@ -0,0 +1,101 @@ +import os +from unittest.mock import patch + +from django.urls import reverse +from django.test import Client, TestCase, override_settings +from django.contrib.auth.models import User +from django.core.files.uploadedfile import SimpleUploadedFile +from plugins.models import Plugin, PluginVersion +from plugins.forms import PluginForm + +def do_nothing(*args, **kwargs): + pass + +TESTFILE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "testfiles")) + +class PluginRenameTestCase(TestCase): + fixtures = [ + "fixtures/styles.json", + "fixtures/auth.json", + "fixtures/simplemenu.json", + ] + + @override_settings(MEDIA_ROOT="api/tests") + def setUp(self): + self.client = Client() + self.url_upload = reverse('plugin_upload') + + # Create a test user + self.user = User.objects.create_user( + username='testuser', + password='testpassword', + email='test@example.com' + ) + + # Log in the test user + self.client.login(username='testuser', password='testpassword') + + # Upload a plugin for renaming test. + # This process is already tested in test_plugin_upload + valid_plugin = os.path.join(TESTFILE_DIR, "valid_plugin.zip_") + with open(valid_plugin, "rb") as file: + uploaded_file = SimpleUploadedFile( + "valid_plugin.zip_", file.read(), + content_type="application/zip") + + self.client.post(self.url_upload, { + 'package': uploaded_file, + }) + + self.plugin = Plugin.objects.get(name='Test Plugin') + self.plugin.save() + + @patch("plugins.tasks.generate_plugins_xml.delay", new=do_nothing) + @patch("plugins.validator._check_url_link", new=do_nothing) + def test_change_maintainer(self): + """ + Test change maintainer for plugin update + """ + package_name = self.plugin.package_name + self.url_plugin_update = reverse('plugin_update', args=[package_name]) + self.url_add_version = reverse('version_create', args=[package_name]) + + # Test GET request + response = self.client.get(self.url_plugin_update) + self.assertEqual(response.status_code, 200) + self.assertIsInstance(response.context['form'], PluginForm) + self.assertEqual(response.context['form']['maintainer'].value(), self.user.pk) + + + # Test POST request to change maintainer + + response = self.client.post(self.url_plugin_update, { + 'description': self.plugin.description, + 'about': self.plugin.about, + 'author': self.plugin.author, + 'email': self.plugin.email, + 'tracker': self.plugin.tracker, + 'repository': self.plugin.repository, + 'maintainer': 1, + }) + self.assertEqual(response.status_code, 302) + self.assertEqual(Plugin.objects.get(name='Test Plugin').maintainer.pk, 1) + + # Test POST request with new version + + valid_plugin = os.path.join(TESTFILE_DIR, "valid_plugin_0.0.2.zip_") + with open(valid_plugin, "rb") as file: + uploaded_file = SimpleUploadedFile( + "valid_plugin_0.0.2.zip_", file.read(), + content_type="application/zip_") + + response = self.client.post(self.url_add_version, { + 'package': uploaded_file, + 'experimental': False, + 'changelog': '' + }) + self.assertEqual(response.status_code, 302) + self.assertEqual(Plugin.objects.get(name='Test Plugin').maintainer.pk, 1) + + def tearDown(self): + self.client.logout() diff --git a/qgis-app/plugins/tests/test_plugin_update.py b/qgis-app/plugins/tests/test_plugin_update.py new file mode 100644 index 00000000..5ee6312e --- /dev/null +++ b/qgis-app/plugins/tests/test_plugin_update.py @@ -0,0 +1,136 @@ +import os +from unittest.mock import patch + +from django.urls import reverse +from django.test import Client, TestCase, override_settings +from django.contrib.auth.models import User +from django.core.files.uploadedfile import SimpleUploadedFile +from plugins.models import Plugin, PluginVersion +from plugins.forms import PluginVersionForm + +def do_nothing(*args, **kwargs): + pass + +TESTFILE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "testfiles")) + +class PluginUpdateTestCase(TestCase): + fixtures = [ + "fixtures/styles.json", + "fixtures/auth.json", + "fixtures/simplemenu.json", + ] + + @override_settings(MEDIA_ROOT="api/tests") + def setUp(self): + self.client = Client() + self.url_upload = reverse('plugin_upload') + + # Create a test user + self.user = User.objects.create_user( + username='testuser', + password='testpassword', + email='test@example.com' + ) + + # Log in the test user + self.client.login(username='testuser', password='testpassword') + + # Upload a plugin for renaming test. + # This process is already tested in test_plugin_upload + valid_plugin = os.path.join(TESTFILE_DIR, "valid_plugin.zip_") + with open(valid_plugin, "rb") as file: + uploaded_file = SimpleUploadedFile( + "valid_plugin.zip_", file.read(), + content_type="application/zip") + + self.client.post(self.url_upload, { + 'package': uploaded_file, + }) + + self.plugin = Plugin.objects.get(name='Test Plugin') + + @patch("plugins.tasks.generate_plugins_xml.delay", new=do_nothing) + @patch("plugins.validator._check_url_link", new=do_nothing) + def test_plugin_new_version(self): + """ + Test upload a new plugin version with a modified metadata + """ + package_name = self.plugin.package_name + self.assertEqual(self.plugin.homepage, "https://example.net/") + self.assertEqual(self.plugin.tracker, "https://example.net/") + self.assertEqual(self.plugin.repository, "https://example.net/") + self.url_add_version = reverse('version_create', args=[package_name]) + + # Test POST request without allowing name from metadata + valid_plugin = os.path.join(TESTFILE_DIR, "change_metadata.zip_") + with open(valid_plugin, "rb") as file: + uploaded_file = SimpleUploadedFile( + "change_metadata.zip_", file.read(), + content_type="application/zip_") + + response = self.client.post(self.url_add_version, { + 'package': uploaded_file, + 'experimental': False, + 'changelog': '' + }) + self.assertEqual(response.status_code, 302) + + # The old version should always exist when creating a new version + self.assertTrue(PluginVersion.objects.filter( + plugin__name='Test Plugin', + version='0.0.1').exists() + ) + self.assertTrue(PluginVersion.objects.filter( + plugin__name='Test Plugin', + version='0.0.2').exists() + ) + + self.plugin = Plugin.objects.get(name='Test Plugin') + self.assertEqual(self.plugin.homepage, "https://github.com/") + self.assertEqual(self.plugin.tracker, "https://github.com/") + self.assertEqual(self.plugin.repository, "https://github.com/") + + @patch("plugins.tasks.generate_plugins_xml.delay", new=do_nothing) + @patch("plugins.validator._check_url_link", new=do_nothing) + def test_plugin_version_update(self): + """ + Test update a plugin version with a modified metadata + """ + package_name = self.plugin.package_name + self.assertEqual(self.plugin.homepage, "https://example.net/") + self.assertEqual(self.plugin.tracker, "https://example.net/") + self.assertEqual(self.plugin.repository, "https://example.net/") + self.url_add_version = reverse('version_update', args=[package_name, '0.0.1']) + + # Test POST request without allowing name from metadata + valid_plugin = os.path.join(TESTFILE_DIR, "change_metadata.zip_") + with open(valid_plugin, "rb") as file: + uploaded_file = SimpleUploadedFile( + "change_metadata.zip_", file.read(), + content_type="application/zip_") + + response = self.client.post(self.url_add_version, { + 'package': uploaded_file, + 'experimental': False, + 'changelog': '' + }) + self.assertEqual(response.status_code, 302) + + # The old version should not exist anymore + self.assertFalse(PluginVersion.objects.filter( + plugin__name='Test Plugin', + version='0.0.1').exists() + ) + self.assertTrue(PluginVersion.objects.filter( + plugin__name='Test Plugin', + version='0.0.2').exists() + ) + + self.plugin = Plugin.objects.get(name='Test Plugin') + self.assertEqual(self.plugin.homepage, "https://github.com/") + self.assertEqual(self.plugin.tracker, "https://github.com/") + self.assertEqual(self.plugin.repository, "https://github.com/") + + + def tearDown(self): + self.client.logout() diff --git a/qgis-app/plugins/tests/test_token_auth.py b/qgis-app/plugins/tests/test_token_auth.py new file mode 100644 index 00000000..cfbca6d8 --- /dev/null +++ b/qgis-app/plugins/tests/test_token_auth.py @@ -0,0 +1,163 @@ +import os +from unittest.mock import patch + +from django.urls import reverse +from django.test import Client, TestCase, override_settings +from django.contrib.auth.models import User +from django.core.files.uploadedfile import SimpleUploadedFile +from plugins.models import Plugin, PluginVersion +from plugins.forms import PackageUploadForm +from rest_framework_simplejwt.token_blacklist.models import OutstandingToken +from rest_framework_simplejwt.tokens import RefreshToken + +def do_nothing(*args, **kwargs): + pass + +TESTFILE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "testfiles")) + +class UploadWithTokenTestCase(TestCase): + fixtures = [ + "fixtures/styles.json", + "fixtures/auth.json", + "fixtures/simplemenu.json", + ] + + @override_settings(MEDIA_ROOT="api/tests") + def setUp(self): + self.client = Client() + self.url_upload = reverse('plugin_upload') + + # Create a test user + self.user = User.objects.create_user( + username='testuser', + password='testpassword', + email='test@example.com' + ) + + # Log in the test user + self.client.login(username='testuser', password='testpassword') + + # Upload a plugin for renaming test. + # This process is already tested in test_plugin_upload + valid_plugin = os.path.join(TESTFILE_DIR, "valid_plugin.zip_") + with open(valid_plugin, "rb") as file: + uploaded_file = SimpleUploadedFile( + "valid_plugin.zip_", file.read(), + content_type="application/zip") + + self.client.post(self.url_upload, { + 'package': uploaded_file, + }) + + self.plugin = Plugin.objects.get(name='Test Plugin') + + package_name = self.plugin.package_name + version = '0.0.1' + self.url_add_version = reverse('version_create_api', args=[package_name]) + self.url_update_version = reverse('version_update_api', args=[package_name, version]) + self.url_token_list = reverse('plugin_token_list', args=[package_name]) + self.url_token_create = reverse('plugin_token_create', args=[package_name]) + + def test_token_create(self): + # Test token create + response = self.client.post(self.url_token_create, {}) + self.assertEqual(response.status_code, 302) + tokens = OutstandingToken.objects.all() + self.assertEqual(tokens.count(), 1) + + def test_upload_new_version_with_valid_token(self): + # Generate a token for the authenticated user + self.client.post(self.url_token_create, {}) + outstanding_token = OutstandingToken.objects.last().token + refresh = RefreshToken(outstanding_token) + refresh['plugin_id'] = self.plugin.pk + refresh['refresh_jti'] = refresh['jti'] + access_token = str(refresh.access_token) + + # Log out the user and use the token + self.client.logout() + + valid_plugin = os.path.join(TESTFILE_DIR, "valid_plugin_0.0.2.zip_") + with open(valid_plugin, "rb") as file: + uploaded_file = SimpleUploadedFile( + "valid_plugin_0.0.2.zip_", file.read(), + content_type="application/zip_") + + c = Client(HTTP_AUTHORIZATION=f"Bearer {access_token}") + + # Test POST request with access token + response = c.post(self.url_add_version, { + 'package': uploaded_file, + }) + self.assertEqual(response.status_code, 302) + self.assertTrue(PluginVersion.objects.filter(plugin__name='Test Plugin', version='0.0.2').exists()) + + def test_upload_new_version_with_invalid_token(self): + # Log out the user and use the token + self.client.logout() + + access_token = 'invalid_token' + valid_plugin = os.path.join(TESTFILE_DIR, "valid_plugin_0.0.2.zip_") + with open(valid_plugin, "rb") as file: + uploaded_file = SimpleUploadedFile( + "valid_plugin_0.0.2.zip_", file.read(), + content_type="application/zip_") + + c = Client(HTTP_AUTHORIZATION=f"Bearer {access_token}") + + # Test POST request with access token + response = c.post(self.url_add_version, { + 'package': uploaded_file, + }) + self.assertEqual(response.status_code, 403) + self.assertFalse(PluginVersion.objects.filter(plugin__name='Test Plugin', version='0.0.2').exists()) + + def test_update_version_with_valid_token(self): + # Generate a token for the authenticated user + self.client.post(self.url_token_create, {}) + outstanding_token = OutstandingToken.objects.last().token + refresh = RefreshToken(outstanding_token) + refresh['plugin_id'] = self.plugin.pk + refresh['refresh_jti'] = refresh['jti'] + access_token = str(refresh.access_token) + + # Log out the user and use the token + self.client.logout() + + valid_plugin = os.path.join(TESTFILE_DIR, "valid_plugin_0.0.2.zip_") + with open(valid_plugin, "rb") as file: + uploaded_file = SimpleUploadedFile( + "valid_plugin_0.0.2.zip_", file.read(), + content_type="application/zip_") + + c = Client(HTTP_AUTHORIZATION=f"Bearer {access_token}") + + # Test POST request with access token + response = c.post(self.url_update_version, { + 'package': uploaded_file, + }) + self.assertEqual(response.status_code, 302) + # This will create a new version because this one is using token and doesn't have a created_by column + self.assertTrue(PluginVersion.objects.filter(plugin__name='Test Plugin', version='0.0.1').exists()) + self.assertTrue(PluginVersion.objects.filter(plugin__name='Test Plugin', version='0.0.2').exists()) + + def test_update_version_with_invalid_token(self): + # Log out the user and use the token + self.client.logout() + access_token = 'invalid_token' + + valid_plugin = os.path.join(TESTFILE_DIR, "valid_plugin_0.0.2.zip_") + with open(valid_plugin, "rb") as file: + uploaded_file = SimpleUploadedFile( + "valid_plugin_0.0.2.zip_", file.read(), + content_type="application/zip_") + + c = Client(HTTP_AUTHORIZATION=f"Bearer {access_token}") + + # Test POST request with access token + response = c.post(self.url_update_version, { + 'package': uploaded_file, + }) + self.assertEqual(response.status_code, 403) + self.assertTrue(PluginVersion.objects.filter(plugin__name='Test Plugin', version='0.0.1').exists()) + self.assertFalse(PluginVersion.objects.filter(plugin__name='Test Plugin', version='0.0.2').exists()) \ No newline at end of file diff --git a/qgis-app/plugins/tests/test_validator.py b/qgis-app/plugins/tests/test_validator.py index 13d5af3a..7a0e7e4e 100644 --- a/qgis-app/plugins/tests/test_validator.py +++ b/qgis-app/plugins/tests/test_validator.py @@ -200,21 +200,83 @@ class TestLicenseValidator(TestCase): def setUp(self) -> None: plugin_without_license = os.path.join(TESTFILE_DIR, "plugin_without_license.zip_") - self.invalid_plugin = open(plugin_without_license, "rb") + self.plugin_package = open(plugin_without_license, "rb") def tearDown(self): - self.invalid_plugin.close() + self.plugin_package.close() - def test_zipfile_without_license(self): - self.assertRaises( - ValidationError, - validator, + # License file is just recommended for now + # def test_new_plugin_without_license(self): + # self.assertRaises( + # ValidationError, + # validator, + # InMemoryUploadedFile( + # self.plugin_package, + # field_name="tempfile", + # name="testfile.zip", + # content_type="application/zip", + # size=39889, + # charset="utf8", + # ), + # plugin_is_new=True + # ) + + def test_plugin_without_license(self): + self.assertTrue( + validator( + InMemoryUploadedFile( + self.plugin_package, + field_name="tempfile", + name="testfile.zip", + content_type="application/zip", + size=39889, + charset="utf8", + ) + ) + ) + +class TestMultipleParentFoldersValidator(TestCase): + """Test if zipfile contains multiple parent folders """ + + def setUp(self) -> None: + multi_parents_plugin = os.path.join(TESTFILE_DIR, "multi_parents_plugin.zip_") + self.multi_parents_plugin_package = open(multi_parents_plugin, "rb") + valid_plugin = os.path.join(TESTFILE_DIR, "valid_plugin.zip_") + self.single_parent_plugin_package = open(valid_plugin, "rb") + + def tearDown(self): + self.multi_parents_plugin_package.close() + self.single_parent_plugin_package.close() + + def _get_value_by_attribute(self, attribute, data): + for key, value in data: + if key == attribute: + return value + return None + def test_plugin_with_multiple_parents(self): + result = validator( InMemoryUploadedFile( - self.invalid_plugin, + self.multi_parents_plugin_package, field_name="tempfile", name="testfile.zip", content_type="application/zip", size=39889, charset="utf8", - ), - ) \ No newline at end of file + ) + ) + multiple_parent_folders = self._get_value_by_attribute('multiple_parent_folders', result) + self.assertIsNotNone(multiple_parent_folders) + + def test_plugin_with_single_parent(self): + result = validator( + InMemoryUploadedFile( + self.single_parent_plugin_package, + field_name="tempfile", + name="testfile.zip", + content_type="application/zip", + size=39889, + charset="utf8", + ) + ) + multiple_parent_folders = self._get_value_by_attribute('multiple_parent_folders', result) + self.assertIsNone(multiple_parent_folders) diff --git a/qgis-app/plugins/tests/testfiles/change_metadata.zip_ b/qgis-app/plugins/tests/testfiles/change_metadata.zip_ new file mode 100644 index 00000000..4b4dd6d5 Binary files /dev/null and b/qgis-app/plugins/tests/testfiles/change_metadata.zip_ differ diff --git a/qgis-app/plugins/tests/testfiles/multi_parents_plugin.zip_ b/qgis-app/plugins/tests/testfiles/multi_parents_plugin.zip_ new file mode 100644 index 00000000..ace51b2c Binary files /dev/null and b/qgis-app/plugins/tests/testfiles/multi_parents_plugin.zip_ differ diff --git a/qgis-app/plugins/urls.py b/qgis-app/plugins/urls.py index 52abab9a..dd021892 100644 --- a/qgis-app/plugins/urls.py +++ b/qgis-app/plugins/urls.py @@ -47,6 +47,34 @@ {}, name="plugin_update", ), + url( + r"^(?P[A-Za-z][A-Za-z0-9-_]+)/tokens/$", + PluginTokenListView.as_view(), + name="plugin_token_list", + ), + url( + r"^(?P[A-Za-z][A-Za-z0-9-_]+)/tokens/(?P\d+)/$", + PluginTokenDetailView.as_view(), + name="plugin_token_detail", + ), + url( + r"^(?P[A-Za-z][A-Za-z0-9-_]+)/tokens/create/$", + plugin_token_create, + {}, + name="plugin_token_create", + ), + url( + r"^(?P[A-Za-z][A-Za-z0-9-_]+)/tokens/(?P\d+)/update$", + plugin_token_update, + {}, + name="plugin_token_update", + ), + url( + r"^(?P[A-Za-z][A-Za-z0-9-_]+)/tokens/(?P[^\/]+)/delete/$", + plugin_token_delete, + {}, + name="plugin_token_delete", + ), url( r"^(?P[A-Za-z][A-Za-z0-9-_]+)/set_featured/$", plugin_set_featured, @@ -224,6 +252,12 @@ {}, name="version_create", ), + url( + r"^api/(?P[A-Za-z][A-Za-z0-9-_]+)/version/add/$", + version_create_api, + {}, + name="version_create_api", + ), url( r"^(?P[A-Za-z][A-Za-z0-9-_]+)/version/(?P[^\/]+)/$", version_detail, @@ -242,6 +276,12 @@ {}, name="version_update", ), + url( + r"^api/(?P[A-Za-z][A-Za-z0-9-_]+)/version/(?P[^\/]+)/update/$", + version_update_api, + {}, + name="version_update_api", + ), url( r"^(?P[A-Za-z][A-Za-z0-9-_]+)/version/(?P[^\/]+)/download/$", version_download, diff --git a/qgis-app/plugins/validator.py b/qgis-app/plugins/validator.py index 930eeb77..e1482bd4 100644 --- a/qgis-app/plugins/validator.py +++ b/qgis-app/plugins/validator.py @@ -90,7 +90,7 @@ def _check_required_metadata(metadata): if md not in dict(metadata) or not dict(metadata)[md]: raise ValidationError( _( - 'Cannot find metadata %s in metadata source %s.
For further informations about metadata, please see: metadata documentation' + 'Cannot find metadata %s in metadata source %s.
For further informations about metadata, please see: metadata documentation' ) % (md, dict(metadata).get("metadata_source")) ) @@ -155,6 +155,7 @@ def validator(package): * size <= PLUGIN_MAX_UPLOAD_SIZE * zip contains __init__.py in first level dir + * Check for LICENCE file * mandatory metadata: ('name', 'description', 'version', 'qgisMinimumVersion', 'author', 'email') * package_name regexp: [A-Za-z][A-Za-z0-9-_]+ * author regexp: [^/]+ @@ -209,8 +210,20 @@ def validator(package): errors="replace", ) - # Checks that package_name exists + # Metadata list, also usefull to pass warnings to the main view + metadata = [] + namelist = zip.namelist() + # Check if the zip file contains multiple parent folders + # If it is, show a warning for now + try: + parent_folders = list(set([str(name).split('/')[0] for name in namelist])) + if len(parent_folders) > 1: + metadata.append(("multiple_parent_folders", ', '.join(parent_folders))) + except: + pass + + # Checks that package_name exists try: package_name = namelist[0][: namelist[0].index("/")] except: @@ -237,13 +250,6 @@ def validator(package): if initname not in namelist: raise ValidationError(_("Cannot find __init__.py in plugin package.")) - # Checks for LICENCE file precense - licensename = package_name + "/LICENSE" - if licensename not in namelist: - raise ValidationError(_("Cannot find LICENSE in plugin package.")) - - # Checks metadata - metadata = [] # First parse metadata.txt if metadataname in namelist: try: @@ -329,6 +335,14 @@ def validator(package): _check_url_link(dict(metadata).get("repository"), "http://repo", "Repository") _check_url_link(dict(metadata).get("homepage"), "http://homepage", "Home page") + + # Checks for LICENCE file presence + # This should be just a warning for now (for new version upload) + # according to https://github.com/qgis/QGIS-Django/issues/38#issuecomment-1824010198 + licensename = package_name + "/LICENSE" + if licensename not in namelist: + metadata.append(("license_recommended", "Yes")) + zip.close() del zip diff --git a/qgis-app/plugins/views.py b/qgis-app/plugins/views.py index 487d22ad..8cb6adaf 100644 --- a/qgis-app/plugins/views.py +++ b/qgis-app/plugins/views.py @@ -23,16 +23,22 @@ from django.utils.encoding import DjangoUnicodeDecodeError from django.utils.translation import ugettext_lazy as _ from django.views.decorators.cache import never_cache -from django.views.decorators.csrf import csrf_protect, ensure_csrf_cookie +from django.views.decorators.csrf import ensure_csrf_cookie, csrf_exempt, csrf_protect from django.views.decorators.http import require_POST from django.views.generic.detail import DetailView +from django.db import transaction # from sortable_listview import SortableListView from django.views.generic.list import ListView +from plugins.decorators import has_valid_token from plugins.forms import * -from plugins.models import Plugin, PluginVersion, PluginVersionDownload, vjust +from plugins.models import Plugin, PluginOutstandingToken, PluginVersion, PluginVersionDownload, vjust from plugins.validator import PLUGIN_REQUIRED_METADATA +from rest_framework_simplejwt.token_blacklist.models import OutstandingToken +from rest_framework_simplejwt.tokens import RefreshToken, api_settings +from rest_framework_simplejwt.exceptions import InvalidToken, TokenError +import time try: from urllib import unquote, urlencode @@ -250,6 +256,16 @@ def check_plugin_access(user, plugin): """ return user.is_staff or user in plugin.editors +def check_plugin_token_access(user, plugin): + """ + Returns true if the user can access all the plugin's token: + + * is_staff + * is maintainer + + """ + return user.is_staff or user.pk == plugin.created_by.pk + def check_plugin_version_approval_rights(user, plugin): """ @@ -468,6 +484,27 @@ def plugin_upload(request): fail_silently=True, ) + if form.cleaned_data.get("license_recommended"): + messages.warning( + request, + _( + "Please note that as of 1 June 2024, providing a license file will be mandatory for any new updates to existing plugins and for any new plugins published." + ), + fail_silently=True, + ) + del form.cleaned_data["license_recommended"] + + if form.cleaned_data.get("multiple_parent_folders"): + parent_folders = form.cleaned_data.get("multiple_parent_folders") + messages.warning( + request, + _( + f"Your plugin includes multiple parent folders: {parent_folders}. Please be aware that only the first folder has been recognized. It is strongly advised to have a single parent folder." + ), + fail_silently=True, + ) + del form.cleaned_data["multiple_parent_folders"] + except (IntegrityError, ValidationError, DjangoUnicodeDecodeError) as e: connection.close() messages.error(request, e, fail_silently=True) @@ -584,6 +621,199 @@ def plugin_update(request, package_name): ) + +class PluginTokenListView(ListView): + """ + Plugin token list + """ + model = PluginOutstandingToken + queryset = PluginOutstandingToken.objects.all().order_by("-token__created_at") + template_name = "plugins/plugin_token_list.html" + + @method_decorator(ensure_csrf_cookie) + def dispatch(self, *args, **kwargs): + return super(PluginTokenListView, self).dispatch(*args, **kwargs) + + def get_filtered_queryset(self, qs): + package_name = self.kwargs.get('package_name') + plugin = get_object_or_404(Plugin, package_name=package_name) + if not check_plugin_token_access(self.request.user, plugin): + return qs.filter( + plugin__pk=plugin.pk, + is_blacklisted=False, + token__user=self.request.user + ) + return qs.filter( + plugin__pk=plugin.pk, + is_blacklisted=False, + ) + + def get_queryset(self): + qs = super(PluginTokenListView, self).get_queryset() + qs = self.get_filtered_queryset(qs) + return qs + + def get_context_data(self, **kwargs): + package_name = self.kwargs.get('package_name') + plugin = get_object_or_404(Plugin, package_name=package_name) + if not check_plugin_access(self.request.user, plugin): + context = {} + self.template_name = "plugins/plugin_token_permission_deny.html" + return context + context = super(PluginTokenListView, self).get_context_data(**kwargs) + context.update( + { + "plugin": plugin + } + ) + return context + +class PluginTokenDetailView(DetailView): + """ + Plugin token detail + """ + model = OutstandingToken + queryset = OutstandingToken.objects.all() + template_name = "plugins/plugin_token_detail.html" + + @method_decorator(ensure_csrf_cookie) + def dispatch(self, *args, **kwargs): + return super(PluginTokenDetailView, self).dispatch(*args, **kwargs) + + def get_context_data(self, **kwargs): + context = super(PluginTokenDetailView, self).get_context_data(**kwargs) + package_name = self.kwargs.get('package_name') + token_id = self.kwargs.get('pk') + plugin = get_object_or_404(Plugin, package_name=package_name) + if not check_plugin_access(self.request.user, plugin): + context = {} + self.template_name = "plugins/plugin_token_permission_deny.html" + return context + + outstanding_token = get_object_or_404(OutstandingToken, pk=token_id, user=self.request.user) + plugin_token = get_object_or_404( + PluginOutstandingToken, + token__pk=outstanding_token.pk, + is_blacklisted=False, + is_newly_created=True + ) + try: + token = RefreshToken(outstanding_token.token) + token['plugin_id'] = plugin.pk + token['refresh_jti'] = token[api_settings.JTI_CLAIM] + del token['user_id'] + except (InvalidToken, TokenError) as e: + context = {} + self.template_name = "plugins/plugin_token_invalid_or_expired.html" + return context + timestamp_from_last_edit = int(time.time()) + context.update( + { + "access_token": str(token.access_token), + "plugin": plugin, + "object": outstanding_token, + 'timestamp_from_last_edit': timestamp_from_last_edit + } + ) + plugin_token.is_newly_created = False + plugin_token.save() + return context + +@login_required +@transaction.atomic +def plugin_token_create(request, package_name): + if request.method == "POST": + plugin = get_object_or_404(Plugin, package_name=package_name) + user = request.user + if not check_plugin_access(user, plugin): + return render(request, "plugins/plugin_permission_deny.html", {}) + + refresh = RefreshToken.for_user(user) + refresh["plugin_id"] = plugin.pk + + jti = refresh[api_settings.JTI_CLAIM] + + outstanding_token = OutstandingToken.objects.get(jti=jti) + + plugin_token = PluginOutstandingToken.objects.create( + plugin=plugin, + token=outstanding_token, + is_blacklisted=False, + is_newly_created=True + ) + + return HttpResponseRedirect( + reverse("plugin_token_detail", args=(plugin.package_name, plugin_token.pk)) + ) + +@login_required +@transaction.atomic +def plugin_token_update(request, package_name, token_id): + plugin = get_object_or_404(Plugin, package_name=package_name) + outstanding_token = get_object_or_404(OutstandingToken, pk=token_id) + if not check_plugin_token_access(request.user, plugin): + outstanding_token = get_object_or_404(OutstandingToken, pk=token_id, user=request.user) + plugin_token = get_object_or_404( + PluginOutstandingToken, + token__pk=outstanding_token.pk, + is_blacklisted=False + ) + if not check_plugin_access(request.user, plugin): + return render(request, "plugins/version_permission_deny.html", {}) + if request.method == "POST": + form = PluginTokenForm(request.POST, instance=plugin_token) + if form.is_valid(): + form.save() + msg = _("The token description has been successfully updated.") + messages.success(request, msg, fail_silently=True) + return HttpResponseRedirect( + reverse("plugin_token_list", args=(plugin.package_name,)) + ) + else: + form = PluginTokenForm(instance=plugin_token) + + return render( + request, + "plugins/plugin_token_form.html", + {"form": form, "token": plugin_token} + ) + +@login_required +@transaction.atomic +def plugin_token_delete(request, package_name, token_id): + plugin = get_object_or_404(Plugin, package_name=package_name) + outstanding_token = get_object_or_404(OutstandingToken, pk=token_id) + if not check_plugin_token_access(request.user, plugin): + outstanding_token = get_object_or_404(OutstandingToken, pk=token_id, user=request.user) + plugin_token = get_object_or_404( + PluginOutstandingToken, + token__pk=outstanding_token.pk, + is_blacklisted=False + ) + + if not check_plugin_access(request.user, plugin): + return render(request, "plugins/version_permission_deny.html", {}) + if "delete_confirm" in request.POST: + try: + token = RefreshToken(outstanding_token.token) + token.blacklist() + plugin_token.is_blacklisted = True + except (InvalidToken, TokenError) as e: + plugin_token.is_blacklisted = True + plugin_token.save() + + msg = _("The token has been successfully deleted.") + messages.success(request, msg, fail_silently=True) + return HttpResponseRedirect( + reverse("plugin_token_list", args=(plugin.package_name,)) + ) + return render( + request, + "plugins/plugin_token_delete_confirm.html", + {"plugin": plugin, "username": outstanding_token.user}, + ) + + class PluginsList(ListView): model = Plugin queryset = Plugin.approved_objects.all() @@ -895,7 +1125,7 @@ def _main_plugin_update(request, plugin, form): Updates the main plugin object from version metadata """ # Check if update name from metadata is allowed - metadata_fields = ["author", "email", "description", "about", "homepage", "tracker"] + metadata_fields = ["author", "email", "description", "about", "homepage", "tracker", "repository"] if plugin.allow_update_name: metadata_fields.insert(0, "name") @@ -913,28 +1143,44 @@ def _main_plugin_update(request, plugin, form): ) plugin.save() +@has_valid_token +@csrf_exempt +def version_create_api(request, package_name): + """ + Create a new version using a valid token. + We make sure that the token is valid before + disabling CSRF protection. + """ + plugin = get_object_or_404(Plugin, package_name=package_name) + version = PluginVersion(plugin=plugin, is_from_token=True, token=request.plugin_token) + + return _version_create(request, plugin, version) + @login_required def version_create(request, package_name): - """ - The form will create versions according to permissions, - plugin name and description are updated according to the info - contained in the package metadata - """ plugin = get_object_or_404(Plugin, package_name=package_name) if not check_plugin_access(request.user, plugin): return render( request, "plugins/version_permission_deny.html", {"plugin": plugin} ) - version = PluginVersion(plugin=plugin, created_by=request.user) + is_trusted=request.user.has_perm("plugins.can_approve") + return _version_create(request, plugin, version, is_trusted=is_trusted) + +def _version_create(request, plugin, version, is_trusted=False): + """ + The form will create versions according to permissions, + plugin name and description are updated according to the info + contained in the package metadata + """ if request.method == "POST": form = PluginVersionForm( request.POST, request.FILES, instance=version, - is_trusted=request.user.has_perm("plugins.can_approve"), + is_trusted=is_trusted ) if form.is_valid(): try: @@ -943,7 +1189,7 @@ def version_create(request, package_name): messages.success(request, msg, fail_silently=True) # The approved flag is also controlled in the form, but we # are checking it here in any case for additional security - if not request.user.has_perm("plugins.can_approve"): + if not is_trusted: new_object.approved = False new_object.save() messages.warning( @@ -956,6 +1202,28 @@ def version_create(request, package_name): version_notify(new_object) if form.cleaned_data.get("icon_file"): form.cleaned_data["icon"] = form.cleaned_data.get("icon_file") + + if form.cleaned_data.get("license_recommended"): + messages.warning( + request, + _( + "Please note that as of 1 June 2024, providing a license file will be mandatory for any new updates to existing plugins and for any new plugins published." + ), + fail_silently=True, + ) + del form.cleaned_data["license_recommended"] + + if form.cleaned_data.get("multiple_parent_folders"): + parent_folders = form.cleaned_data.get("multiple_parent_folders") + messages.warning( + request, + _( + f"Your plugin includes multiple parent folders: {parent_folders}. Please be aware that only the first folder has been recognized. It is strongly advised to have a single parent folder." + ), + fail_silently=True, + ) + del form.cleaned_data["multiple_parent_folders"] + _main_plugin_update(request, new_object.plugin, form) _check_optional_metadata(form, request) return HttpResponseRedirect(new_object.plugin.get_absolute_url()) @@ -965,7 +1233,7 @@ def version_create(request, package_name): return HttpResponseRedirect(plugin.get_absolute_url()) else: form = PluginVersionForm( - is_trusted=request.user.has_perm("plugins.can_approve") + is_trusted=is_trusted ) return render( @@ -975,24 +1243,42 @@ def version_create(request, package_name): ) -@login_required -def version_update(request, package_name, version): +@has_valid_token +@csrf_exempt +def version_update_api(request, package_name, version): """ - The form will update versions according to permissions + Update a version using a valid token. + We make sure that the token is valid before + disabling CSRF protection. """ + plugin = get_object_or_404(Plugin, package_name=package_name) + version = PluginVersion(plugin=plugin, is_from_token=True, token=request.plugin_token) + return _version_update(request, plugin, version) + + +@login_required +def version_update(request, package_name, version): plugin = get_object_or_404(Plugin, package_name=package_name) version = get_object_or_404(PluginVersion, plugin=plugin, version=version) if not check_plugin_access(request.user, plugin): return render( request, "plugins/version_permission_deny.html", {"plugin": plugin} ) + version = PluginVersion(plugin=plugin, created_by=request.user) + is_trusted=request.user.has_perm("plugins.can_approve") + return _version_update(request, plugin, version, is_trusted=is_trusted) + +def _version_update(request, plugin, version, is_trusted=False): + """ + The form will update versions according to permissions + """ if request.method == "POST": form = PluginVersionForm( request.POST, request.FILES, instance=version, - is_trusted=request.user.has_perm("plugins.can_approve"), + is_trusted=is_trusted, ) if form.is_valid(): try: @@ -1001,13 +1287,35 @@ def version_update(request, package_name, version): _main_plugin_update(request, new_object.plugin, form) msg = _("The Plugin Version has been successfully updated.") messages.success(request, msg, fail_silently=True) + + if form.cleaned_data.get("license_recommended"): + messages.warning( + request, + _( + "Please note that as of 1 June 2024, providing a license file will be mandatory for any new updates to existing plugins and for any new plugins published." + ), + fail_silently=True, + ) + del form.cleaned_data["license_recommended"] + + if form.cleaned_data.get("multiple_parent_folders"): + parent_folders = form.cleaned_data.get("multiple_parent_folders") + messages.warning( + request, + _( + f"Your plugin includes multiple parent folders: {parent_folders}. Please be aware that only the first folder has been recognized. It is strongly advised to have a single parent folder." + ), + fail_silently=True, + ) + del form.cleaned_data["multiple_parent_folders"] + except (IntegrityError, ValidationError, DjangoUnicodeDecodeError) as e: messages.error(request, e, fail_silently=True) connection.close() return HttpResponseRedirect(plugin.get_absolute_url()) else: form = PluginVersionForm( - instance=version, is_trusted=request.user.has_perm("plugins.can_approve") + instance=version, is_trusted=is_trusted ) return render( @@ -1283,11 +1591,13 @@ def xml_plugins(request, qg_version=None, stable_only=None, package_name=None): * package_name: Plugin.package_name """ + request_version = request.GET.get("qgis", "1.8.0") + version_level = len(str(request_version).split('.')) - 1 qg_version = ( qg_version if qg_version is not None else vjust( - request.GET.get("qgis", "1.8.0"), fillchar="0", level=2, force_zero=True + request_version, fillchar="0", level=version_level, force_zero=True ) ) stable_only = ( @@ -1399,11 +1709,13 @@ def xml_plugins_new(request, qg_version=None, stable_only=None, package_name=Non * package_name: Plugin.package_name """ + request_version = request.GET.get("qgis", "1.8.0") + version_level = len(str(request_version).split('.')) - 1 qg_version = ( qg_version if qg_version is not None else vjust( - request.GET.get("qgis", "1.8.0"), fillchar="0", level=2, force_zero=True + request_version, fillchar="0", level=version_level, force_zero=True ) ) stable_only = ( diff --git a/qgis-app/settings.py b/qgis-app/settings.py index 54d76a4a..8e6e8ef3 100644 --- a/qgis-app/settings.py +++ b/qgis-app/settings.py @@ -3,6 +3,7 @@ # ABP: More portable config import os +from datetime import timedelta SITE_ROOT = os.path.dirname(os.path.realpath(__file__)) TEMPLATE_DEBUG = False @@ -148,10 +149,14 @@ "leaflet", "bootstrapform", "rest_framework", + 'rest_framework.authtoken', + 'rest_framework_simplejwt', + 'rest_framework_simplejwt.token_blacklist', "rest_framework_gis", "preferences", # styles: "styles", + "matomo" ] TEMPLATES = [ @@ -329,3 +334,12 @@ RESULT_BACKEND = BROKER_URL CELERY_BROKER_URL = BROKER_URL CELERY_RESULT_BACKEND = CELERY_BROKER_URL + +# Token access and refresh validity +SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': timedelta(days=15), + 'REFRESH_TOKEN_LIFETIME': timedelta(days=15), +} + +MATOMO_SITE_ID="1" +MATOMO_URL="//matomo.qgis.org/" diff --git a/qgis-app/settings_docker.py b/qgis-app/settings_docker.py index b156be9a..b54836d0 100644 --- a/qgis-app/settings_docker.py +++ b/qgis-app/settings_docker.py @@ -7,6 +7,7 @@ from settings import * SITE_ROOT = os.path.dirname(os.path.realpath(__file__)) +from datetime import timedelta DEBUG = ast.literal_eval(os.environ.get("DEBUG", "True")) THUMBNAIL_DEBUG = DEBUG @@ -14,7 +15,7 @@ # Absolute filesystem path to the directory that will hold user-uploaded files. # Example: "/var/www/example.com/media/" -MEDIA_ROOT = "/home/web/media/" +MEDIA_ROOT = os.environ.get("MEDIA_ROOT", "/home/web/media") # URL that handles the media served from MEDIA_ROOT. Make sure to use a # trailing slash. @@ -27,7 +28,7 @@ # Don't put anything in this directory yourself; store your static files # in apps' "static/" subdirectories and in STATICFILES_DIRS. # Example: "/var/www/example.com/static/" -STATIC_ROOT = "/home/web/static" +STATIC_ROOT = os.environ.get("STATIC_ROOT", "/home/web/static") # URL prefix for static files. # Example: "http://example.com/static/", "http://static.example.com/" @@ -66,6 +67,9 @@ "feedjack", "preferences", "rest_framework", + 'rest_framework.authtoken', + 'rest_framework_simplejwt', + 'rest_framework_simplejwt.token_blacklist', "sorl_thumbnail_serializer", # serialize image "drf_multiple_model", "drf_yasg", @@ -79,6 +83,7 @@ # models (sharing .model3 file feature) "models", "wavefronts", + "matomo" ] DATABASES = { @@ -139,3 +144,11 @@ 'schedule': crontab(minute='*/30'), # Execute every 30 minutes. } } +# Set plugin token access and refresh validity to a very long duration +SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': timedelta(days=365*1000), + 'REFRESH_TOKEN_LIFETIME': timedelta(days=365*1000) +} + +MATOMO_SITE_ID="1" +MATOMO_URL="//matomo.qgis.org/" diff --git a/qgis-app/static/js/local_timezone-1.0.js b/qgis-app/static/js/local_timezone-1.0.js new file mode 100644 index 00000000..6b6cca21 --- /dev/null +++ b/qgis-app/static/js/local_timezone-1.0.js @@ -0,0 +1,40 @@ +// Replace the date with local timezone + +$(".user-timezone").each(function (i) { + let localDate = toUserTimeZone($(this).text()); + $(this).text(localDate); +}) + +$(".user-timezone-short").each(function (i) { + let localDate = toUserTimeZone($(this).text(), withTime=false); + $(this).text(localDate); +}) + +$(".user-timezone-short-naturalday").each(function (i) { + let localDate = toUserTimeZone($(this).text(), withTime=false, isNaturalDay=true); + $(this).text(localDate); +}) + +function toUserTimeZone(date, withTime=true, isNaturalDay=false) { + try { + date = new Date(date); + let options = { + year: 'numeric', month: 'short', day: 'numeric' + } + if (withTime) { + options['hour'] = '2-digit' + options['minute'] = '2-digit' + options['timeZoneName'] = 'short' + } + const diffInDays = moment().diff(moment(date), 'days'); + + if (diffInDays <= 1 && isNaturalDay) { + const distance = moment(date).fromNow(); + return distance + } + return date.toLocaleDateString([], options); + } catch (e) { + return date; + } +} + diff --git a/qgis-app/static/js/local_timezone.js b/qgis-app/static/js/local_timezone.js deleted file mode 100644 index e3436293..00000000 --- a/qgis-app/static/js/local_timezone.js +++ /dev/null @@ -1,21 +0,0 @@ -// Replace the date with local timezone - -$(".user-timezone").each(function (i) { - let localDate = toUserTimeZone($(this).text()); - $(this).text(localDate); -}) - -function toUserTimeZone(date) { - try { - date = new Date(date); - let options = { - year: 'numeric', month: 'short', day: 'numeric', - hour: '2-digit', minute: '2-digit', - timeZoneName: 'short' - } - return date.toLocaleDateString([], options); - } catch (e) { - return date; - } -} - diff --git a/qgis-app/static/js/moment.min.js b/qgis-app/static/js/moment.min.js new file mode 100644 index 00000000..05a63b14 --- /dev/null +++ b/qgis-app/static/js/moment.min.js @@ -0,0 +1,5685 @@ +//! moment.js +//! version : 2.29.4 +//! authors : Tim Wood, Iskren Chernev, Moment.js contributors +//! license : MIT +//! momentjs.com + +;(function (global, factory) { + typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : + typeof define === 'function' && define.amd ? define(factory) : + global.moment = factory() +}(this, (function () { 'use strict'; + + var hookCallback; + + function hooks() { + return hookCallback.apply(null, arguments); + } + + // This is done to register the method called with moment() + // without creating circular dependencies. + function setHookCallback(callback) { + hookCallback = callback; + } + + function isArray(input) { + return ( + input instanceof Array || + Object.prototype.toString.call(input) === '[object Array]' + ); + } + + function isObject(input) { + // IE8 will treat undefined and null as object if it wasn't for + // input != null + return ( + input != null && + Object.prototype.toString.call(input) === '[object Object]' + ); + } + + function hasOwnProp(a, b) { + return Object.prototype.hasOwnProperty.call(a, b); + } + + function isObjectEmpty(obj) { + if (Object.getOwnPropertyNames) { + return Object.getOwnPropertyNames(obj).length === 0; + } else { + var k; + for (k in obj) { + if (hasOwnProp(obj, k)) { + return false; + } + } + return true; + } + } + + function isUndefined(input) { + return input === void 0; + } + + function isNumber(input) { + return ( + typeof input === 'number' || + Object.prototype.toString.call(input) === '[object Number]' + ); + } + + function isDate(input) { + return ( + input instanceof Date || + Object.prototype.toString.call(input) === '[object Date]' + ); + } + + function map(arr, fn) { + var res = [], + i, + arrLen = arr.length; + for (i = 0; i < arrLen; ++i) { + res.push(fn(arr[i], i)); + } + return res; + } + + function extend(a, b) { + for (var i in b) { + if (hasOwnProp(b, i)) { + a[i] = b[i]; + } + } + + if (hasOwnProp(b, 'toString')) { + a.toString = b.toString; + } + + if (hasOwnProp(b, 'valueOf')) { + a.valueOf = b.valueOf; + } + + return a; + } + + function createUTC(input, format, locale, strict) { + return createLocalOrUTC(input, format, locale, strict, true).utc(); + } + + function defaultParsingFlags() { + // We need to deep clone this object. + return { + empty: false, + unusedTokens: [], + unusedInput: [], + overflow: -2, + charsLeftOver: 0, + nullInput: false, + invalidEra: null, + invalidMonth: null, + invalidFormat: false, + userInvalidated: false, + iso: false, + parsedDateParts: [], + era: null, + meridiem: null, + rfc2822: false, + weekdayMismatch: false, + }; + } + + function getParsingFlags(m) { + if (m._pf == null) { + m._pf = defaultParsingFlags(); + } + return m._pf; + } + + var some; + if (Array.prototype.some) { + some = Array.prototype.some; + } else { + some = function (fun) { + var t = Object(this), + len = t.length >>> 0, + i; + + for (i = 0; i < len; i++) { + if (i in t && fun.call(this, t[i], i, t)) { + return true; + } + } + + return false; + }; + } + + function isValid(m) { + if (m._isValid == null) { + var flags = getParsingFlags(m), + parsedParts = some.call(flags.parsedDateParts, function (i) { + return i != null; + }), + isNowValid = + !isNaN(m._d.getTime()) && + flags.overflow < 0 && + !flags.empty && + !flags.invalidEra && + !flags.invalidMonth && + !flags.invalidWeekday && + !flags.weekdayMismatch && + !flags.nullInput && + !flags.invalidFormat && + !flags.userInvalidated && + (!flags.meridiem || (flags.meridiem && parsedParts)); + + if (m._strict) { + isNowValid = + isNowValid && + flags.charsLeftOver === 0 && + flags.unusedTokens.length === 0 && + flags.bigHour === undefined; + } + + if (Object.isFrozen == null || !Object.isFrozen(m)) { + m._isValid = isNowValid; + } else { + return isNowValid; + } + } + return m._isValid; + } + + function createInvalid(flags) { + var m = createUTC(NaN); + if (flags != null) { + extend(getParsingFlags(m), flags); + } else { + getParsingFlags(m).userInvalidated = true; + } + + return m; + } + + // Plugins that add properties should also add the key here (null value), + // so we can properly clone ourselves. + var momentProperties = (hooks.momentProperties = []), + updateInProgress = false; + + function copyConfig(to, from) { + var i, + prop, + val, + momentPropertiesLen = momentProperties.length; + + if (!isUndefined(from._isAMomentObject)) { + to._isAMomentObject = from._isAMomentObject; + } + if (!isUndefined(from._i)) { + to._i = from._i; + } + if (!isUndefined(from._f)) { + to._f = from._f; + } + if (!isUndefined(from._l)) { + to._l = from._l; + } + if (!isUndefined(from._strict)) { + to._strict = from._strict; + } + if (!isUndefined(from._tzm)) { + to._tzm = from._tzm; + } + if (!isUndefined(from._isUTC)) { + to._isUTC = from._isUTC; + } + if (!isUndefined(from._offset)) { + to._offset = from._offset; + } + if (!isUndefined(from._pf)) { + to._pf = getParsingFlags(from); + } + if (!isUndefined(from._locale)) { + to._locale = from._locale; + } + + if (momentPropertiesLen > 0) { + for (i = 0; i < momentPropertiesLen; i++) { + prop = momentProperties[i]; + val = from[prop]; + if (!isUndefined(val)) { + to[prop] = val; + } + } + } + + return to; + } + + // Moment prototype object + function Moment(config) { + copyConfig(this, config); + this._d = new Date(config._d != null ? config._d.getTime() : NaN); + if (!this.isValid()) { + this._d = new Date(NaN); + } + // Prevent infinite loop in case updateOffset creates new moment + // objects. + if (updateInProgress === false) { + updateInProgress = true; + hooks.updateOffset(this); + updateInProgress = false; + } + } + + function isMoment(obj) { + return ( + obj instanceof Moment || (obj != null && obj._isAMomentObject != null) + ); + } + + function warn(msg) { + if ( + hooks.suppressDeprecationWarnings === false && + typeof console !== 'undefined' && + console.warn + ) { + console.warn('Deprecation warning: ' + msg); + } + } + + function deprecate(msg, fn) { + var firstTime = true; + + return extend(function () { + if (hooks.deprecationHandler != null) { + hooks.deprecationHandler(null, msg); + } + if (firstTime) { + var args = [], + arg, + i, + key, + argLen = arguments.length; + for (i = 0; i < argLen; i++) { + arg = ''; + if (typeof arguments[i] === 'object') { + arg += '\n[' + i + '] '; + for (key in arguments[0]) { + if (hasOwnProp(arguments[0], key)) { + arg += key + ': ' + arguments[0][key] + ', '; + } + } + arg = arg.slice(0, -2); // Remove trailing comma and space + } else { + arg = arguments[i]; + } + args.push(arg); + } + warn( + msg + + '\nArguments: ' + + Array.prototype.slice.call(args).join('') + + '\n' + + new Error().stack + ); + firstTime = false; + } + return fn.apply(this, arguments); + }, fn); + } + + var deprecations = {}; + + function deprecateSimple(name, msg) { + if (hooks.deprecationHandler != null) { + hooks.deprecationHandler(name, msg); + } + if (!deprecations[name]) { + warn(msg); + deprecations[name] = true; + } + } + + hooks.suppressDeprecationWarnings = false; + hooks.deprecationHandler = null; + + function isFunction(input) { + return ( + (typeof Function !== 'undefined' && input instanceof Function) || + Object.prototype.toString.call(input) === '[object Function]' + ); + } + + function set(config) { + var prop, i; + for (i in config) { + if (hasOwnProp(config, i)) { + prop = config[i]; + if (isFunction(prop)) { + this[i] = prop; + } else { + this['_' + i] = prop; + } + } + } + this._config = config; + // Lenient ordinal parsing accepts just a number in addition to + // number + (possibly) stuff coming from _dayOfMonthOrdinalParse. + // TODO: Remove "ordinalParse" fallback in next major release. + this._dayOfMonthOrdinalParseLenient = new RegExp( + (this._dayOfMonthOrdinalParse.source || this._ordinalParse.source) + + '|' + + /\d{1,2}/.source + ); + } + + function mergeConfigs(parentConfig, childConfig) { + var res = extend({}, parentConfig), + prop; + for (prop in childConfig) { + if (hasOwnProp(childConfig, prop)) { + if (isObject(parentConfig[prop]) && isObject(childConfig[prop])) { + res[prop] = {}; + extend(res[prop], parentConfig[prop]); + extend(res[prop], childConfig[prop]); + } else if (childConfig[prop] != null) { + res[prop] = childConfig[prop]; + } else { + delete res[prop]; + } + } + } + for (prop in parentConfig) { + if ( + hasOwnProp(parentConfig, prop) && + !hasOwnProp(childConfig, prop) && + isObject(parentConfig[prop]) + ) { + // make sure changes to properties don't modify parent config + res[prop] = extend({}, res[prop]); + } + } + return res; + } + + function Locale(config) { + if (config != null) { + this.set(config); + } + } + + var keys; + + if (Object.keys) { + keys = Object.keys; + } else { + keys = function (obj) { + var i, + res = []; + for (i in obj) { + if (hasOwnProp(obj, i)) { + res.push(i); + } + } + return res; + }; + } + + var defaultCalendar = { + sameDay: '[Today at] LT', + nextDay: '[Tomorrow at] LT', + nextWeek: 'dddd [at] LT', + lastDay: '[Yesterday at] LT', + lastWeek: '[Last] dddd [at] LT', + sameElse: 'L', + }; + + function calendar(key, mom, now) { + var output = this._calendar[key] || this._calendar['sameElse']; + return isFunction(output) ? output.call(mom, now) : output; + } + + function zeroFill(number, targetLength, forceSign) { + var absNumber = '' + Math.abs(number), + zerosToFill = targetLength - absNumber.length, + sign = number >= 0; + return ( + (sign ? (forceSign ? '+' : '') : '-') + + Math.pow(10, Math.max(0, zerosToFill)).toString().substr(1) + + absNumber + ); + } + + var formattingTokens = + /(\[[^\[]*\])|(\\)?([Hh]mm(ss)?|Mo|MM?M?M?|Do|DDDo|DD?D?D?|ddd?d?|do?|w[o|w]?|W[o|W]?|Qo?|N{1,5}|YYYYYY|YYYYY|YYYY|YY|y{2,4}|yo?|gg(ggg?)?|GG(GGG?)?|e|E|a|A|hh?|HH?|kk?|mm?|ss?|S{1,9}|x|X|zz?|ZZ?|.)/g, + localFormattingTokens = /(\[[^\[]*\])|(\\)?(LTS|LT|LL?L?L?|l{1,4})/g, + formatFunctions = {}, + formatTokenFunctions = {}; + + // token: 'M' + // padded: ['MM', 2] + // ordinal: 'Mo' + // callback: function () { this.month() + 1 } + function addFormatToken(token, padded, ordinal, callback) { + var func = callback; + if (typeof callback === 'string') { + func = function () { + return this[callback](); + }; + } + if (token) { + formatTokenFunctions[token] = func; + } + if (padded) { + formatTokenFunctions[padded[0]] = function () { + return zeroFill(func.apply(this, arguments), padded[1], padded[2]); + }; + } + if (ordinal) { + formatTokenFunctions[ordinal] = function () { + return this.localeData().ordinal( + func.apply(this, arguments), + token + ); + }; + } + } + + function removeFormattingTokens(input) { + if (input.match(/\[[\s\S]/)) { + return input.replace(/^\[|\]$/g, ''); + } + return input.replace(/\\/g, ''); + } + + function makeFormatFunction(format) { + var array = format.match(formattingTokens), + i, + length; + + for (i = 0, length = array.length; i < length; i++) { + if (formatTokenFunctions[array[i]]) { + array[i] = formatTokenFunctions[array[i]]; + } else { + array[i] = removeFormattingTokens(array[i]); + } + } + + return function (mom) { + var output = '', + i; + for (i = 0; i < length; i++) { + output += isFunction(array[i]) + ? array[i].call(mom, format) + : array[i]; + } + return output; + }; + } + + // format date using native date object + function formatMoment(m, format) { + if (!m.isValid()) { + return m.localeData().invalidDate(); + } + + format = expandFormat(format, m.localeData()); + formatFunctions[format] = + formatFunctions[format] || makeFormatFunction(format); + + return formatFunctions[format](m); + } + + function expandFormat(format, locale) { + var i = 5; + + function replaceLongDateFormatTokens(input) { + return locale.longDateFormat(input) || input; + } + + localFormattingTokens.lastIndex = 0; + while (i >= 0 && localFormattingTokens.test(format)) { + format = format.replace( + localFormattingTokens, + replaceLongDateFormatTokens + ); + localFormattingTokens.lastIndex = 0; + i -= 1; + } + + return format; + } + + var defaultLongDateFormat = { + LTS: 'h:mm:ss A', + LT: 'h:mm A', + L: 'MM/DD/YYYY', + LL: 'MMMM D, YYYY', + LLL: 'MMMM D, YYYY h:mm A', + LLLL: 'dddd, MMMM D, YYYY h:mm A', + }; + + function longDateFormat(key) { + var format = this._longDateFormat[key], + formatUpper = this._longDateFormat[key.toUpperCase()]; + + if (format || !formatUpper) { + return format; + } + + this._longDateFormat[key] = formatUpper + .match(formattingTokens) + .map(function (tok) { + if ( + tok === 'MMMM' || + tok === 'MM' || + tok === 'DD' || + tok === 'dddd' + ) { + return tok.slice(1); + } + return tok; + }) + .join(''); + + return this._longDateFormat[key]; + } + + var defaultInvalidDate = 'Invalid date'; + + function invalidDate() { + return this._invalidDate; + } + + var defaultOrdinal = '%d', + defaultDayOfMonthOrdinalParse = /\d{1,2}/; + + function ordinal(number) { + return this._ordinal.replace('%d', number); + } + + var defaultRelativeTime = { + future: 'in %s', + past: '%s ago', + s: 'a few seconds', + ss: '%d seconds', + m: 'a minute', + mm: '%d minutes', + h: 'an hour', + hh: '%d hours', + d: 'a day', + dd: '%d days', + w: 'a week', + ww: '%d weeks', + M: 'a month', + MM: '%d months', + y: 'a year', + yy: '%d years', + }; + + function relativeTime(number, withoutSuffix, string, isFuture) { + var output = this._relativeTime[string]; + return isFunction(output) + ? output(number, withoutSuffix, string, isFuture) + : output.replace(/%d/i, number); + } + + function pastFuture(diff, output) { + var format = this._relativeTime[diff > 0 ? 'future' : 'past']; + return isFunction(format) ? format(output) : format.replace(/%s/i, output); + } + + var aliases = {}; + + function addUnitAlias(unit, shorthand) { + var lowerCase = unit.toLowerCase(); + aliases[lowerCase] = aliases[lowerCase + 's'] = aliases[shorthand] = unit; + } + + function normalizeUnits(units) { + return typeof units === 'string' + ? aliases[units] || aliases[units.toLowerCase()] + : undefined; + } + + function normalizeObjectUnits(inputObject) { + var normalizedInput = {}, + normalizedProp, + prop; + + for (prop in inputObject) { + if (hasOwnProp(inputObject, prop)) { + normalizedProp = normalizeUnits(prop); + if (normalizedProp) { + normalizedInput[normalizedProp] = inputObject[prop]; + } + } + } + + return normalizedInput; + } + + var priorities = {}; + + function addUnitPriority(unit, priority) { + priorities[unit] = priority; + } + + function getPrioritizedUnits(unitsObj) { + var units = [], + u; + for (u in unitsObj) { + if (hasOwnProp(unitsObj, u)) { + units.push({ unit: u, priority: priorities[u] }); + } + } + units.sort(function (a, b) { + return a.priority - b.priority; + }); + return units; + } + + function isLeapYear(year) { + return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0; + } + + function absFloor(number) { + if (number < 0) { + // -0 -> 0 + return Math.ceil(number) || 0; + } else { + return Math.floor(number); + } + } + + function toInt(argumentForCoercion) { + var coercedNumber = +argumentForCoercion, + value = 0; + + if (coercedNumber !== 0 && isFinite(coercedNumber)) { + value = absFloor(coercedNumber); + } + + return value; + } + + function makeGetSet(unit, keepTime) { + return function (value) { + if (value != null) { + set$1(this, unit, value); + hooks.updateOffset(this, keepTime); + return this; + } else { + return get(this, unit); + } + }; + } + + function get(mom, unit) { + return mom.isValid() + ? mom._d['get' + (mom._isUTC ? 'UTC' : '') + unit]() + : NaN; + } + + function set$1(mom, unit, value) { + if (mom.isValid() && !isNaN(value)) { + if ( + unit === 'FullYear' && + isLeapYear(mom.year()) && + mom.month() === 1 && + mom.date() === 29 + ) { + value = toInt(value); + mom._d['set' + (mom._isUTC ? 'UTC' : '') + unit]( + value, + mom.month(), + daysInMonth(value, mom.month()) + ); + } else { + mom._d['set' + (mom._isUTC ? 'UTC' : '') + unit](value); + } + } + } + + // MOMENTS + + function stringGet(units) { + units = normalizeUnits(units); + if (isFunction(this[units])) { + return this[units](); + } + return this; + } + + function stringSet(units, value) { + if (typeof units === 'object') { + units = normalizeObjectUnits(units); + var prioritized = getPrioritizedUnits(units), + i, + prioritizedLen = prioritized.length; + for (i = 0; i < prioritizedLen; i++) { + this[prioritized[i].unit](units[prioritized[i].unit]); + } + } else { + units = normalizeUnits(units); + if (isFunction(this[units])) { + return this[units](value); + } + } + return this; + } + + var match1 = /\d/, // 0 - 9 + match2 = /\d\d/, // 00 - 99 + match3 = /\d{3}/, // 000 - 999 + match4 = /\d{4}/, // 0000 - 9999 + match6 = /[+-]?\d{6}/, // -999999 - 999999 + match1to2 = /\d\d?/, // 0 - 99 + match3to4 = /\d\d\d\d?/, // 999 - 9999 + match5to6 = /\d\d\d\d\d\d?/, // 99999 - 999999 + match1to3 = /\d{1,3}/, // 0 - 999 + match1to4 = /\d{1,4}/, // 0 - 9999 + match1to6 = /[+-]?\d{1,6}/, // -999999 - 999999 + matchUnsigned = /\d+/, // 0 - inf + matchSigned = /[+-]?\d+/, // -inf - inf + matchOffset = /Z|[+-]\d\d:?\d\d/gi, // +00:00 -00:00 +0000 -0000 or Z + matchShortOffset = /Z|[+-]\d\d(?::?\d\d)?/gi, // +00 -00 +00:00 -00:00 +0000 -0000 or Z + matchTimestamp = /[+-]?\d+(\.\d{1,3})?/, // 123456789 123456789.123 + // any word (or two) characters or numbers including two/three word month in arabic. + // includes scottish gaelic two word and hyphenated months + matchWord = + /[0-9]{0,256}['a-z\u00A0-\u05FF\u0700-\uD7FF\uF900-\uFDCF\uFDF0-\uFF07\uFF10-\uFFEF]{1,256}|[\u0600-\u06FF\/]{1,256}(\s*?[\u0600-\u06FF]{1,256}){1,2}/i, + regexes; + + regexes = {}; + + function addRegexToken(token, regex, strictRegex) { + regexes[token] = isFunction(regex) + ? regex + : function (isStrict, localeData) { + return isStrict && strictRegex ? strictRegex : regex; + }; + } + + function getParseRegexForToken(token, config) { + if (!hasOwnProp(regexes, token)) { + return new RegExp(unescapeFormat(token)); + } + + return regexes[token](config._strict, config._locale); + } + + // Code from http://stackoverflow.com/questions/3561493/is-there-a-regexp-escape-function-in-javascript + function unescapeFormat(s) { + return regexEscape( + s + .replace('\\', '') + .replace( + /\\(\[)|\\(\])|\[([^\]\[]*)\]|\\(.)/g, + function (matched, p1, p2, p3, p4) { + return p1 || p2 || p3 || p4; + } + ) + ); + } + + function regexEscape(s) { + return s.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); + } + + var tokens = {}; + + function addParseToken(token, callback) { + var i, + func = callback, + tokenLen; + if (typeof token === 'string') { + token = [token]; + } + if (isNumber(callback)) { + func = function (input, array) { + array[callback] = toInt(input); + }; + } + tokenLen = token.length; + for (i = 0; i < tokenLen; i++) { + tokens[token[i]] = func; + } + } + + function addWeekParseToken(token, callback) { + addParseToken(token, function (input, array, config, token) { + config._w = config._w || {}; + callback(input, config._w, config, token); + }); + } + + function addTimeToArrayFromToken(token, input, config) { + if (input != null && hasOwnProp(tokens, token)) { + tokens[token](input, config._a, config, token); + } + } + + var YEAR = 0, + MONTH = 1, + DATE = 2, + HOUR = 3, + MINUTE = 4, + SECOND = 5, + MILLISECOND = 6, + WEEK = 7, + WEEKDAY = 8; + + function mod(n, x) { + return ((n % x) + x) % x; + } + + var indexOf; + + if (Array.prototype.indexOf) { + indexOf = Array.prototype.indexOf; + } else { + indexOf = function (o) { + // I know + var i; + for (i = 0; i < this.length; ++i) { + if (this[i] === o) { + return i; + } + } + return -1; + }; + } + + function daysInMonth(year, month) { + if (isNaN(year) || isNaN(month)) { + return NaN; + } + var modMonth = mod(month, 12); + year += (month - modMonth) / 12; + return modMonth === 1 + ? isLeapYear(year) + ? 29 + : 28 + : 31 - ((modMonth % 7) % 2); + } + + // FORMATTING + + addFormatToken('M', ['MM', 2], 'Mo', function () { + return this.month() + 1; + }); + + addFormatToken('MMM', 0, 0, function (format) { + return this.localeData().monthsShort(this, format); + }); + + addFormatToken('MMMM', 0, 0, function (format) { + return this.localeData().months(this, format); + }); + + // ALIASES + + addUnitAlias('month', 'M'); + + // PRIORITY + + addUnitPriority('month', 8); + + // PARSING + + addRegexToken('M', match1to2); + addRegexToken('MM', match1to2, match2); + addRegexToken('MMM', function (isStrict, locale) { + return locale.monthsShortRegex(isStrict); + }); + addRegexToken('MMMM', function (isStrict, locale) { + return locale.monthsRegex(isStrict); + }); + + addParseToken(['M', 'MM'], function (input, array) { + array[MONTH] = toInt(input) - 1; + }); + + addParseToken(['MMM', 'MMMM'], function (input, array, config, token) { + var month = config._locale.monthsParse(input, token, config._strict); + // if we didn't find a month name, mark the date as invalid. + if (month != null) { + array[MONTH] = month; + } else { + getParsingFlags(config).invalidMonth = input; + } + }); + + // LOCALES + + var defaultLocaleMonths = + 'January_February_March_April_May_June_July_August_September_October_November_December'.split( + '_' + ), + defaultLocaleMonthsShort = + 'Jan_Feb_Mar_Apr_May_Jun_Jul_Aug_Sep_Oct_Nov_Dec'.split('_'), + MONTHS_IN_FORMAT = /D[oD]?(\[[^\[\]]*\]|\s)+MMMM?/, + defaultMonthsShortRegex = matchWord, + defaultMonthsRegex = matchWord; + + function localeMonths(m, format) { + if (!m) { + return isArray(this._months) + ? this._months + : this._months['standalone']; + } + return isArray(this._months) + ? this._months[m.month()] + : this._months[ + (this._months.isFormat || MONTHS_IN_FORMAT).test(format) + ? 'format' + : 'standalone' + ][m.month()]; + } + + function localeMonthsShort(m, format) { + if (!m) { + return isArray(this._monthsShort) + ? this._monthsShort + : this._monthsShort['standalone']; + } + return isArray(this._monthsShort) + ? this._monthsShort[m.month()] + : this._monthsShort[ + MONTHS_IN_FORMAT.test(format) ? 'format' : 'standalone' + ][m.month()]; + } + + function handleStrictParse(monthName, format, strict) { + var i, + ii, + mom, + llc = monthName.toLocaleLowerCase(); + if (!this._monthsParse) { + // this is not used + this._monthsParse = []; + this._longMonthsParse = []; + this._shortMonthsParse = []; + for (i = 0; i < 12; ++i) { + mom = createUTC([2000, i]); + this._shortMonthsParse[i] = this.monthsShort( + mom, + '' + ).toLocaleLowerCase(); + this._longMonthsParse[i] = this.months(mom, '').toLocaleLowerCase(); + } + } + + if (strict) { + if (format === 'MMM') { + ii = indexOf.call(this._shortMonthsParse, llc); + return ii !== -1 ? ii : null; + } else { + ii = indexOf.call(this._longMonthsParse, llc); + return ii !== -1 ? ii : null; + } + } else { + if (format === 'MMM') { + ii = indexOf.call(this._shortMonthsParse, llc); + if (ii !== -1) { + return ii; + } + ii = indexOf.call(this._longMonthsParse, llc); + return ii !== -1 ? ii : null; + } else { + ii = indexOf.call(this._longMonthsParse, llc); + if (ii !== -1) { + return ii; + } + ii = indexOf.call(this._shortMonthsParse, llc); + return ii !== -1 ? ii : null; + } + } + } + + function localeMonthsParse(monthName, format, strict) { + var i, mom, regex; + + if (this._monthsParseExact) { + return handleStrictParse.call(this, monthName, format, strict); + } + + if (!this._monthsParse) { + this._monthsParse = []; + this._longMonthsParse = []; + this._shortMonthsParse = []; + } + + // TODO: add sorting + // Sorting makes sure if one month (or abbr) is a prefix of another + // see sorting in computeMonthsParse + for (i = 0; i < 12; i++) { + // make the regex if we don't have it already + mom = createUTC([2000, i]); + if (strict && !this._longMonthsParse[i]) { + this._longMonthsParse[i] = new RegExp( + '^' + this.months(mom, '').replace('.', '') + '$', + 'i' + ); + this._shortMonthsParse[i] = new RegExp( + '^' + this.monthsShort(mom, '').replace('.', '') + '$', + 'i' + ); + } + if (!strict && !this._monthsParse[i]) { + regex = + '^' + this.months(mom, '') + '|^' + this.monthsShort(mom, ''); + this._monthsParse[i] = new RegExp(regex.replace('.', ''), 'i'); + } + // test the regex + if ( + strict && + format === 'MMMM' && + this._longMonthsParse[i].test(monthName) + ) { + return i; + } else if ( + strict && + format === 'MMM' && + this._shortMonthsParse[i].test(monthName) + ) { + return i; + } else if (!strict && this._monthsParse[i].test(monthName)) { + return i; + } + } + } + + // MOMENTS + + function setMonth(mom, value) { + var dayOfMonth; + + if (!mom.isValid()) { + // No op + return mom; + } + + if (typeof value === 'string') { + if (/^\d+$/.test(value)) { + value = toInt(value); + } else { + value = mom.localeData().monthsParse(value); + // TODO: Another silent failure? + if (!isNumber(value)) { + return mom; + } + } + } + + dayOfMonth = Math.min(mom.date(), daysInMonth(mom.year(), value)); + mom._d['set' + (mom._isUTC ? 'UTC' : '') + 'Month'](value, dayOfMonth); + return mom; + } + + function getSetMonth(value) { + if (value != null) { + setMonth(this, value); + hooks.updateOffset(this, true); + return this; + } else { + return get(this, 'Month'); + } + } + + function getDaysInMonth() { + return daysInMonth(this.year(), this.month()); + } + + function monthsShortRegex(isStrict) { + if (this._monthsParseExact) { + if (!hasOwnProp(this, '_monthsRegex')) { + computeMonthsParse.call(this); + } + if (isStrict) { + return this._monthsShortStrictRegex; + } else { + return this._monthsShortRegex; + } + } else { + if (!hasOwnProp(this, '_monthsShortRegex')) { + this._monthsShortRegex = defaultMonthsShortRegex; + } + return this._monthsShortStrictRegex && isStrict + ? this._monthsShortStrictRegex + : this._monthsShortRegex; + } + } + + function monthsRegex(isStrict) { + if (this._monthsParseExact) { + if (!hasOwnProp(this, '_monthsRegex')) { + computeMonthsParse.call(this); + } + if (isStrict) { + return this._monthsStrictRegex; + } else { + return this._monthsRegex; + } + } else { + if (!hasOwnProp(this, '_monthsRegex')) { + this._monthsRegex = defaultMonthsRegex; + } + return this._monthsStrictRegex && isStrict + ? this._monthsStrictRegex + : this._monthsRegex; + } + } + + function computeMonthsParse() { + function cmpLenRev(a, b) { + return b.length - a.length; + } + + var shortPieces = [], + longPieces = [], + mixedPieces = [], + i, + mom; + for (i = 0; i < 12; i++) { + // make the regex if we don't have it already + mom = createUTC([2000, i]); + shortPieces.push(this.monthsShort(mom, '')); + longPieces.push(this.months(mom, '')); + mixedPieces.push(this.months(mom, '')); + mixedPieces.push(this.monthsShort(mom, '')); + } + // Sorting makes sure if one month (or abbr) is a prefix of another it + // will match the longer piece. + shortPieces.sort(cmpLenRev); + longPieces.sort(cmpLenRev); + mixedPieces.sort(cmpLenRev); + for (i = 0; i < 12; i++) { + shortPieces[i] = regexEscape(shortPieces[i]); + longPieces[i] = regexEscape(longPieces[i]); + } + for (i = 0; i < 24; i++) { + mixedPieces[i] = regexEscape(mixedPieces[i]); + } + + this._monthsRegex = new RegExp('^(' + mixedPieces.join('|') + ')', 'i'); + this._monthsShortRegex = this._monthsRegex; + this._monthsStrictRegex = new RegExp( + '^(' + longPieces.join('|') + ')', + 'i' + ); + this._monthsShortStrictRegex = new RegExp( + '^(' + shortPieces.join('|') + ')', + 'i' + ); + } + + // FORMATTING + + addFormatToken('Y', 0, 0, function () { + var y = this.year(); + return y <= 9999 ? zeroFill(y, 4) : '+' + y; + }); + + addFormatToken(0, ['YY', 2], 0, function () { + return this.year() % 100; + }); + + addFormatToken(0, ['YYYY', 4], 0, 'year'); + addFormatToken(0, ['YYYYY', 5], 0, 'year'); + addFormatToken(0, ['YYYYYY', 6, true], 0, 'year'); + + // ALIASES + + addUnitAlias('year', 'y'); + + // PRIORITIES + + addUnitPriority('year', 1); + + // PARSING + + addRegexToken('Y', matchSigned); + addRegexToken('YY', match1to2, match2); + addRegexToken('YYYY', match1to4, match4); + addRegexToken('YYYYY', match1to6, match6); + addRegexToken('YYYYYY', match1to6, match6); + + addParseToken(['YYYYY', 'YYYYYY'], YEAR); + addParseToken('YYYY', function (input, array) { + array[YEAR] = + input.length === 2 ? hooks.parseTwoDigitYear(input) : toInt(input); + }); + addParseToken('YY', function (input, array) { + array[YEAR] = hooks.parseTwoDigitYear(input); + }); + addParseToken('Y', function (input, array) { + array[YEAR] = parseInt(input, 10); + }); + + // HELPERS + + function daysInYear(year) { + return isLeapYear(year) ? 366 : 365; + } + + // HOOKS + + hooks.parseTwoDigitYear = function (input) { + return toInt(input) + (toInt(input) > 68 ? 1900 : 2000); + }; + + // MOMENTS + + var getSetYear = makeGetSet('FullYear', true); + + function getIsLeapYear() { + return isLeapYear(this.year()); + } + + function createDate(y, m, d, h, M, s, ms) { + // can't just apply() to create a date: + // https://stackoverflow.com/q/181348 + var date; + // the date constructor remaps years 0-99 to 1900-1999 + if (y < 100 && y >= 0) { + // preserve leap years using a full 400 year cycle, then reset + date = new Date(y + 400, m, d, h, M, s, ms); + if (isFinite(date.getFullYear())) { + date.setFullYear(y); + } + } else { + date = new Date(y, m, d, h, M, s, ms); + } + + return date; + } + + function createUTCDate(y) { + var date, args; + // the Date.UTC function remaps years 0-99 to 1900-1999 + if (y < 100 && y >= 0) { + args = Array.prototype.slice.call(arguments); + // preserve leap years using a full 400 year cycle, then reset + args[0] = y + 400; + date = new Date(Date.UTC.apply(null, args)); + if (isFinite(date.getUTCFullYear())) { + date.setUTCFullYear(y); + } + } else { + date = new Date(Date.UTC.apply(null, arguments)); + } + + return date; + } + + // start-of-first-week - start-of-year + function firstWeekOffset(year, dow, doy) { + var // first-week day -- which january is always in the first week (4 for iso, 1 for other) + fwd = 7 + dow - doy, + // first-week day local weekday -- which local weekday is fwd + fwdlw = (7 + createUTCDate(year, 0, fwd).getUTCDay() - dow) % 7; + + return -fwdlw + fwd - 1; + } + + // https://en.wikipedia.org/wiki/ISO_week_date#Calculating_a_date_given_the_year.2C_week_number_and_weekday + function dayOfYearFromWeeks(year, week, weekday, dow, doy) { + var localWeekday = (7 + weekday - dow) % 7, + weekOffset = firstWeekOffset(year, dow, doy), + dayOfYear = 1 + 7 * (week - 1) + localWeekday + weekOffset, + resYear, + resDayOfYear; + + if (dayOfYear <= 0) { + resYear = year - 1; + resDayOfYear = daysInYear(resYear) + dayOfYear; + } else if (dayOfYear > daysInYear(year)) { + resYear = year + 1; + resDayOfYear = dayOfYear - daysInYear(year); + } else { + resYear = year; + resDayOfYear = dayOfYear; + } + + return { + year: resYear, + dayOfYear: resDayOfYear, + }; + } + + function weekOfYear(mom, dow, doy) { + var weekOffset = firstWeekOffset(mom.year(), dow, doy), + week = Math.floor((mom.dayOfYear() - weekOffset - 1) / 7) + 1, + resWeek, + resYear; + + if (week < 1) { + resYear = mom.year() - 1; + resWeek = week + weeksInYear(resYear, dow, doy); + } else if (week > weeksInYear(mom.year(), dow, doy)) { + resWeek = week - weeksInYear(mom.year(), dow, doy); + resYear = mom.year() + 1; + } else { + resYear = mom.year(); + resWeek = week; + } + + return { + week: resWeek, + year: resYear, + }; + } + + function weeksInYear(year, dow, doy) { + var weekOffset = firstWeekOffset(year, dow, doy), + weekOffsetNext = firstWeekOffset(year + 1, dow, doy); + return (daysInYear(year) - weekOffset + weekOffsetNext) / 7; + } + + // FORMATTING + + addFormatToken('w', ['ww', 2], 'wo', 'week'); + addFormatToken('W', ['WW', 2], 'Wo', 'isoWeek'); + + // ALIASES + + addUnitAlias('week', 'w'); + addUnitAlias('isoWeek', 'W'); + + // PRIORITIES + + addUnitPriority('week', 5); + addUnitPriority('isoWeek', 5); + + // PARSING + + addRegexToken('w', match1to2); + addRegexToken('ww', match1to2, match2); + addRegexToken('W', match1to2); + addRegexToken('WW', match1to2, match2); + + addWeekParseToken( + ['w', 'ww', 'W', 'WW'], + function (input, week, config, token) { + week[token.substr(0, 1)] = toInt(input); + } + ); + + // HELPERS + + // LOCALES + + function localeWeek(mom) { + return weekOfYear(mom, this._week.dow, this._week.doy).week; + } + + var defaultLocaleWeek = { + dow: 0, // Sunday is the first day of the week. + doy: 6, // The week that contains Jan 6th is the first week of the year. + }; + + function localeFirstDayOfWeek() { + return this._week.dow; + } + + function localeFirstDayOfYear() { + return this._week.doy; + } + + // MOMENTS + + function getSetWeek(input) { + var week = this.localeData().week(this); + return input == null ? week : this.add((input - week) * 7, 'd'); + } + + function getSetISOWeek(input) { + var week = weekOfYear(this, 1, 4).week; + return input == null ? week : this.add((input - week) * 7, 'd'); + } + + // FORMATTING + + addFormatToken('d', 0, 'do', 'day'); + + addFormatToken('dd', 0, 0, function (format) { + return this.localeData().weekdaysMin(this, format); + }); + + addFormatToken('ddd', 0, 0, function (format) { + return this.localeData().weekdaysShort(this, format); + }); + + addFormatToken('dddd', 0, 0, function (format) { + return this.localeData().weekdays(this, format); + }); + + addFormatToken('e', 0, 0, 'weekday'); + addFormatToken('E', 0, 0, 'isoWeekday'); + + // ALIASES + + addUnitAlias('day', 'd'); + addUnitAlias('weekday', 'e'); + addUnitAlias('isoWeekday', 'E'); + + // PRIORITY + addUnitPriority('day', 11); + addUnitPriority('weekday', 11); + addUnitPriority('isoWeekday', 11); + + // PARSING + + addRegexToken('d', match1to2); + addRegexToken('e', match1to2); + addRegexToken('E', match1to2); + addRegexToken('dd', function (isStrict, locale) { + return locale.weekdaysMinRegex(isStrict); + }); + addRegexToken('ddd', function (isStrict, locale) { + return locale.weekdaysShortRegex(isStrict); + }); + addRegexToken('dddd', function (isStrict, locale) { + return locale.weekdaysRegex(isStrict); + }); + + addWeekParseToken(['dd', 'ddd', 'dddd'], function (input, week, config, token) { + var weekday = config._locale.weekdaysParse(input, token, config._strict); + // if we didn't get a weekday name, mark the date as invalid + if (weekday != null) { + week.d = weekday; + } else { + getParsingFlags(config).invalidWeekday = input; + } + }); + + addWeekParseToken(['d', 'e', 'E'], function (input, week, config, token) { + week[token] = toInt(input); + }); + + // HELPERS + + function parseWeekday(input, locale) { + if (typeof input !== 'string') { + return input; + } + + if (!isNaN(input)) { + return parseInt(input, 10); + } + + input = locale.weekdaysParse(input); + if (typeof input === 'number') { + return input; + } + + return null; + } + + function parseIsoWeekday(input, locale) { + if (typeof input === 'string') { + return locale.weekdaysParse(input) % 7 || 7; + } + return isNaN(input) ? null : input; + } + + // LOCALES + function shiftWeekdays(ws, n) { + return ws.slice(n, 7).concat(ws.slice(0, n)); + } + + var defaultLocaleWeekdays = + 'Sunday_Monday_Tuesday_Wednesday_Thursday_Friday_Saturday'.split('_'), + defaultLocaleWeekdaysShort = 'Sun_Mon_Tue_Wed_Thu_Fri_Sat'.split('_'), + defaultLocaleWeekdaysMin = 'Su_Mo_Tu_We_Th_Fr_Sa'.split('_'), + defaultWeekdaysRegex = matchWord, + defaultWeekdaysShortRegex = matchWord, + defaultWeekdaysMinRegex = matchWord; + + function localeWeekdays(m, format) { + var weekdays = isArray(this._weekdays) + ? this._weekdays + : this._weekdays[ + m && m !== true && this._weekdays.isFormat.test(format) + ? 'format' + : 'standalone' + ]; + return m === true + ? shiftWeekdays(weekdays, this._week.dow) + : m + ? weekdays[m.day()] + : weekdays; + } + + function localeWeekdaysShort(m) { + return m === true + ? shiftWeekdays(this._weekdaysShort, this._week.dow) + : m + ? this._weekdaysShort[m.day()] + : this._weekdaysShort; + } + + function localeWeekdaysMin(m) { + return m === true + ? shiftWeekdays(this._weekdaysMin, this._week.dow) + : m + ? this._weekdaysMin[m.day()] + : this._weekdaysMin; + } + + function handleStrictParse$1(weekdayName, format, strict) { + var i, + ii, + mom, + llc = weekdayName.toLocaleLowerCase(); + if (!this._weekdaysParse) { + this._weekdaysParse = []; + this._shortWeekdaysParse = []; + this._minWeekdaysParse = []; + + for (i = 0; i < 7; ++i) { + mom = createUTC([2000, 1]).day(i); + this._minWeekdaysParse[i] = this.weekdaysMin( + mom, + '' + ).toLocaleLowerCase(); + this._shortWeekdaysParse[i] = this.weekdaysShort( + mom, + '' + ).toLocaleLowerCase(); + this._weekdaysParse[i] = this.weekdays(mom, '').toLocaleLowerCase(); + } + } + + if (strict) { + if (format === 'dddd') { + ii = indexOf.call(this._weekdaysParse, llc); + return ii !== -1 ? ii : null; + } else if (format === 'ddd') { + ii = indexOf.call(this._shortWeekdaysParse, llc); + return ii !== -1 ? ii : null; + } else { + ii = indexOf.call(this._minWeekdaysParse, llc); + return ii !== -1 ? ii : null; + } + } else { + if (format === 'dddd') { + ii = indexOf.call(this._weekdaysParse, llc); + if (ii !== -1) { + return ii; + } + ii = indexOf.call(this._shortWeekdaysParse, llc); + if (ii !== -1) { + return ii; + } + ii = indexOf.call(this._minWeekdaysParse, llc); + return ii !== -1 ? ii : null; + } else if (format === 'ddd') { + ii = indexOf.call(this._shortWeekdaysParse, llc); + if (ii !== -1) { + return ii; + } + ii = indexOf.call(this._weekdaysParse, llc); + if (ii !== -1) { + return ii; + } + ii = indexOf.call(this._minWeekdaysParse, llc); + return ii !== -1 ? ii : null; + } else { + ii = indexOf.call(this._minWeekdaysParse, llc); + if (ii !== -1) { + return ii; + } + ii = indexOf.call(this._weekdaysParse, llc); + if (ii !== -1) { + return ii; + } + ii = indexOf.call(this._shortWeekdaysParse, llc); + return ii !== -1 ? ii : null; + } + } + } + + function localeWeekdaysParse(weekdayName, format, strict) { + var i, mom, regex; + + if (this._weekdaysParseExact) { + return handleStrictParse$1.call(this, weekdayName, format, strict); + } + + if (!this._weekdaysParse) { + this._weekdaysParse = []; + this._minWeekdaysParse = []; + this._shortWeekdaysParse = []; + this._fullWeekdaysParse = []; + } + + for (i = 0; i < 7; i++) { + // make the regex if we don't have it already + + mom = createUTC([2000, 1]).day(i); + if (strict && !this._fullWeekdaysParse[i]) { + this._fullWeekdaysParse[i] = new RegExp( + '^' + this.weekdays(mom, '').replace('.', '\\.?') + '$', + 'i' + ); + this._shortWeekdaysParse[i] = new RegExp( + '^' + this.weekdaysShort(mom, '').replace('.', '\\.?') + '$', + 'i' + ); + this._minWeekdaysParse[i] = new RegExp( + '^' + this.weekdaysMin(mom, '').replace('.', '\\.?') + '$', + 'i' + ); + } + if (!this._weekdaysParse[i]) { + regex = + '^' + + this.weekdays(mom, '') + + '|^' + + this.weekdaysShort(mom, '') + + '|^' + + this.weekdaysMin(mom, ''); + this._weekdaysParse[i] = new RegExp(regex.replace('.', ''), 'i'); + } + // test the regex + if ( + strict && + format === 'dddd' && + this._fullWeekdaysParse[i].test(weekdayName) + ) { + return i; + } else if ( + strict && + format === 'ddd' && + this._shortWeekdaysParse[i].test(weekdayName) + ) { + return i; + } else if ( + strict && + format === 'dd' && + this._minWeekdaysParse[i].test(weekdayName) + ) { + return i; + } else if (!strict && this._weekdaysParse[i].test(weekdayName)) { + return i; + } + } + } + + // MOMENTS + + function getSetDayOfWeek(input) { + if (!this.isValid()) { + return input != null ? this : NaN; + } + var day = this._isUTC ? this._d.getUTCDay() : this._d.getDay(); + if (input != null) { + input = parseWeekday(input, this.localeData()); + return this.add(input - day, 'd'); + } else { + return day; + } + } + + function getSetLocaleDayOfWeek(input) { + if (!this.isValid()) { + return input != null ? this : NaN; + } + var weekday = (this.day() + 7 - this.localeData()._week.dow) % 7; + return input == null ? weekday : this.add(input - weekday, 'd'); + } + + function getSetISODayOfWeek(input) { + if (!this.isValid()) { + return input != null ? this : NaN; + } + + // behaves the same as moment#day except + // as a getter, returns 7 instead of 0 (1-7 range instead of 0-6) + // as a setter, sunday should belong to the previous week. + + if (input != null) { + var weekday = parseIsoWeekday(input, this.localeData()); + return this.day(this.day() % 7 ? weekday : weekday - 7); + } else { + return this.day() || 7; + } + } + + function weekdaysRegex(isStrict) { + if (this._weekdaysParseExact) { + if (!hasOwnProp(this, '_weekdaysRegex')) { + computeWeekdaysParse.call(this); + } + if (isStrict) { + return this._weekdaysStrictRegex; + } else { + return this._weekdaysRegex; + } + } else { + if (!hasOwnProp(this, '_weekdaysRegex')) { + this._weekdaysRegex = defaultWeekdaysRegex; + } + return this._weekdaysStrictRegex && isStrict + ? this._weekdaysStrictRegex + : this._weekdaysRegex; + } + } + + function weekdaysShortRegex(isStrict) { + if (this._weekdaysParseExact) { + if (!hasOwnProp(this, '_weekdaysRegex')) { + computeWeekdaysParse.call(this); + } + if (isStrict) { + return this._weekdaysShortStrictRegex; + } else { + return this._weekdaysShortRegex; + } + } else { + if (!hasOwnProp(this, '_weekdaysShortRegex')) { + this._weekdaysShortRegex = defaultWeekdaysShortRegex; + } + return this._weekdaysShortStrictRegex && isStrict + ? this._weekdaysShortStrictRegex + : this._weekdaysShortRegex; + } + } + + function weekdaysMinRegex(isStrict) { + if (this._weekdaysParseExact) { + if (!hasOwnProp(this, '_weekdaysRegex')) { + computeWeekdaysParse.call(this); + } + if (isStrict) { + return this._weekdaysMinStrictRegex; + } else { + return this._weekdaysMinRegex; + } + } else { + if (!hasOwnProp(this, '_weekdaysMinRegex')) { + this._weekdaysMinRegex = defaultWeekdaysMinRegex; + } + return this._weekdaysMinStrictRegex && isStrict + ? this._weekdaysMinStrictRegex + : this._weekdaysMinRegex; + } + } + + function computeWeekdaysParse() { + function cmpLenRev(a, b) { + return b.length - a.length; + } + + var minPieces = [], + shortPieces = [], + longPieces = [], + mixedPieces = [], + i, + mom, + minp, + shortp, + longp; + for (i = 0; i < 7; i++) { + // make the regex if we don't have it already + mom = createUTC([2000, 1]).day(i); + minp = regexEscape(this.weekdaysMin(mom, '')); + shortp = regexEscape(this.weekdaysShort(mom, '')); + longp = regexEscape(this.weekdays(mom, '')); + minPieces.push(minp); + shortPieces.push(shortp); + longPieces.push(longp); + mixedPieces.push(minp); + mixedPieces.push(shortp); + mixedPieces.push(longp); + } + // Sorting makes sure if one weekday (or abbr) is a prefix of another it + // will match the longer piece. + minPieces.sort(cmpLenRev); + shortPieces.sort(cmpLenRev); + longPieces.sort(cmpLenRev); + mixedPieces.sort(cmpLenRev); + + this._weekdaysRegex = new RegExp('^(' + mixedPieces.join('|') + ')', 'i'); + this._weekdaysShortRegex = this._weekdaysRegex; + this._weekdaysMinRegex = this._weekdaysRegex; + + this._weekdaysStrictRegex = new RegExp( + '^(' + longPieces.join('|') + ')', + 'i' + ); + this._weekdaysShortStrictRegex = new RegExp( + '^(' + shortPieces.join('|') + ')', + 'i' + ); + this._weekdaysMinStrictRegex = new RegExp( + '^(' + minPieces.join('|') + ')', + 'i' + ); + } + + // FORMATTING + + function hFormat() { + return this.hours() % 12 || 12; + } + + function kFormat() { + return this.hours() || 24; + } + + addFormatToken('H', ['HH', 2], 0, 'hour'); + addFormatToken('h', ['hh', 2], 0, hFormat); + addFormatToken('k', ['kk', 2], 0, kFormat); + + addFormatToken('hmm', 0, 0, function () { + return '' + hFormat.apply(this) + zeroFill(this.minutes(), 2); + }); + + addFormatToken('hmmss', 0, 0, function () { + return ( + '' + + hFormat.apply(this) + + zeroFill(this.minutes(), 2) + + zeroFill(this.seconds(), 2) + ); + }); + + addFormatToken('Hmm', 0, 0, function () { + return '' + this.hours() + zeroFill(this.minutes(), 2); + }); + + addFormatToken('Hmmss', 0, 0, function () { + return ( + '' + + this.hours() + + zeroFill(this.minutes(), 2) + + zeroFill(this.seconds(), 2) + ); + }); + + function meridiem(token, lowercase) { + addFormatToken(token, 0, 0, function () { + return this.localeData().meridiem( + this.hours(), + this.minutes(), + lowercase + ); + }); + } + + meridiem('a', true); + meridiem('A', false); + + // ALIASES + + addUnitAlias('hour', 'h'); + + // PRIORITY + addUnitPriority('hour', 13); + + // PARSING + + function matchMeridiem(isStrict, locale) { + return locale._meridiemParse; + } + + addRegexToken('a', matchMeridiem); + addRegexToken('A', matchMeridiem); + addRegexToken('H', match1to2); + addRegexToken('h', match1to2); + addRegexToken('k', match1to2); + addRegexToken('HH', match1to2, match2); + addRegexToken('hh', match1to2, match2); + addRegexToken('kk', match1to2, match2); + + addRegexToken('hmm', match3to4); + addRegexToken('hmmss', match5to6); + addRegexToken('Hmm', match3to4); + addRegexToken('Hmmss', match5to6); + + addParseToken(['H', 'HH'], HOUR); + addParseToken(['k', 'kk'], function (input, array, config) { + var kInput = toInt(input); + array[HOUR] = kInput === 24 ? 0 : kInput; + }); + addParseToken(['a', 'A'], function (input, array, config) { + config._isPm = config._locale.isPM(input); + config._meridiem = input; + }); + addParseToken(['h', 'hh'], function (input, array, config) { + array[HOUR] = toInt(input); + getParsingFlags(config).bigHour = true; + }); + addParseToken('hmm', function (input, array, config) { + var pos = input.length - 2; + array[HOUR] = toInt(input.substr(0, pos)); + array[MINUTE] = toInt(input.substr(pos)); + getParsingFlags(config).bigHour = true; + }); + addParseToken('hmmss', function (input, array, config) { + var pos1 = input.length - 4, + pos2 = input.length - 2; + array[HOUR] = toInt(input.substr(0, pos1)); + array[MINUTE] = toInt(input.substr(pos1, 2)); + array[SECOND] = toInt(input.substr(pos2)); + getParsingFlags(config).bigHour = true; + }); + addParseToken('Hmm', function (input, array, config) { + var pos = input.length - 2; + array[HOUR] = toInt(input.substr(0, pos)); + array[MINUTE] = toInt(input.substr(pos)); + }); + addParseToken('Hmmss', function (input, array, config) { + var pos1 = input.length - 4, + pos2 = input.length - 2; + array[HOUR] = toInt(input.substr(0, pos1)); + array[MINUTE] = toInt(input.substr(pos1, 2)); + array[SECOND] = toInt(input.substr(pos2)); + }); + + // LOCALES + + function localeIsPM(input) { + // IE8 Quirks Mode & IE7 Standards Mode do not allow accessing strings like arrays + // Using charAt should be more compatible. + return (input + '').toLowerCase().charAt(0) === 'p'; + } + + var defaultLocaleMeridiemParse = /[ap]\.?m?\.?/i, + // Setting the hour should keep the time, because the user explicitly + // specified which hour they want. So trying to maintain the same hour (in + // a new timezone) makes sense. Adding/subtracting hours does not follow + // this rule. + getSetHour = makeGetSet('Hours', true); + + function localeMeridiem(hours, minutes, isLower) { + if (hours > 11) { + return isLower ? 'pm' : 'PM'; + } else { + return isLower ? 'am' : 'AM'; + } + } + + var baseConfig = { + calendar: defaultCalendar, + longDateFormat: defaultLongDateFormat, + invalidDate: defaultInvalidDate, + ordinal: defaultOrdinal, + dayOfMonthOrdinalParse: defaultDayOfMonthOrdinalParse, + relativeTime: defaultRelativeTime, + + months: defaultLocaleMonths, + monthsShort: defaultLocaleMonthsShort, + + week: defaultLocaleWeek, + + weekdays: defaultLocaleWeekdays, + weekdaysMin: defaultLocaleWeekdaysMin, + weekdaysShort: defaultLocaleWeekdaysShort, + + meridiemParse: defaultLocaleMeridiemParse, + }; + + // internal storage for locale config files + var locales = {}, + localeFamilies = {}, + globalLocale; + + function commonPrefix(arr1, arr2) { + var i, + minl = Math.min(arr1.length, arr2.length); + for (i = 0; i < minl; i += 1) { + if (arr1[i] !== arr2[i]) { + return i; + } + } + return minl; + } + + function normalizeLocale(key) { + return key ? key.toLowerCase().replace('_', '-') : key; + } + + // pick the locale from the array + // try ['en-au', 'en-gb'] as 'en-au', 'en-gb', 'en', as in move through the list trying each + // substring from most specific to least, but move to the next array item if it's a more specific variant than the current root + function chooseLocale(names) { + var i = 0, + j, + next, + locale, + split; + + while (i < names.length) { + split = normalizeLocale(names[i]).split('-'); + j = split.length; + next = normalizeLocale(names[i + 1]); + next = next ? next.split('-') : null; + while (j > 0) { + locale = loadLocale(split.slice(0, j).join('-')); + if (locale) { + return locale; + } + if ( + next && + next.length >= j && + commonPrefix(split, next) >= j - 1 + ) { + //the next array item is better than a shallower substring of this one + break; + } + j--; + } + i++; + } + return globalLocale; + } + + function isLocaleNameSane(name) { + // Prevent names that look like filesystem paths, i.e contain '/' or '\' + return name.match('^[^/\\\\]*$') != null; + } + + function loadLocale(name) { + var oldLocale = null, + aliasedRequire; + // TODO: Find a better way to register and load all the locales in Node + if ( + locales[name] === undefined && + typeof module !== 'undefined' && + module && + module.exports && + isLocaleNameSane(name) + ) { + try { + oldLocale = globalLocale._abbr; + aliasedRequire = require; + aliasedRequire('./locale/' + name); + getSetGlobalLocale(oldLocale); + } catch (e) { + // mark as not found to avoid repeating expensive file require call causing high CPU + // when trying to find en-US, en_US, en-us for every format call + locales[name] = null; // null means not found + } + } + return locales[name]; + } + + // This function will load locale and then set the global locale. If + // no arguments are passed in, it will simply return the current global + // locale key. + function getSetGlobalLocale(key, values) { + var data; + if (key) { + if (isUndefined(values)) { + data = getLocale(key); + } else { + data = defineLocale(key, values); + } + + if (data) { + // moment.duration._locale = moment._locale = data; + globalLocale = data; + } else { + if (typeof console !== 'undefined' && console.warn) { + //warn user if arguments are passed but the locale could not be set + console.warn( + 'Locale ' + key + ' not found. Did you forget to load it?' + ); + } + } + } + + return globalLocale._abbr; + } + + function defineLocale(name, config) { + if (config !== null) { + var locale, + parentConfig = baseConfig; + config.abbr = name; + if (locales[name] != null) { + deprecateSimple( + 'defineLocaleOverride', + 'use moment.updateLocale(localeName, config) to change ' + + 'an existing locale. moment.defineLocale(localeName, ' + + 'config) should only be used for creating a new locale ' + + 'See http://momentjs.com/guides/#/warnings/define-locale/ for more info.' + ); + parentConfig = locales[name]._config; + } else if (config.parentLocale != null) { + if (locales[config.parentLocale] != null) { + parentConfig = locales[config.parentLocale]._config; + } else { + locale = loadLocale(config.parentLocale); + if (locale != null) { + parentConfig = locale._config; + } else { + if (!localeFamilies[config.parentLocale]) { + localeFamilies[config.parentLocale] = []; + } + localeFamilies[config.parentLocale].push({ + name: name, + config: config, + }); + return null; + } + } + } + locales[name] = new Locale(mergeConfigs(parentConfig, config)); + + if (localeFamilies[name]) { + localeFamilies[name].forEach(function (x) { + defineLocale(x.name, x.config); + }); + } + + // backwards compat for now: also set the locale + // make sure we set the locale AFTER all child locales have been + // created, so we won't end up with the child locale set. + getSetGlobalLocale(name); + + return locales[name]; + } else { + // useful for testing + delete locales[name]; + return null; + } + } + + function updateLocale(name, config) { + if (config != null) { + var locale, + tmpLocale, + parentConfig = baseConfig; + + if (locales[name] != null && locales[name].parentLocale != null) { + // Update existing child locale in-place to avoid memory-leaks + locales[name].set(mergeConfigs(locales[name]._config, config)); + } else { + // MERGE + tmpLocale = loadLocale(name); + if (tmpLocale != null) { + parentConfig = tmpLocale._config; + } + config = mergeConfigs(parentConfig, config); + if (tmpLocale == null) { + // updateLocale is called for creating a new locale + // Set abbr so it will have a name (getters return + // undefined otherwise). + config.abbr = name; + } + locale = new Locale(config); + locale.parentLocale = locales[name]; + locales[name] = locale; + } + + // backwards compat for now: also set the locale + getSetGlobalLocale(name); + } else { + // pass null for config to unupdate, useful for tests + if (locales[name] != null) { + if (locales[name].parentLocale != null) { + locales[name] = locales[name].parentLocale; + if (name === getSetGlobalLocale()) { + getSetGlobalLocale(name); + } + } else if (locales[name] != null) { + delete locales[name]; + } + } + } + return locales[name]; + } + + // returns locale data + function getLocale(key) { + var locale; + + if (key && key._locale && key._locale._abbr) { + key = key._locale._abbr; + } + + if (!key) { + return globalLocale; + } + + if (!isArray(key)) { + //short-circuit everything else + locale = loadLocale(key); + if (locale) { + return locale; + } + key = [key]; + } + + return chooseLocale(key); + } + + function listLocales() { + return keys(locales); + } + + function checkOverflow(m) { + var overflow, + a = m._a; + + if (a && getParsingFlags(m).overflow === -2) { + overflow = + a[MONTH] < 0 || a[MONTH] > 11 + ? MONTH + : a[DATE] < 1 || a[DATE] > daysInMonth(a[YEAR], a[MONTH]) + ? DATE + : a[HOUR] < 0 || + a[HOUR] > 24 || + (a[HOUR] === 24 && + (a[MINUTE] !== 0 || + a[SECOND] !== 0 || + a[MILLISECOND] !== 0)) + ? HOUR + : a[MINUTE] < 0 || a[MINUTE] > 59 + ? MINUTE + : a[SECOND] < 0 || a[SECOND] > 59 + ? SECOND + : a[MILLISECOND] < 0 || a[MILLISECOND] > 999 + ? MILLISECOND + : -1; + + if ( + getParsingFlags(m)._overflowDayOfYear && + (overflow < YEAR || overflow > DATE) + ) { + overflow = DATE; + } + if (getParsingFlags(m)._overflowWeeks && overflow === -1) { + overflow = WEEK; + } + if (getParsingFlags(m)._overflowWeekday && overflow === -1) { + overflow = WEEKDAY; + } + + getParsingFlags(m).overflow = overflow; + } + + return m; + } + + // iso 8601 regex + // 0000-00-00 0000-W00 or 0000-W00-0 + T + 00 or 00:00 or 00:00:00 or 00:00:00.000 + +00:00 or +0000 or +00) + var extendedIsoRegex = + /^\s*((?:[+-]\d{6}|\d{4})-(?:\d\d-\d\d|W\d\d-\d|W\d\d|\d\d\d|\d\d))(?:(T| )(\d\d(?::\d\d(?::\d\d(?:[.,]\d+)?)?)?)([+-]\d\d(?::?\d\d)?|\s*Z)?)?$/, + basicIsoRegex = + /^\s*((?:[+-]\d{6}|\d{4})(?:\d\d\d\d|W\d\d\d|W\d\d|\d\d\d|\d\d|))(?:(T| )(\d\d(?:\d\d(?:\d\d(?:[.,]\d+)?)?)?)([+-]\d\d(?::?\d\d)?|\s*Z)?)?$/, + tzRegex = /Z|[+-]\d\d(?::?\d\d)?/, + isoDates = [ + ['YYYYYY-MM-DD', /[+-]\d{6}-\d\d-\d\d/], + ['YYYY-MM-DD', /\d{4}-\d\d-\d\d/], + ['GGGG-[W]WW-E', /\d{4}-W\d\d-\d/], + ['GGGG-[W]WW', /\d{4}-W\d\d/, false], + ['YYYY-DDD', /\d{4}-\d{3}/], + ['YYYY-MM', /\d{4}-\d\d/, false], + ['YYYYYYMMDD', /[+-]\d{10}/], + ['YYYYMMDD', /\d{8}/], + ['GGGG[W]WWE', /\d{4}W\d{3}/], + ['GGGG[W]WW', /\d{4}W\d{2}/, false], + ['YYYYDDD', /\d{7}/], + ['YYYYMM', /\d{6}/, false], + ['YYYY', /\d{4}/, false], + ], + // iso time formats and regexes + isoTimes = [ + ['HH:mm:ss.SSSS', /\d\d:\d\d:\d\d\.\d+/], + ['HH:mm:ss,SSSS', /\d\d:\d\d:\d\d,\d+/], + ['HH:mm:ss', /\d\d:\d\d:\d\d/], + ['HH:mm', /\d\d:\d\d/], + ['HHmmss.SSSS', /\d\d\d\d\d\d\.\d+/], + ['HHmmss,SSSS', /\d\d\d\d\d\d,\d+/], + ['HHmmss', /\d\d\d\d\d\d/], + ['HHmm', /\d\d\d\d/], + ['HH', /\d\d/], + ], + aspNetJsonRegex = /^\/?Date\((-?\d+)/i, + // RFC 2822 regex: For details see https://tools.ietf.org/html/rfc2822#section-3.3 + rfc2822 = + /^(?:(Mon|Tue|Wed|Thu|Fri|Sat|Sun),?\s)?(\d{1,2})\s(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s(\d{2,4})\s(\d\d):(\d\d)(?::(\d\d))?\s(?:(UT|GMT|[ECMP][SD]T)|([Zz])|([+-]\d{4}))$/, + obsOffsets = { + UT: 0, + GMT: 0, + EDT: -4 * 60, + EST: -5 * 60, + CDT: -5 * 60, + CST: -6 * 60, + MDT: -6 * 60, + MST: -7 * 60, + PDT: -7 * 60, + PST: -8 * 60, + }; + + // date from iso format + function configFromISO(config) { + var i, + l, + string = config._i, + match = extendedIsoRegex.exec(string) || basicIsoRegex.exec(string), + allowTime, + dateFormat, + timeFormat, + tzFormat, + isoDatesLen = isoDates.length, + isoTimesLen = isoTimes.length; + + if (match) { + getParsingFlags(config).iso = true; + for (i = 0, l = isoDatesLen; i < l; i++) { + if (isoDates[i][1].exec(match[1])) { + dateFormat = isoDates[i][0]; + allowTime = isoDates[i][2] !== false; + break; + } + } + if (dateFormat == null) { + config._isValid = false; + return; + } + if (match[3]) { + for (i = 0, l = isoTimesLen; i < l; i++) { + if (isoTimes[i][1].exec(match[3])) { + // match[2] should be 'T' or space + timeFormat = (match[2] || ' ') + isoTimes[i][0]; + break; + } + } + if (timeFormat == null) { + config._isValid = false; + return; + } + } + if (!allowTime && timeFormat != null) { + config._isValid = false; + return; + } + if (match[4]) { + if (tzRegex.exec(match[4])) { + tzFormat = 'Z'; + } else { + config._isValid = false; + return; + } + } + config._f = dateFormat + (timeFormat || '') + (tzFormat || ''); + configFromStringAndFormat(config); + } else { + config._isValid = false; + } + } + + function extractFromRFC2822Strings( + yearStr, + monthStr, + dayStr, + hourStr, + minuteStr, + secondStr + ) { + var result = [ + untruncateYear(yearStr), + defaultLocaleMonthsShort.indexOf(monthStr), + parseInt(dayStr, 10), + parseInt(hourStr, 10), + parseInt(minuteStr, 10), + ]; + + if (secondStr) { + result.push(parseInt(secondStr, 10)); + } + + return result; + } + + function untruncateYear(yearStr) { + var year = parseInt(yearStr, 10); + if (year <= 49) { + return 2000 + year; + } else if (year <= 999) { + return 1900 + year; + } + return year; + } + + function preprocessRFC2822(s) { + // Remove comments and folding whitespace and replace multiple-spaces with a single space + return s + .replace(/\([^()]*\)|[\n\t]/g, ' ') + .replace(/(\s\s+)/g, ' ') + .replace(/^\s\s*/, '') + .replace(/\s\s*$/, ''); + } + + function checkWeekday(weekdayStr, parsedInput, config) { + if (weekdayStr) { + // TODO: Replace the vanilla JS Date object with an independent day-of-week check. + var weekdayProvided = defaultLocaleWeekdaysShort.indexOf(weekdayStr), + weekdayActual = new Date( + parsedInput[0], + parsedInput[1], + parsedInput[2] + ).getDay(); + if (weekdayProvided !== weekdayActual) { + getParsingFlags(config).weekdayMismatch = true; + config._isValid = false; + return false; + } + } + return true; + } + + function calculateOffset(obsOffset, militaryOffset, numOffset) { + if (obsOffset) { + return obsOffsets[obsOffset]; + } else if (militaryOffset) { + // the only allowed military tz is Z + return 0; + } else { + var hm = parseInt(numOffset, 10), + m = hm % 100, + h = (hm - m) / 100; + return h * 60 + m; + } + } + + // date and time from ref 2822 format + function configFromRFC2822(config) { + var match = rfc2822.exec(preprocessRFC2822(config._i)), + parsedArray; + if (match) { + parsedArray = extractFromRFC2822Strings( + match[4], + match[3], + match[2], + match[5], + match[6], + match[7] + ); + if (!checkWeekday(match[1], parsedArray, config)) { + return; + } + + config._a = parsedArray; + config._tzm = calculateOffset(match[8], match[9], match[10]); + + config._d = createUTCDate.apply(null, config._a); + config._d.setUTCMinutes(config._d.getUTCMinutes() - config._tzm); + + getParsingFlags(config).rfc2822 = true; + } else { + config._isValid = false; + } + } + + // date from 1) ASP.NET, 2) ISO, 3) RFC 2822 formats, or 4) optional fallback if parsing isn't strict + function configFromString(config) { + var matched = aspNetJsonRegex.exec(config._i); + if (matched !== null) { + config._d = new Date(+matched[1]); + return; + } + + configFromISO(config); + if (config._isValid === false) { + delete config._isValid; + } else { + return; + } + + configFromRFC2822(config); + if (config._isValid === false) { + delete config._isValid; + } else { + return; + } + + if (config._strict) { + config._isValid = false; + } else { + // Final attempt, use Input Fallback + hooks.createFromInputFallback(config); + } + } + + hooks.createFromInputFallback = deprecate( + 'value provided is not in a recognized RFC2822 or ISO format. moment construction falls back to js Date(), ' + + 'which is not reliable across all browsers and versions. Non RFC2822/ISO date formats are ' + + 'discouraged. Please refer to http://momentjs.com/guides/#/warnings/js-date/ for more info.', + function (config) { + config._d = new Date(config._i + (config._useUTC ? ' UTC' : '')); + } + ); + + // Pick the first defined of two or three arguments. + function defaults(a, b, c) { + if (a != null) { + return a; + } + if (b != null) { + return b; + } + return c; + } + + function currentDateArray(config) { + // hooks is actually the exported moment object + var nowValue = new Date(hooks.now()); + if (config._useUTC) { + return [ + nowValue.getUTCFullYear(), + nowValue.getUTCMonth(), + nowValue.getUTCDate(), + ]; + } + return [nowValue.getFullYear(), nowValue.getMonth(), nowValue.getDate()]; + } + + // convert an array to a date. + // the array should mirror the parameters below + // note: all values past the year are optional and will default to the lowest possible value. + // [year, month, day , hour, minute, second, millisecond] + function configFromArray(config) { + var i, + date, + input = [], + currentDate, + expectedWeekday, + yearToUse; + + if (config._d) { + return; + } + + currentDate = currentDateArray(config); + + //compute day of the year from weeks and weekdays + if (config._w && config._a[DATE] == null && config._a[MONTH] == null) { + dayOfYearFromWeekInfo(config); + } + + //if the day of the year is set, figure out what it is + if (config._dayOfYear != null) { + yearToUse = defaults(config._a[YEAR], currentDate[YEAR]); + + if ( + config._dayOfYear > daysInYear(yearToUse) || + config._dayOfYear === 0 + ) { + getParsingFlags(config)._overflowDayOfYear = true; + } + + date = createUTCDate(yearToUse, 0, config._dayOfYear); + config._a[MONTH] = date.getUTCMonth(); + config._a[DATE] = date.getUTCDate(); + } + + // Default to current date. + // * if no year, month, day of month are given, default to today + // * if day of month is given, default month and year + // * if month is given, default only year + // * if year is given, don't default anything + for (i = 0; i < 3 && config._a[i] == null; ++i) { + config._a[i] = input[i] = currentDate[i]; + } + + // Zero out whatever was not defaulted, including time + for (; i < 7; i++) { + config._a[i] = input[i] = + config._a[i] == null ? (i === 2 ? 1 : 0) : config._a[i]; + } + + // Check for 24:00:00.000 + if ( + config._a[HOUR] === 24 && + config._a[MINUTE] === 0 && + config._a[SECOND] === 0 && + config._a[MILLISECOND] === 0 + ) { + config._nextDay = true; + config._a[HOUR] = 0; + } + + config._d = (config._useUTC ? createUTCDate : createDate).apply( + null, + input + ); + expectedWeekday = config._useUTC + ? config._d.getUTCDay() + : config._d.getDay(); + + // Apply timezone offset from input. The actual utcOffset can be changed + // with parseZone. + if (config._tzm != null) { + config._d.setUTCMinutes(config._d.getUTCMinutes() - config._tzm); + } + + if (config._nextDay) { + config._a[HOUR] = 24; + } + + // check for mismatching day of week + if ( + config._w && + typeof config._w.d !== 'undefined' && + config._w.d !== expectedWeekday + ) { + getParsingFlags(config).weekdayMismatch = true; + } + } + + function dayOfYearFromWeekInfo(config) { + var w, weekYear, week, weekday, dow, doy, temp, weekdayOverflow, curWeek; + + w = config._w; + if (w.GG != null || w.W != null || w.E != null) { + dow = 1; + doy = 4; + + // TODO: We need to take the current isoWeekYear, but that depends on + // how we interpret now (local, utc, fixed offset). So create + // a now version of current config (take local/utc/offset flags, and + // create now). + weekYear = defaults( + w.GG, + config._a[YEAR], + weekOfYear(createLocal(), 1, 4).year + ); + week = defaults(w.W, 1); + weekday = defaults(w.E, 1); + if (weekday < 1 || weekday > 7) { + weekdayOverflow = true; + } + } else { + dow = config._locale._week.dow; + doy = config._locale._week.doy; + + curWeek = weekOfYear(createLocal(), dow, doy); + + weekYear = defaults(w.gg, config._a[YEAR], curWeek.year); + + // Default to current week. + week = defaults(w.w, curWeek.week); + + if (w.d != null) { + // weekday -- low day numbers are considered next week + weekday = w.d; + if (weekday < 0 || weekday > 6) { + weekdayOverflow = true; + } + } else if (w.e != null) { + // local weekday -- counting starts from beginning of week + weekday = w.e + dow; + if (w.e < 0 || w.e > 6) { + weekdayOverflow = true; + } + } else { + // default to beginning of week + weekday = dow; + } + } + if (week < 1 || week > weeksInYear(weekYear, dow, doy)) { + getParsingFlags(config)._overflowWeeks = true; + } else if (weekdayOverflow != null) { + getParsingFlags(config)._overflowWeekday = true; + } else { + temp = dayOfYearFromWeeks(weekYear, week, weekday, dow, doy); + config._a[YEAR] = temp.year; + config._dayOfYear = temp.dayOfYear; + } + } + + // constant that refers to the ISO standard + hooks.ISO_8601 = function () {}; + + // constant that refers to the RFC 2822 form + hooks.RFC_2822 = function () {}; + + // date from string and format string + function configFromStringAndFormat(config) { + // TODO: Move this to another part of the creation flow to prevent circular deps + if (config._f === hooks.ISO_8601) { + configFromISO(config); + return; + } + if (config._f === hooks.RFC_2822) { + configFromRFC2822(config); + return; + } + config._a = []; + getParsingFlags(config).empty = true; + + // This array is used to make a Date, either with `new Date` or `Date.UTC` + var string = '' + config._i, + i, + parsedInput, + tokens, + token, + skipped, + stringLength = string.length, + totalParsedInputLength = 0, + era, + tokenLen; + + tokens = + expandFormat(config._f, config._locale).match(formattingTokens) || []; + tokenLen = tokens.length; + for (i = 0; i < tokenLen; i++) { + token = tokens[i]; + parsedInput = (string.match(getParseRegexForToken(token, config)) || + [])[0]; + if (parsedInput) { + skipped = string.substr(0, string.indexOf(parsedInput)); + if (skipped.length > 0) { + getParsingFlags(config).unusedInput.push(skipped); + } + string = string.slice( + string.indexOf(parsedInput) + parsedInput.length + ); + totalParsedInputLength += parsedInput.length; + } + // don't parse if it's not a known token + if (formatTokenFunctions[token]) { + if (parsedInput) { + getParsingFlags(config).empty = false; + } else { + getParsingFlags(config).unusedTokens.push(token); + } + addTimeToArrayFromToken(token, parsedInput, config); + } else if (config._strict && !parsedInput) { + getParsingFlags(config).unusedTokens.push(token); + } + } + + // add remaining unparsed input length to the string + getParsingFlags(config).charsLeftOver = + stringLength - totalParsedInputLength; + if (string.length > 0) { + getParsingFlags(config).unusedInput.push(string); + } + + // clear _12h flag if hour is <= 12 + if ( + config._a[HOUR] <= 12 && + getParsingFlags(config).bigHour === true && + config._a[HOUR] > 0 + ) { + getParsingFlags(config).bigHour = undefined; + } + + getParsingFlags(config).parsedDateParts = config._a.slice(0); + getParsingFlags(config).meridiem = config._meridiem; + // handle meridiem + config._a[HOUR] = meridiemFixWrap( + config._locale, + config._a[HOUR], + config._meridiem + ); + + // handle era + era = getParsingFlags(config).era; + if (era !== null) { + config._a[YEAR] = config._locale.erasConvertYear(era, config._a[YEAR]); + } + + configFromArray(config); + checkOverflow(config); + } + + function meridiemFixWrap(locale, hour, meridiem) { + var isPm; + + if (meridiem == null) { + // nothing to do + return hour; + } + if (locale.meridiemHour != null) { + return locale.meridiemHour(hour, meridiem); + } else if (locale.isPM != null) { + // Fallback + isPm = locale.isPM(meridiem); + if (isPm && hour < 12) { + hour += 12; + } + if (!isPm && hour === 12) { + hour = 0; + } + return hour; + } else { + // this is not supposed to happen + return hour; + } + } + + // date from string and array of format strings + function configFromStringAndArray(config) { + var tempConfig, + bestMoment, + scoreToBeat, + i, + currentScore, + validFormatFound, + bestFormatIsValid = false, + configfLen = config._f.length; + + if (configfLen === 0) { + getParsingFlags(config).invalidFormat = true; + config._d = new Date(NaN); + return; + } + + for (i = 0; i < configfLen; i++) { + currentScore = 0; + validFormatFound = false; + tempConfig = copyConfig({}, config); + if (config._useUTC != null) { + tempConfig._useUTC = config._useUTC; + } + tempConfig._f = config._f[i]; + configFromStringAndFormat(tempConfig); + + if (isValid(tempConfig)) { + validFormatFound = true; + } + + // if there is any input that was not parsed add a penalty for that format + currentScore += getParsingFlags(tempConfig).charsLeftOver; + + //or tokens + currentScore += getParsingFlags(tempConfig).unusedTokens.length * 10; + + getParsingFlags(tempConfig).score = currentScore; + + if (!bestFormatIsValid) { + if ( + scoreToBeat == null || + currentScore < scoreToBeat || + validFormatFound + ) { + scoreToBeat = currentScore; + bestMoment = tempConfig; + if (validFormatFound) { + bestFormatIsValid = true; + } + } + } else { + if (currentScore < scoreToBeat) { + scoreToBeat = currentScore; + bestMoment = tempConfig; + } + } + } + + extend(config, bestMoment || tempConfig); + } + + function configFromObject(config) { + if (config._d) { + return; + } + + var i = normalizeObjectUnits(config._i), + dayOrDate = i.day === undefined ? i.date : i.day; + config._a = map( + [i.year, i.month, dayOrDate, i.hour, i.minute, i.second, i.millisecond], + function (obj) { + return obj && parseInt(obj, 10); + } + ); + + configFromArray(config); + } + + function createFromConfig(config) { + var res = new Moment(checkOverflow(prepareConfig(config))); + if (res._nextDay) { + // Adding is smart enough around DST + res.add(1, 'd'); + res._nextDay = undefined; + } + + return res; + } + + function prepareConfig(config) { + var input = config._i, + format = config._f; + + config._locale = config._locale || getLocale(config._l); + + if (input === null || (format === undefined && input === '')) { + return createInvalid({ nullInput: true }); + } + + if (typeof input === 'string') { + config._i = input = config._locale.preparse(input); + } + + if (isMoment(input)) { + return new Moment(checkOverflow(input)); + } else if (isDate(input)) { + config._d = input; + } else if (isArray(format)) { + configFromStringAndArray(config); + } else if (format) { + configFromStringAndFormat(config); + } else { + configFromInput(config); + } + + if (!isValid(config)) { + config._d = null; + } + + return config; + } + + function configFromInput(config) { + var input = config._i; + if (isUndefined(input)) { + config._d = new Date(hooks.now()); + } else if (isDate(input)) { + config._d = new Date(input.valueOf()); + } else if (typeof input === 'string') { + configFromString(config); + } else if (isArray(input)) { + config._a = map(input.slice(0), function (obj) { + return parseInt(obj, 10); + }); + configFromArray(config); + } else if (isObject(input)) { + configFromObject(config); + } else if (isNumber(input)) { + // from milliseconds + config._d = new Date(input); + } else { + hooks.createFromInputFallback(config); + } + } + + function createLocalOrUTC(input, format, locale, strict, isUTC) { + var c = {}; + + if (format === true || format === false) { + strict = format; + format = undefined; + } + + if (locale === true || locale === false) { + strict = locale; + locale = undefined; + } + + if ( + (isObject(input) && isObjectEmpty(input)) || + (isArray(input) && input.length === 0) + ) { + input = undefined; + } + // object construction must be done this way. + // https://github.com/moment/moment/issues/1423 + c._isAMomentObject = true; + c._useUTC = c._isUTC = isUTC; + c._l = locale; + c._i = input; + c._f = format; + c._strict = strict; + + return createFromConfig(c); + } + + function createLocal(input, format, locale, strict) { + return createLocalOrUTC(input, format, locale, strict, false); + } + + var prototypeMin = deprecate( + 'moment().min is deprecated, use moment.max instead. http://momentjs.com/guides/#/warnings/min-max/', + function () { + var other = createLocal.apply(null, arguments); + if (this.isValid() && other.isValid()) { + return other < this ? this : other; + } else { + return createInvalid(); + } + } + ), + prototypeMax = deprecate( + 'moment().max is deprecated, use moment.min instead. http://momentjs.com/guides/#/warnings/min-max/', + function () { + var other = createLocal.apply(null, arguments); + if (this.isValid() && other.isValid()) { + return other > this ? this : other; + } else { + return createInvalid(); + } + } + ); + + // Pick a moment m from moments so that m[fn](other) is true for all + // other. This relies on the function fn to be transitive. + // + // moments should either be an array of moment objects or an array, whose + // first element is an array of moment objects. + function pickBy(fn, moments) { + var res, i; + if (moments.length === 1 && isArray(moments[0])) { + moments = moments[0]; + } + if (!moments.length) { + return createLocal(); + } + res = moments[0]; + for (i = 1; i < moments.length; ++i) { + if (!moments[i].isValid() || moments[i][fn](res)) { + res = moments[i]; + } + } + return res; + } + + // TODO: Use [].sort instead? + function min() { + var args = [].slice.call(arguments, 0); + + return pickBy('isBefore', args); + } + + function max() { + var args = [].slice.call(arguments, 0); + + return pickBy('isAfter', args); + } + + var now = function () { + return Date.now ? Date.now() : +new Date(); + }; + + var ordering = [ + 'year', + 'quarter', + 'month', + 'week', + 'day', + 'hour', + 'minute', + 'second', + 'millisecond', + ]; + + function isDurationValid(m) { + var key, + unitHasDecimal = false, + i, + orderLen = ordering.length; + for (key in m) { + if ( + hasOwnProp(m, key) && + !( + indexOf.call(ordering, key) !== -1 && + (m[key] == null || !isNaN(m[key])) + ) + ) { + return false; + } + } + + for (i = 0; i < orderLen; ++i) { + if (m[ordering[i]]) { + if (unitHasDecimal) { + return false; // only allow non-integers for smallest unit + } + if (parseFloat(m[ordering[i]]) !== toInt(m[ordering[i]])) { + unitHasDecimal = true; + } + } + } + + return true; + } + + function isValid$1() { + return this._isValid; + } + + function createInvalid$1() { + return createDuration(NaN); + } + + function Duration(duration) { + var normalizedInput = normalizeObjectUnits(duration), + years = normalizedInput.year || 0, + quarters = normalizedInput.quarter || 0, + months = normalizedInput.month || 0, + weeks = normalizedInput.week || normalizedInput.isoWeek || 0, + days = normalizedInput.day || 0, + hours = normalizedInput.hour || 0, + minutes = normalizedInput.minute || 0, + seconds = normalizedInput.second || 0, + milliseconds = normalizedInput.millisecond || 0; + + this._isValid = isDurationValid(normalizedInput); + + // representation for dateAddRemove + this._milliseconds = + +milliseconds + + seconds * 1e3 + // 1000 + minutes * 6e4 + // 1000 * 60 + hours * 1000 * 60 * 60; //using 1000 * 60 * 60 instead of 36e5 to avoid floating point rounding errors https://github.com/moment/moment/issues/2978 + // Because of dateAddRemove treats 24 hours as different from a + // day when working around DST, we need to store them separately + this._days = +days + weeks * 7; + // It is impossible to translate months into days without knowing + // which months you are are talking about, so we have to store + // it separately. + this._months = +months + quarters * 3 + years * 12; + + this._data = {}; + + this._locale = getLocale(); + + this._bubble(); + } + + function isDuration(obj) { + return obj instanceof Duration; + } + + function absRound(number) { + if (number < 0) { + return Math.round(-1 * number) * -1; + } else { + return Math.round(number); + } + } + + // compare two arrays, return the number of differences + function compareArrays(array1, array2, dontConvert) { + var len = Math.min(array1.length, array2.length), + lengthDiff = Math.abs(array1.length - array2.length), + diffs = 0, + i; + for (i = 0; i < len; i++) { + if ( + (dontConvert && array1[i] !== array2[i]) || + (!dontConvert && toInt(array1[i]) !== toInt(array2[i])) + ) { + diffs++; + } + } + return diffs + lengthDiff; + } + + // FORMATTING + + function offset(token, separator) { + addFormatToken(token, 0, 0, function () { + var offset = this.utcOffset(), + sign = '+'; + if (offset < 0) { + offset = -offset; + sign = '-'; + } + return ( + sign + + zeroFill(~~(offset / 60), 2) + + separator + + zeroFill(~~offset % 60, 2) + ); + }); + } + + offset('Z', ':'); + offset('ZZ', ''); + + // PARSING + + addRegexToken('Z', matchShortOffset); + addRegexToken('ZZ', matchShortOffset); + addParseToken(['Z', 'ZZ'], function (input, array, config) { + config._useUTC = true; + config._tzm = offsetFromString(matchShortOffset, input); + }); + + // HELPERS + + // timezone chunker + // '+10:00' > ['10', '00'] + // '-1530' > ['-15', '30'] + var chunkOffset = /([\+\-]|\d\d)/gi; + + function offsetFromString(matcher, string) { + var matches = (string || '').match(matcher), + chunk, + parts, + minutes; + + if (matches === null) { + return null; + } + + chunk = matches[matches.length - 1] || []; + parts = (chunk + '').match(chunkOffset) || ['-', 0, 0]; + minutes = +(parts[1] * 60) + toInt(parts[2]); + + return minutes === 0 ? 0 : parts[0] === '+' ? minutes : -minutes; + } + + // Return a moment from input, that is local/utc/zone equivalent to model. + function cloneWithOffset(input, model) { + var res, diff; + if (model._isUTC) { + res = model.clone(); + diff = + (isMoment(input) || isDate(input) + ? input.valueOf() + : createLocal(input).valueOf()) - res.valueOf(); + // Use low-level api, because this fn is low-level api. + res._d.setTime(res._d.valueOf() + diff); + hooks.updateOffset(res, false); + return res; + } else { + return createLocal(input).local(); + } + } + + function getDateOffset(m) { + // On Firefox.24 Date#getTimezoneOffset returns a floating point. + // https://github.com/moment/moment/pull/1871 + return -Math.round(m._d.getTimezoneOffset()); + } + + // HOOKS + + // This function will be called whenever a moment is mutated. + // It is intended to keep the offset in sync with the timezone. + hooks.updateOffset = function () {}; + + // MOMENTS + + // keepLocalTime = true means only change the timezone, without + // affecting the local hour. So 5:31:26 +0300 --[utcOffset(2, true)]--> + // 5:31:26 +0200 It is possible that 5:31:26 doesn't exist with offset + // +0200, so we adjust the time as needed, to be valid. + // + // Keeping the time actually adds/subtracts (one hour) + // from the actual represented time. That is why we call updateOffset + // a second time. In case it wants us to change the offset again + // _changeInProgress == true case, then we have to adjust, because + // there is no such time in the given timezone. + function getSetOffset(input, keepLocalTime, keepMinutes) { + var offset = this._offset || 0, + localAdjust; + if (!this.isValid()) { + return input != null ? this : NaN; + } + if (input != null) { + if (typeof input === 'string') { + input = offsetFromString(matchShortOffset, input); + if (input === null) { + return this; + } + } else if (Math.abs(input) < 16 && !keepMinutes) { + input = input * 60; + } + if (!this._isUTC && keepLocalTime) { + localAdjust = getDateOffset(this); + } + this._offset = input; + this._isUTC = true; + if (localAdjust != null) { + this.add(localAdjust, 'm'); + } + if (offset !== input) { + if (!keepLocalTime || this._changeInProgress) { + addSubtract( + this, + createDuration(input - offset, 'm'), + 1, + false + ); + } else if (!this._changeInProgress) { + this._changeInProgress = true; + hooks.updateOffset(this, true); + this._changeInProgress = null; + } + } + return this; + } else { + return this._isUTC ? offset : getDateOffset(this); + } + } + + function getSetZone(input, keepLocalTime) { + if (input != null) { + if (typeof input !== 'string') { + input = -input; + } + + this.utcOffset(input, keepLocalTime); + + return this; + } else { + return -this.utcOffset(); + } + } + + function setOffsetToUTC(keepLocalTime) { + return this.utcOffset(0, keepLocalTime); + } + + function setOffsetToLocal(keepLocalTime) { + if (this._isUTC) { + this.utcOffset(0, keepLocalTime); + this._isUTC = false; + + if (keepLocalTime) { + this.subtract(getDateOffset(this), 'm'); + } + } + return this; + } + + function setOffsetToParsedOffset() { + if (this._tzm != null) { + this.utcOffset(this._tzm, false, true); + } else if (typeof this._i === 'string') { + var tZone = offsetFromString(matchOffset, this._i); + if (tZone != null) { + this.utcOffset(tZone); + } else { + this.utcOffset(0, true); + } + } + return this; + } + + function hasAlignedHourOffset(input) { + if (!this.isValid()) { + return false; + } + input = input ? createLocal(input).utcOffset() : 0; + + return (this.utcOffset() - input) % 60 === 0; + } + + function isDaylightSavingTime() { + return ( + this.utcOffset() > this.clone().month(0).utcOffset() || + this.utcOffset() > this.clone().month(5).utcOffset() + ); + } + + function isDaylightSavingTimeShifted() { + if (!isUndefined(this._isDSTShifted)) { + return this._isDSTShifted; + } + + var c = {}, + other; + + copyConfig(c, this); + c = prepareConfig(c); + + if (c._a) { + other = c._isUTC ? createUTC(c._a) : createLocal(c._a); + this._isDSTShifted = + this.isValid() && compareArrays(c._a, other.toArray()) > 0; + } else { + this._isDSTShifted = false; + } + + return this._isDSTShifted; + } + + function isLocal() { + return this.isValid() ? !this._isUTC : false; + } + + function isUtcOffset() { + return this.isValid() ? this._isUTC : false; + } + + function isUtc() { + return this.isValid() ? this._isUTC && this._offset === 0 : false; + } + + // ASP.NET json date format regex + var aspNetRegex = /^(-|\+)?(?:(\d*)[. ])?(\d+):(\d+)(?::(\d+)(\.\d*)?)?$/, + // from http://docs.closure-library.googlecode.com/git/closure_goog_date_date.js.source.html + // somewhat more in line with 4.4.3.2 2004 spec, but allows decimal anywhere + // and further modified to allow for strings containing both week and day + isoRegex = + /^(-|\+)?P(?:([-+]?[0-9,.]*)Y)?(?:([-+]?[0-9,.]*)M)?(?:([-+]?[0-9,.]*)W)?(?:([-+]?[0-9,.]*)D)?(?:T(?:([-+]?[0-9,.]*)H)?(?:([-+]?[0-9,.]*)M)?(?:([-+]?[0-9,.]*)S)?)?$/; + + function createDuration(input, key) { + var duration = input, + // matching against regexp is expensive, do it on demand + match = null, + sign, + ret, + diffRes; + + if (isDuration(input)) { + duration = { + ms: input._milliseconds, + d: input._days, + M: input._months, + }; + } else if (isNumber(input) || !isNaN(+input)) { + duration = {}; + if (key) { + duration[key] = +input; + } else { + duration.milliseconds = +input; + } + } else if ((match = aspNetRegex.exec(input))) { + sign = match[1] === '-' ? -1 : 1; + duration = { + y: 0, + d: toInt(match[DATE]) * sign, + h: toInt(match[HOUR]) * sign, + m: toInt(match[MINUTE]) * sign, + s: toInt(match[SECOND]) * sign, + ms: toInt(absRound(match[MILLISECOND] * 1000)) * sign, // the millisecond decimal point is included in the match + }; + } else if ((match = isoRegex.exec(input))) { + sign = match[1] === '-' ? -1 : 1; + duration = { + y: parseIso(match[2], sign), + M: parseIso(match[3], sign), + w: parseIso(match[4], sign), + d: parseIso(match[5], sign), + h: parseIso(match[6], sign), + m: parseIso(match[7], sign), + s: parseIso(match[8], sign), + }; + } else if (duration == null) { + // checks for null or undefined + duration = {}; + } else if ( + typeof duration === 'object' && + ('from' in duration || 'to' in duration) + ) { + diffRes = momentsDifference( + createLocal(duration.from), + createLocal(duration.to) + ); + + duration = {}; + duration.ms = diffRes.milliseconds; + duration.M = diffRes.months; + } + + ret = new Duration(duration); + + if (isDuration(input) && hasOwnProp(input, '_locale')) { + ret._locale = input._locale; + } + + if (isDuration(input) && hasOwnProp(input, '_isValid')) { + ret._isValid = input._isValid; + } + + return ret; + } + + createDuration.fn = Duration.prototype; + createDuration.invalid = createInvalid$1; + + function parseIso(inp, sign) { + // We'd normally use ~~inp for this, but unfortunately it also + // converts floats to ints. + // inp may be undefined, so careful calling replace on it. + var res = inp && parseFloat(inp.replace(',', '.')); + // apply sign while we're at it + return (isNaN(res) ? 0 : res) * sign; + } + + function positiveMomentsDifference(base, other) { + var res = {}; + + res.months = + other.month() - base.month() + (other.year() - base.year()) * 12; + if (base.clone().add(res.months, 'M').isAfter(other)) { + --res.months; + } + + res.milliseconds = +other - +base.clone().add(res.months, 'M'); + + return res; + } + + function momentsDifference(base, other) { + var res; + if (!(base.isValid() && other.isValid())) { + return { milliseconds: 0, months: 0 }; + } + + other = cloneWithOffset(other, base); + if (base.isBefore(other)) { + res = positiveMomentsDifference(base, other); + } else { + res = positiveMomentsDifference(other, base); + res.milliseconds = -res.milliseconds; + res.months = -res.months; + } + + return res; + } + + // TODO: remove 'name' arg after deprecation is removed + function createAdder(direction, name) { + return function (val, period) { + var dur, tmp; + //invert the arguments, but complain about it + if (period !== null && !isNaN(+period)) { + deprecateSimple( + name, + 'moment().' + + name + + '(period, number) is deprecated. Please use moment().' + + name + + '(number, period). ' + + 'See http://momentjs.com/guides/#/warnings/add-inverted-param/ for more info.' + ); + tmp = val; + val = period; + period = tmp; + } + + dur = createDuration(val, period); + addSubtract(this, dur, direction); + return this; + }; + } + + function addSubtract(mom, duration, isAdding, updateOffset) { + var milliseconds = duration._milliseconds, + days = absRound(duration._days), + months = absRound(duration._months); + + if (!mom.isValid()) { + // No op + return; + } + + updateOffset = updateOffset == null ? true : updateOffset; + + if (months) { + setMonth(mom, get(mom, 'Month') + months * isAdding); + } + if (days) { + set$1(mom, 'Date', get(mom, 'Date') + days * isAdding); + } + if (milliseconds) { + mom._d.setTime(mom._d.valueOf() + milliseconds * isAdding); + } + if (updateOffset) { + hooks.updateOffset(mom, days || months); + } + } + + var add = createAdder(1, 'add'), + subtract = createAdder(-1, 'subtract'); + + function isString(input) { + return typeof input === 'string' || input instanceof String; + } + + // type MomentInput = Moment | Date | string | number | (number | string)[] | MomentInputObject | void; // null | undefined + function isMomentInput(input) { + return ( + isMoment(input) || + isDate(input) || + isString(input) || + isNumber(input) || + isNumberOrStringArray(input) || + isMomentInputObject(input) || + input === null || + input === undefined + ); + } + + function isMomentInputObject(input) { + var objectTest = isObject(input) && !isObjectEmpty(input), + propertyTest = false, + properties = [ + 'years', + 'year', + 'y', + 'months', + 'month', + 'M', + 'days', + 'day', + 'd', + 'dates', + 'date', + 'D', + 'hours', + 'hour', + 'h', + 'minutes', + 'minute', + 'm', + 'seconds', + 'second', + 's', + 'milliseconds', + 'millisecond', + 'ms', + ], + i, + property, + propertyLen = properties.length; + + for (i = 0; i < propertyLen; i += 1) { + property = properties[i]; + propertyTest = propertyTest || hasOwnProp(input, property); + } + + return objectTest && propertyTest; + } + + function isNumberOrStringArray(input) { + var arrayTest = isArray(input), + dataTypeTest = false; + if (arrayTest) { + dataTypeTest = + input.filter(function (item) { + return !isNumber(item) && isString(input); + }).length === 0; + } + return arrayTest && dataTypeTest; + } + + function isCalendarSpec(input) { + var objectTest = isObject(input) && !isObjectEmpty(input), + propertyTest = false, + properties = [ + 'sameDay', + 'nextDay', + 'lastDay', + 'nextWeek', + 'lastWeek', + 'sameElse', + ], + i, + property; + + for (i = 0; i < properties.length; i += 1) { + property = properties[i]; + propertyTest = propertyTest || hasOwnProp(input, property); + } + + return objectTest && propertyTest; + } + + function getCalendarFormat(myMoment, now) { + var diff = myMoment.diff(now, 'days', true); + return diff < -6 + ? 'sameElse' + : diff < -1 + ? 'lastWeek' + : diff < 0 + ? 'lastDay' + : diff < 1 + ? 'sameDay' + : diff < 2 + ? 'nextDay' + : diff < 7 + ? 'nextWeek' + : 'sameElse'; + } + + function calendar$1(time, formats) { + // Support for single parameter, formats only overload to the calendar function + if (arguments.length === 1) { + if (!arguments[0]) { + time = undefined; + formats = undefined; + } else if (isMomentInput(arguments[0])) { + time = arguments[0]; + formats = undefined; + } else if (isCalendarSpec(arguments[0])) { + formats = arguments[0]; + time = undefined; + } + } + // We want to compare the start of today, vs this. + // Getting start-of-today depends on whether we're local/utc/offset or not. + var now = time || createLocal(), + sod = cloneWithOffset(now, this).startOf('day'), + format = hooks.calendarFormat(this, sod) || 'sameElse', + output = + formats && + (isFunction(formats[format]) + ? formats[format].call(this, now) + : formats[format]); + + return this.format( + output || this.localeData().calendar(format, this, createLocal(now)) + ); + } + + function clone() { + return new Moment(this); + } + + function isAfter(input, units) { + var localInput = isMoment(input) ? input : createLocal(input); + if (!(this.isValid() && localInput.isValid())) { + return false; + } + units = normalizeUnits(units) || 'millisecond'; + if (units === 'millisecond') { + return this.valueOf() > localInput.valueOf(); + } else { + return localInput.valueOf() < this.clone().startOf(units).valueOf(); + } + } + + function isBefore(input, units) { + var localInput = isMoment(input) ? input : createLocal(input); + if (!(this.isValid() && localInput.isValid())) { + return false; + } + units = normalizeUnits(units) || 'millisecond'; + if (units === 'millisecond') { + return this.valueOf() < localInput.valueOf(); + } else { + return this.clone().endOf(units).valueOf() < localInput.valueOf(); + } + } + + function isBetween(from, to, units, inclusivity) { + var localFrom = isMoment(from) ? from : createLocal(from), + localTo = isMoment(to) ? to : createLocal(to); + if (!(this.isValid() && localFrom.isValid() && localTo.isValid())) { + return false; + } + inclusivity = inclusivity || '()'; + return ( + (inclusivity[0] === '(' + ? this.isAfter(localFrom, units) + : !this.isBefore(localFrom, units)) && + (inclusivity[1] === ')' + ? this.isBefore(localTo, units) + : !this.isAfter(localTo, units)) + ); + } + + function isSame(input, units) { + var localInput = isMoment(input) ? input : createLocal(input), + inputMs; + if (!(this.isValid() && localInput.isValid())) { + return false; + } + units = normalizeUnits(units) || 'millisecond'; + if (units === 'millisecond') { + return this.valueOf() === localInput.valueOf(); + } else { + inputMs = localInput.valueOf(); + return ( + this.clone().startOf(units).valueOf() <= inputMs && + inputMs <= this.clone().endOf(units).valueOf() + ); + } + } + + function isSameOrAfter(input, units) { + return this.isSame(input, units) || this.isAfter(input, units); + } + + function isSameOrBefore(input, units) { + return this.isSame(input, units) || this.isBefore(input, units); + } + + function diff(input, units, asFloat) { + var that, zoneDelta, output; + + if (!this.isValid()) { + return NaN; + } + + that = cloneWithOffset(input, this); + + if (!that.isValid()) { + return NaN; + } + + zoneDelta = (that.utcOffset() - this.utcOffset()) * 6e4; + + units = normalizeUnits(units); + + switch (units) { + case 'year': + output = monthDiff(this, that) / 12; + break; + case 'month': + output = monthDiff(this, that); + break; + case 'quarter': + output = monthDiff(this, that) / 3; + break; + case 'second': + output = (this - that) / 1e3; + break; // 1000 + case 'minute': + output = (this - that) / 6e4; + break; // 1000 * 60 + case 'hour': + output = (this - that) / 36e5; + break; // 1000 * 60 * 60 + case 'day': + output = (this - that - zoneDelta) / 864e5; + break; // 1000 * 60 * 60 * 24, negate dst + case 'week': + output = (this - that - zoneDelta) / 6048e5; + break; // 1000 * 60 * 60 * 24 * 7, negate dst + default: + output = this - that; + } + + return asFloat ? output : absFloor(output); + } + + function monthDiff(a, b) { + if (a.date() < b.date()) { + // end-of-month calculations work correct when the start month has more + // days than the end month. + return -monthDiff(b, a); + } + // difference in months + var wholeMonthDiff = (b.year() - a.year()) * 12 + (b.month() - a.month()), + // b is in (anchor - 1 month, anchor + 1 month) + anchor = a.clone().add(wholeMonthDiff, 'months'), + anchor2, + adjust; + + if (b - anchor < 0) { + anchor2 = a.clone().add(wholeMonthDiff - 1, 'months'); + // linear across the month + adjust = (b - anchor) / (anchor - anchor2); + } else { + anchor2 = a.clone().add(wholeMonthDiff + 1, 'months'); + // linear across the month + adjust = (b - anchor) / (anchor2 - anchor); + } + + //check for negative zero, return zero if negative zero + return -(wholeMonthDiff + adjust) || 0; + } + + hooks.defaultFormat = 'YYYY-MM-DDTHH:mm:ssZ'; + hooks.defaultFormatUtc = 'YYYY-MM-DDTHH:mm:ss[Z]'; + + function toString() { + return this.clone().locale('en').format('ddd MMM DD YYYY HH:mm:ss [GMT]ZZ'); + } + + function toISOString(keepOffset) { + if (!this.isValid()) { + return null; + } + var utc = keepOffset !== true, + m = utc ? this.clone().utc() : this; + if (m.year() < 0 || m.year() > 9999) { + return formatMoment( + m, + utc + ? 'YYYYYY-MM-DD[T]HH:mm:ss.SSS[Z]' + : 'YYYYYY-MM-DD[T]HH:mm:ss.SSSZ' + ); + } + if (isFunction(Date.prototype.toISOString)) { + // native implementation is ~50x faster, use it when we can + if (utc) { + return this.toDate().toISOString(); + } else { + return new Date(this.valueOf() + this.utcOffset() * 60 * 1000) + .toISOString() + .replace('Z', formatMoment(m, 'Z')); + } + } + return formatMoment( + m, + utc ? 'YYYY-MM-DD[T]HH:mm:ss.SSS[Z]' : 'YYYY-MM-DD[T]HH:mm:ss.SSSZ' + ); + } + + /** + * Return a human readable representation of a moment that can + * also be evaluated to get a new moment which is the same + * + * @link https://nodejs.org/dist/latest/docs/api/util.html#util_custom_inspect_function_on_objects + */ + function inspect() { + if (!this.isValid()) { + return 'moment.invalid(/* ' + this._i + ' */)'; + } + var func = 'moment', + zone = '', + prefix, + year, + datetime, + suffix; + if (!this.isLocal()) { + func = this.utcOffset() === 0 ? 'moment.utc' : 'moment.parseZone'; + zone = 'Z'; + } + prefix = '[' + func + '("]'; + year = 0 <= this.year() && this.year() <= 9999 ? 'YYYY' : 'YYYYYY'; + datetime = '-MM-DD[T]HH:mm:ss.SSS'; + suffix = zone + '[")]'; + + return this.format(prefix + year + datetime + suffix); + } + + function format(inputString) { + if (!inputString) { + inputString = this.isUtc() + ? hooks.defaultFormatUtc + : hooks.defaultFormat; + } + var output = formatMoment(this, inputString); + return this.localeData().postformat(output); + } + + function from(time, withoutSuffix) { + if ( + this.isValid() && + ((isMoment(time) && time.isValid()) || createLocal(time).isValid()) + ) { + return createDuration({ to: this, from: time }) + .locale(this.locale()) + .humanize(!withoutSuffix); + } else { + return this.localeData().invalidDate(); + } + } + + function fromNow(withoutSuffix) { + return this.from(createLocal(), withoutSuffix); + } + + function to(time, withoutSuffix) { + if ( + this.isValid() && + ((isMoment(time) && time.isValid()) || createLocal(time).isValid()) + ) { + return createDuration({ from: this, to: time }) + .locale(this.locale()) + .humanize(!withoutSuffix); + } else { + return this.localeData().invalidDate(); + } + } + + function toNow(withoutSuffix) { + return this.to(createLocal(), withoutSuffix); + } + + // If passed a locale key, it will set the locale for this + // instance. Otherwise, it will return the locale configuration + // variables for this instance. + function locale(key) { + var newLocaleData; + + if (key === undefined) { + return this._locale._abbr; + } else { + newLocaleData = getLocale(key); + if (newLocaleData != null) { + this._locale = newLocaleData; + } + return this; + } + } + + var lang = deprecate( + 'moment().lang() is deprecated. Instead, use moment().localeData() to get the language configuration. Use moment().locale() to change languages.', + function (key) { + if (key === undefined) { + return this.localeData(); + } else { + return this.locale(key); + } + } + ); + + function localeData() { + return this._locale; + } + + var MS_PER_SECOND = 1000, + MS_PER_MINUTE = 60 * MS_PER_SECOND, + MS_PER_HOUR = 60 * MS_PER_MINUTE, + MS_PER_400_YEARS = (365 * 400 + 97) * 24 * MS_PER_HOUR; + + // actual modulo - handles negative numbers (for dates before 1970): + function mod$1(dividend, divisor) { + return ((dividend % divisor) + divisor) % divisor; + } + + function localStartOfDate(y, m, d) { + // the date constructor remaps years 0-99 to 1900-1999 + if (y < 100 && y >= 0) { + // preserve leap years using a full 400 year cycle, then reset + return new Date(y + 400, m, d) - MS_PER_400_YEARS; + } else { + return new Date(y, m, d).valueOf(); + } + } + + function utcStartOfDate(y, m, d) { + // Date.UTC remaps years 0-99 to 1900-1999 + if (y < 100 && y >= 0) { + // preserve leap years using a full 400 year cycle, then reset + return Date.UTC(y + 400, m, d) - MS_PER_400_YEARS; + } else { + return Date.UTC(y, m, d); + } + } + + function startOf(units) { + var time, startOfDate; + units = normalizeUnits(units); + if (units === undefined || units === 'millisecond' || !this.isValid()) { + return this; + } + + startOfDate = this._isUTC ? utcStartOfDate : localStartOfDate; + + switch (units) { + case 'year': + time = startOfDate(this.year(), 0, 1); + break; + case 'quarter': + time = startOfDate( + this.year(), + this.month() - (this.month() % 3), + 1 + ); + break; + case 'month': + time = startOfDate(this.year(), this.month(), 1); + break; + case 'week': + time = startOfDate( + this.year(), + this.month(), + this.date() - this.weekday() + ); + break; + case 'isoWeek': + time = startOfDate( + this.year(), + this.month(), + this.date() - (this.isoWeekday() - 1) + ); + break; + case 'day': + case 'date': + time = startOfDate(this.year(), this.month(), this.date()); + break; + case 'hour': + time = this._d.valueOf(); + time -= mod$1( + time + (this._isUTC ? 0 : this.utcOffset() * MS_PER_MINUTE), + MS_PER_HOUR + ); + break; + case 'minute': + time = this._d.valueOf(); + time -= mod$1(time, MS_PER_MINUTE); + break; + case 'second': + time = this._d.valueOf(); + time -= mod$1(time, MS_PER_SECOND); + break; + } + + this._d.setTime(time); + hooks.updateOffset(this, true); + return this; + } + + function endOf(units) { + var time, startOfDate; + units = normalizeUnits(units); + if (units === undefined || units === 'millisecond' || !this.isValid()) { + return this; + } + + startOfDate = this._isUTC ? utcStartOfDate : localStartOfDate; + + switch (units) { + case 'year': + time = startOfDate(this.year() + 1, 0, 1) - 1; + break; + case 'quarter': + time = + startOfDate( + this.year(), + this.month() - (this.month() % 3) + 3, + 1 + ) - 1; + break; + case 'month': + time = startOfDate(this.year(), this.month() + 1, 1) - 1; + break; + case 'week': + time = + startOfDate( + this.year(), + this.month(), + this.date() - this.weekday() + 7 + ) - 1; + break; + case 'isoWeek': + time = + startOfDate( + this.year(), + this.month(), + this.date() - (this.isoWeekday() - 1) + 7 + ) - 1; + break; + case 'day': + case 'date': + time = startOfDate(this.year(), this.month(), this.date() + 1) - 1; + break; + case 'hour': + time = this._d.valueOf(); + time += + MS_PER_HOUR - + mod$1( + time + (this._isUTC ? 0 : this.utcOffset() * MS_PER_MINUTE), + MS_PER_HOUR + ) - + 1; + break; + case 'minute': + time = this._d.valueOf(); + time += MS_PER_MINUTE - mod$1(time, MS_PER_MINUTE) - 1; + break; + case 'second': + time = this._d.valueOf(); + time += MS_PER_SECOND - mod$1(time, MS_PER_SECOND) - 1; + break; + } + + this._d.setTime(time); + hooks.updateOffset(this, true); + return this; + } + + function valueOf() { + return this._d.valueOf() - (this._offset || 0) * 60000; + } + + function unix() { + return Math.floor(this.valueOf() / 1000); + } + + function toDate() { + return new Date(this.valueOf()); + } + + function toArray() { + var m = this; + return [ + m.year(), + m.month(), + m.date(), + m.hour(), + m.minute(), + m.second(), + m.millisecond(), + ]; + } + + function toObject() { + var m = this; + return { + years: m.year(), + months: m.month(), + date: m.date(), + hours: m.hours(), + minutes: m.minutes(), + seconds: m.seconds(), + milliseconds: m.milliseconds(), + }; + } + + function toJSON() { + // new Date(NaN).toJSON() === null + return this.isValid() ? this.toISOString() : null; + } + + function isValid$2() { + return isValid(this); + } + + function parsingFlags() { + return extend({}, getParsingFlags(this)); + } + + function invalidAt() { + return getParsingFlags(this).overflow; + } + + function creationData() { + return { + input: this._i, + format: this._f, + locale: this._locale, + isUTC: this._isUTC, + strict: this._strict, + }; + } + + addFormatToken('N', 0, 0, 'eraAbbr'); + addFormatToken('NN', 0, 0, 'eraAbbr'); + addFormatToken('NNN', 0, 0, 'eraAbbr'); + addFormatToken('NNNN', 0, 0, 'eraName'); + addFormatToken('NNNNN', 0, 0, 'eraNarrow'); + + addFormatToken('y', ['y', 1], 'yo', 'eraYear'); + addFormatToken('y', ['yy', 2], 0, 'eraYear'); + addFormatToken('y', ['yyy', 3], 0, 'eraYear'); + addFormatToken('y', ['yyyy', 4], 0, 'eraYear'); + + addRegexToken('N', matchEraAbbr); + addRegexToken('NN', matchEraAbbr); + addRegexToken('NNN', matchEraAbbr); + addRegexToken('NNNN', matchEraName); + addRegexToken('NNNNN', matchEraNarrow); + + addParseToken( + ['N', 'NN', 'NNN', 'NNNN', 'NNNNN'], + function (input, array, config, token) { + var era = config._locale.erasParse(input, token, config._strict); + if (era) { + getParsingFlags(config).era = era; + } else { + getParsingFlags(config).invalidEra = input; + } + } + ); + + addRegexToken('y', matchUnsigned); + addRegexToken('yy', matchUnsigned); + addRegexToken('yyy', matchUnsigned); + addRegexToken('yyyy', matchUnsigned); + addRegexToken('yo', matchEraYearOrdinal); + + addParseToken(['y', 'yy', 'yyy', 'yyyy'], YEAR); + addParseToken(['yo'], function (input, array, config, token) { + var match; + if (config._locale._eraYearOrdinalRegex) { + match = input.match(config._locale._eraYearOrdinalRegex); + } + + if (config._locale.eraYearOrdinalParse) { + array[YEAR] = config._locale.eraYearOrdinalParse(input, match); + } else { + array[YEAR] = parseInt(input, 10); + } + }); + + function localeEras(m, format) { + var i, + l, + date, + eras = this._eras || getLocale('en')._eras; + for (i = 0, l = eras.length; i < l; ++i) { + switch (typeof eras[i].since) { + case 'string': + // truncate time + date = hooks(eras[i].since).startOf('day'); + eras[i].since = date.valueOf(); + break; + } + + switch (typeof eras[i].until) { + case 'undefined': + eras[i].until = +Infinity; + break; + case 'string': + // truncate time + date = hooks(eras[i].until).startOf('day').valueOf(); + eras[i].until = date.valueOf(); + break; + } + } + return eras; + } + + function localeErasParse(eraName, format, strict) { + var i, + l, + eras = this.eras(), + name, + abbr, + narrow; + eraName = eraName.toUpperCase(); + + for (i = 0, l = eras.length; i < l; ++i) { + name = eras[i].name.toUpperCase(); + abbr = eras[i].abbr.toUpperCase(); + narrow = eras[i].narrow.toUpperCase(); + + if (strict) { + switch (format) { + case 'N': + case 'NN': + case 'NNN': + if (abbr === eraName) { + return eras[i]; + } + break; + + case 'NNNN': + if (name === eraName) { + return eras[i]; + } + break; + + case 'NNNNN': + if (narrow === eraName) { + return eras[i]; + } + break; + } + } else if ([name, abbr, narrow].indexOf(eraName) >= 0) { + return eras[i]; + } + } + } + + function localeErasConvertYear(era, year) { + var dir = era.since <= era.until ? +1 : -1; + if (year === undefined) { + return hooks(era.since).year(); + } else { + return hooks(era.since).year() + (year - era.offset) * dir; + } + } + + function getEraName() { + var i, + l, + val, + eras = this.localeData().eras(); + for (i = 0, l = eras.length; i < l; ++i) { + // truncate time + val = this.clone().startOf('day').valueOf(); + + if (eras[i].since <= val && val <= eras[i].until) { + return eras[i].name; + } + if (eras[i].until <= val && val <= eras[i].since) { + return eras[i].name; + } + } + + return ''; + } + + function getEraNarrow() { + var i, + l, + val, + eras = this.localeData().eras(); + for (i = 0, l = eras.length; i < l; ++i) { + // truncate time + val = this.clone().startOf('day').valueOf(); + + if (eras[i].since <= val && val <= eras[i].until) { + return eras[i].narrow; + } + if (eras[i].until <= val && val <= eras[i].since) { + return eras[i].narrow; + } + } + + return ''; + } + + function getEraAbbr() { + var i, + l, + val, + eras = this.localeData().eras(); + for (i = 0, l = eras.length; i < l; ++i) { + // truncate time + val = this.clone().startOf('day').valueOf(); + + if (eras[i].since <= val && val <= eras[i].until) { + return eras[i].abbr; + } + if (eras[i].until <= val && val <= eras[i].since) { + return eras[i].abbr; + } + } + + return ''; + } + + function getEraYear() { + var i, + l, + dir, + val, + eras = this.localeData().eras(); + for (i = 0, l = eras.length; i < l; ++i) { + dir = eras[i].since <= eras[i].until ? +1 : -1; + + // truncate time + val = this.clone().startOf('day').valueOf(); + + if ( + (eras[i].since <= val && val <= eras[i].until) || + (eras[i].until <= val && val <= eras[i].since) + ) { + return ( + (this.year() - hooks(eras[i].since).year()) * dir + + eras[i].offset + ); + } + } + + return this.year(); + } + + function erasNameRegex(isStrict) { + if (!hasOwnProp(this, '_erasNameRegex')) { + computeErasParse.call(this); + } + return isStrict ? this._erasNameRegex : this._erasRegex; + } + + function erasAbbrRegex(isStrict) { + if (!hasOwnProp(this, '_erasAbbrRegex')) { + computeErasParse.call(this); + } + return isStrict ? this._erasAbbrRegex : this._erasRegex; + } + + function erasNarrowRegex(isStrict) { + if (!hasOwnProp(this, '_erasNarrowRegex')) { + computeErasParse.call(this); + } + return isStrict ? this._erasNarrowRegex : this._erasRegex; + } + + function matchEraAbbr(isStrict, locale) { + return locale.erasAbbrRegex(isStrict); + } + + function matchEraName(isStrict, locale) { + return locale.erasNameRegex(isStrict); + } + + function matchEraNarrow(isStrict, locale) { + return locale.erasNarrowRegex(isStrict); + } + + function matchEraYearOrdinal(isStrict, locale) { + return locale._eraYearOrdinalRegex || matchUnsigned; + } + + function computeErasParse() { + var abbrPieces = [], + namePieces = [], + narrowPieces = [], + mixedPieces = [], + i, + l, + eras = this.eras(); + + for (i = 0, l = eras.length; i < l; ++i) { + namePieces.push(regexEscape(eras[i].name)); + abbrPieces.push(regexEscape(eras[i].abbr)); + narrowPieces.push(regexEscape(eras[i].narrow)); + + mixedPieces.push(regexEscape(eras[i].name)); + mixedPieces.push(regexEscape(eras[i].abbr)); + mixedPieces.push(regexEscape(eras[i].narrow)); + } + + this._erasRegex = new RegExp('^(' + mixedPieces.join('|') + ')', 'i'); + this._erasNameRegex = new RegExp('^(' + namePieces.join('|') + ')', 'i'); + this._erasAbbrRegex = new RegExp('^(' + abbrPieces.join('|') + ')', 'i'); + this._erasNarrowRegex = new RegExp( + '^(' + narrowPieces.join('|') + ')', + 'i' + ); + } + + // FORMATTING + + addFormatToken(0, ['gg', 2], 0, function () { + return this.weekYear() % 100; + }); + + addFormatToken(0, ['GG', 2], 0, function () { + return this.isoWeekYear() % 100; + }); + + function addWeekYearFormatToken(token, getter) { + addFormatToken(0, [token, token.length], 0, getter); + } + + addWeekYearFormatToken('gggg', 'weekYear'); + addWeekYearFormatToken('ggggg', 'weekYear'); + addWeekYearFormatToken('GGGG', 'isoWeekYear'); + addWeekYearFormatToken('GGGGG', 'isoWeekYear'); + + // ALIASES + + addUnitAlias('weekYear', 'gg'); + addUnitAlias('isoWeekYear', 'GG'); + + // PRIORITY + + addUnitPriority('weekYear', 1); + addUnitPriority('isoWeekYear', 1); + + // PARSING + + addRegexToken('G', matchSigned); + addRegexToken('g', matchSigned); + addRegexToken('GG', match1to2, match2); + addRegexToken('gg', match1to2, match2); + addRegexToken('GGGG', match1to4, match4); + addRegexToken('gggg', match1to4, match4); + addRegexToken('GGGGG', match1to6, match6); + addRegexToken('ggggg', match1to6, match6); + + addWeekParseToken( + ['gggg', 'ggggg', 'GGGG', 'GGGGG'], + function (input, week, config, token) { + week[token.substr(0, 2)] = toInt(input); + } + ); + + addWeekParseToken(['gg', 'GG'], function (input, week, config, token) { + week[token] = hooks.parseTwoDigitYear(input); + }); + + // MOMENTS + + function getSetWeekYear(input) { + return getSetWeekYearHelper.call( + this, + input, + this.week(), + this.weekday(), + this.localeData()._week.dow, + this.localeData()._week.doy + ); + } + + function getSetISOWeekYear(input) { + return getSetWeekYearHelper.call( + this, + input, + this.isoWeek(), + this.isoWeekday(), + 1, + 4 + ); + } + + function getISOWeeksInYear() { + return weeksInYear(this.year(), 1, 4); + } + + function getISOWeeksInISOWeekYear() { + return weeksInYear(this.isoWeekYear(), 1, 4); + } + + function getWeeksInYear() { + var weekInfo = this.localeData()._week; + return weeksInYear(this.year(), weekInfo.dow, weekInfo.doy); + } + + function getWeeksInWeekYear() { + var weekInfo = this.localeData()._week; + return weeksInYear(this.weekYear(), weekInfo.dow, weekInfo.doy); + } + + function getSetWeekYearHelper(input, week, weekday, dow, doy) { + var weeksTarget; + if (input == null) { + return weekOfYear(this, dow, doy).year; + } else { + weeksTarget = weeksInYear(input, dow, doy); + if (week > weeksTarget) { + week = weeksTarget; + } + return setWeekAll.call(this, input, week, weekday, dow, doy); + } + } + + function setWeekAll(weekYear, week, weekday, dow, doy) { + var dayOfYearData = dayOfYearFromWeeks(weekYear, week, weekday, dow, doy), + date = createUTCDate(dayOfYearData.year, 0, dayOfYearData.dayOfYear); + + this.year(date.getUTCFullYear()); + this.month(date.getUTCMonth()); + this.date(date.getUTCDate()); + return this; + } + + // FORMATTING + + addFormatToken('Q', 0, 'Qo', 'quarter'); + + // ALIASES + + addUnitAlias('quarter', 'Q'); + + // PRIORITY + + addUnitPriority('quarter', 7); + + // PARSING + + addRegexToken('Q', match1); + addParseToken('Q', function (input, array) { + array[MONTH] = (toInt(input) - 1) * 3; + }); + + // MOMENTS + + function getSetQuarter(input) { + return input == null + ? Math.ceil((this.month() + 1) / 3) + : this.month((input - 1) * 3 + (this.month() % 3)); + } + + // FORMATTING + + addFormatToken('D', ['DD', 2], 'Do', 'date'); + + // ALIASES + + addUnitAlias('date', 'D'); + + // PRIORITY + addUnitPriority('date', 9); + + // PARSING + + addRegexToken('D', match1to2); + addRegexToken('DD', match1to2, match2); + addRegexToken('Do', function (isStrict, locale) { + // TODO: Remove "ordinalParse" fallback in next major release. + return isStrict + ? locale._dayOfMonthOrdinalParse || locale._ordinalParse + : locale._dayOfMonthOrdinalParseLenient; + }); + + addParseToken(['D', 'DD'], DATE); + addParseToken('Do', function (input, array) { + array[DATE] = toInt(input.match(match1to2)[0]); + }); + + // MOMENTS + + var getSetDayOfMonth = makeGetSet('Date', true); + + // FORMATTING + + addFormatToken('DDD', ['DDDD', 3], 'DDDo', 'dayOfYear'); + + // ALIASES + + addUnitAlias('dayOfYear', 'DDD'); + + // PRIORITY + addUnitPriority('dayOfYear', 4); + + // PARSING + + addRegexToken('DDD', match1to3); + addRegexToken('DDDD', match3); + addParseToken(['DDD', 'DDDD'], function (input, array, config) { + config._dayOfYear = toInt(input); + }); + + // HELPERS + + // MOMENTS + + function getSetDayOfYear(input) { + var dayOfYear = + Math.round( + (this.clone().startOf('day') - this.clone().startOf('year')) / 864e5 + ) + 1; + return input == null ? dayOfYear : this.add(input - dayOfYear, 'd'); + } + + // FORMATTING + + addFormatToken('m', ['mm', 2], 0, 'minute'); + + // ALIASES + + addUnitAlias('minute', 'm'); + + // PRIORITY + + addUnitPriority('minute', 14); + + // PARSING + + addRegexToken('m', match1to2); + addRegexToken('mm', match1to2, match2); + addParseToken(['m', 'mm'], MINUTE); + + // MOMENTS + + var getSetMinute = makeGetSet('Minutes', false); + + // FORMATTING + + addFormatToken('s', ['ss', 2], 0, 'second'); + + // ALIASES + + addUnitAlias('second', 's'); + + // PRIORITY + + addUnitPriority('second', 15); + + // PARSING + + addRegexToken('s', match1to2); + addRegexToken('ss', match1to2, match2); + addParseToken(['s', 'ss'], SECOND); + + // MOMENTS + + var getSetSecond = makeGetSet('Seconds', false); + + // FORMATTING + + addFormatToken('S', 0, 0, function () { + return ~~(this.millisecond() / 100); + }); + + addFormatToken(0, ['SS', 2], 0, function () { + return ~~(this.millisecond() / 10); + }); + + addFormatToken(0, ['SSS', 3], 0, 'millisecond'); + addFormatToken(0, ['SSSS', 4], 0, function () { + return this.millisecond() * 10; + }); + addFormatToken(0, ['SSSSS', 5], 0, function () { + return this.millisecond() * 100; + }); + addFormatToken(0, ['SSSSSS', 6], 0, function () { + return this.millisecond() * 1000; + }); + addFormatToken(0, ['SSSSSSS', 7], 0, function () { + return this.millisecond() * 10000; + }); + addFormatToken(0, ['SSSSSSSS', 8], 0, function () { + return this.millisecond() * 100000; + }); + addFormatToken(0, ['SSSSSSSSS', 9], 0, function () { + return this.millisecond() * 1000000; + }); + + // ALIASES + + addUnitAlias('millisecond', 'ms'); + + // PRIORITY + + addUnitPriority('millisecond', 16); + + // PARSING + + addRegexToken('S', match1to3, match1); + addRegexToken('SS', match1to3, match2); + addRegexToken('SSS', match1to3, match3); + + var token, getSetMillisecond; + for (token = 'SSSS'; token.length <= 9; token += 'S') { + addRegexToken(token, matchUnsigned); + } + + function parseMs(input, array) { + array[MILLISECOND] = toInt(('0.' + input) * 1000); + } + + for (token = 'S'; token.length <= 9; token += 'S') { + addParseToken(token, parseMs); + } + + getSetMillisecond = makeGetSet('Milliseconds', false); + + // FORMATTING + + addFormatToken('z', 0, 0, 'zoneAbbr'); + addFormatToken('zz', 0, 0, 'zoneName'); + + // MOMENTS + + function getZoneAbbr() { + return this._isUTC ? 'UTC' : ''; + } + + function getZoneName() { + return this._isUTC ? 'Coordinated Universal Time' : ''; + } + + var proto = Moment.prototype; + + proto.add = add; + proto.calendar = calendar$1; + proto.clone = clone; + proto.diff = diff; + proto.endOf = endOf; + proto.format = format; + proto.from = from; + proto.fromNow = fromNow; + proto.to = to; + proto.toNow = toNow; + proto.get = stringGet; + proto.invalidAt = invalidAt; + proto.isAfter = isAfter; + proto.isBefore = isBefore; + proto.isBetween = isBetween; + proto.isSame = isSame; + proto.isSameOrAfter = isSameOrAfter; + proto.isSameOrBefore = isSameOrBefore; + proto.isValid = isValid$2; + proto.lang = lang; + proto.locale = locale; + proto.localeData = localeData; + proto.max = prototypeMax; + proto.min = prototypeMin; + proto.parsingFlags = parsingFlags; + proto.set = stringSet; + proto.startOf = startOf; + proto.subtract = subtract; + proto.toArray = toArray; + proto.toObject = toObject; + proto.toDate = toDate; + proto.toISOString = toISOString; + proto.inspect = inspect; + if (typeof Symbol !== 'undefined' && Symbol.for != null) { + proto[Symbol.for('nodejs.util.inspect.custom')] = function () { + return 'Moment<' + this.format() + '>'; + }; + } + proto.toJSON = toJSON; + proto.toString = toString; + proto.unix = unix; + proto.valueOf = valueOf; + proto.creationData = creationData; + proto.eraName = getEraName; + proto.eraNarrow = getEraNarrow; + proto.eraAbbr = getEraAbbr; + proto.eraYear = getEraYear; + proto.year = getSetYear; + proto.isLeapYear = getIsLeapYear; + proto.weekYear = getSetWeekYear; + proto.isoWeekYear = getSetISOWeekYear; + proto.quarter = proto.quarters = getSetQuarter; + proto.month = getSetMonth; + proto.daysInMonth = getDaysInMonth; + proto.week = proto.weeks = getSetWeek; + proto.isoWeek = proto.isoWeeks = getSetISOWeek; + proto.weeksInYear = getWeeksInYear; + proto.weeksInWeekYear = getWeeksInWeekYear; + proto.isoWeeksInYear = getISOWeeksInYear; + proto.isoWeeksInISOWeekYear = getISOWeeksInISOWeekYear; + proto.date = getSetDayOfMonth; + proto.day = proto.days = getSetDayOfWeek; + proto.weekday = getSetLocaleDayOfWeek; + proto.isoWeekday = getSetISODayOfWeek; + proto.dayOfYear = getSetDayOfYear; + proto.hour = proto.hours = getSetHour; + proto.minute = proto.minutes = getSetMinute; + proto.second = proto.seconds = getSetSecond; + proto.millisecond = proto.milliseconds = getSetMillisecond; + proto.utcOffset = getSetOffset; + proto.utc = setOffsetToUTC; + proto.local = setOffsetToLocal; + proto.parseZone = setOffsetToParsedOffset; + proto.hasAlignedHourOffset = hasAlignedHourOffset; + proto.isDST = isDaylightSavingTime; + proto.isLocal = isLocal; + proto.isUtcOffset = isUtcOffset; + proto.isUtc = isUtc; + proto.isUTC = isUtc; + proto.zoneAbbr = getZoneAbbr; + proto.zoneName = getZoneName; + proto.dates = deprecate( + 'dates accessor is deprecated. Use date instead.', + getSetDayOfMonth + ); + proto.months = deprecate( + 'months accessor is deprecated. Use month instead', + getSetMonth + ); + proto.years = deprecate( + 'years accessor is deprecated. Use year instead', + getSetYear + ); + proto.zone = deprecate( + 'moment().zone is deprecated, use moment().utcOffset instead. http://momentjs.com/guides/#/warnings/zone/', + getSetZone + ); + proto.isDSTShifted = deprecate( + 'isDSTShifted is deprecated. See http://momentjs.com/guides/#/warnings/dst-shifted/ for more information', + isDaylightSavingTimeShifted + ); + + function createUnix(input) { + return createLocal(input * 1000); + } + + function createInZone() { + return createLocal.apply(null, arguments).parseZone(); + } + + function preParsePostFormat(string) { + return string; + } + + var proto$1 = Locale.prototype; + + proto$1.calendar = calendar; + proto$1.longDateFormat = longDateFormat; + proto$1.invalidDate = invalidDate; + proto$1.ordinal = ordinal; + proto$1.preparse = preParsePostFormat; + proto$1.postformat = preParsePostFormat; + proto$1.relativeTime = relativeTime; + proto$1.pastFuture = pastFuture; + proto$1.set = set; + proto$1.eras = localeEras; + proto$1.erasParse = localeErasParse; + proto$1.erasConvertYear = localeErasConvertYear; + proto$1.erasAbbrRegex = erasAbbrRegex; + proto$1.erasNameRegex = erasNameRegex; + proto$1.erasNarrowRegex = erasNarrowRegex; + + proto$1.months = localeMonths; + proto$1.monthsShort = localeMonthsShort; + proto$1.monthsParse = localeMonthsParse; + proto$1.monthsRegex = monthsRegex; + proto$1.monthsShortRegex = monthsShortRegex; + proto$1.week = localeWeek; + proto$1.firstDayOfYear = localeFirstDayOfYear; + proto$1.firstDayOfWeek = localeFirstDayOfWeek; + + proto$1.weekdays = localeWeekdays; + proto$1.weekdaysMin = localeWeekdaysMin; + proto$1.weekdaysShort = localeWeekdaysShort; + proto$1.weekdaysParse = localeWeekdaysParse; + + proto$1.weekdaysRegex = weekdaysRegex; + proto$1.weekdaysShortRegex = weekdaysShortRegex; + proto$1.weekdaysMinRegex = weekdaysMinRegex; + + proto$1.isPM = localeIsPM; + proto$1.meridiem = localeMeridiem; + + function get$1(format, index, field, setter) { + var locale = getLocale(), + utc = createUTC().set(setter, index); + return locale[field](utc, format); + } + + function listMonthsImpl(format, index, field) { + if (isNumber(format)) { + index = format; + format = undefined; + } + + format = format || ''; + + if (index != null) { + return get$1(format, index, field, 'month'); + } + + var i, + out = []; + for (i = 0; i < 12; i++) { + out[i] = get$1(format, i, field, 'month'); + } + return out; + } + + // () + // (5) + // (fmt, 5) + // (fmt) + // (true) + // (true, 5) + // (true, fmt, 5) + // (true, fmt) + function listWeekdaysImpl(localeSorted, format, index, field) { + if (typeof localeSorted === 'boolean') { + if (isNumber(format)) { + index = format; + format = undefined; + } + + format = format || ''; + } else { + format = localeSorted; + index = format; + localeSorted = false; + + if (isNumber(format)) { + index = format; + format = undefined; + } + + format = format || ''; + } + + var locale = getLocale(), + shift = localeSorted ? locale._week.dow : 0, + i, + out = []; + + if (index != null) { + return get$1(format, (index + shift) % 7, field, 'day'); + } + + for (i = 0; i < 7; i++) { + out[i] = get$1(format, (i + shift) % 7, field, 'day'); + } + return out; + } + + function listMonths(format, index) { + return listMonthsImpl(format, index, 'months'); + } + + function listMonthsShort(format, index) { + return listMonthsImpl(format, index, 'monthsShort'); + } + + function listWeekdays(localeSorted, format, index) { + return listWeekdaysImpl(localeSorted, format, index, 'weekdays'); + } + + function listWeekdaysShort(localeSorted, format, index) { + return listWeekdaysImpl(localeSorted, format, index, 'weekdaysShort'); + } + + function listWeekdaysMin(localeSorted, format, index) { + return listWeekdaysImpl(localeSorted, format, index, 'weekdaysMin'); + } + + getSetGlobalLocale('en', { + eras: [ + { + since: '0001-01-01', + until: +Infinity, + offset: 1, + name: 'Anno Domini', + narrow: 'AD', + abbr: 'AD', + }, + { + since: '0000-12-31', + until: -Infinity, + offset: 1, + name: 'Before Christ', + narrow: 'BC', + abbr: 'BC', + }, + ], + dayOfMonthOrdinalParse: /\d{1,2}(th|st|nd|rd)/, + ordinal: function (number) { + var b = number % 10, + output = + toInt((number % 100) / 10) === 1 + ? 'th' + : b === 1 + ? 'st' + : b === 2 + ? 'nd' + : b === 3 + ? 'rd' + : 'th'; + return number + output; + }, + }); + + // Side effect imports + + hooks.lang = deprecate( + 'moment.lang is deprecated. Use moment.locale instead.', + getSetGlobalLocale + ); + hooks.langData = deprecate( + 'moment.langData is deprecated. Use moment.localeData instead.', + getLocale + ); + + var mathAbs = Math.abs; + + function abs() { + var data = this._data; + + this._milliseconds = mathAbs(this._milliseconds); + this._days = mathAbs(this._days); + this._months = mathAbs(this._months); + + data.milliseconds = mathAbs(data.milliseconds); + data.seconds = mathAbs(data.seconds); + data.minutes = mathAbs(data.minutes); + data.hours = mathAbs(data.hours); + data.months = mathAbs(data.months); + data.years = mathAbs(data.years); + + return this; + } + + function addSubtract$1(duration, input, value, direction) { + var other = createDuration(input, value); + + duration._milliseconds += direction * other._milliseconds; + duration._days += direction * other._days; + duration._months += direction * other._months; + + return duration._bubble(); + } + + // supports only 2.0-style add(1, 's') or add(duration) + function add$1(input, value) { + return addSubtract$1(this, input, value, 1); + } + + // supports only 2.0-style subtract(1, 's') or subtract(duration) + function subtract$1(input, value) { + return addSubtract$1(this, input, value, -1); + } + + function absCeil(number) { + if (number < 0) { + return Math.floor(number); + } else { + return Math.ceil(number); + } + } + + function bubble() { + var milliseconds = this._milliseconds, + days = this._days, + months = this._months, + data = this._data, + seconds, + minutes, + hours, + years, + monthsFromDays; + + // if we have a mix of positive and negative values, bubble down first + // check: https://github.com/moment/moment/issues/2166 + if ( + !( + (milliseconds >= 0 && days >= 0 && months >= 0) || + (milliseconds <= 0 && days <= 0 && months <= 0) + ) + ) { + milliseconds += absCeil(monthsToDays(months) + days) * 864e5; + days = 0; + months = 0; + } + + // The following code bubbles up values, see the tests for + // examples of what that means. + data.milliseconds = milliseconds % 1000; + + seconds = absFloor(milliseconds / 1000); + data.seconds = seconds % 60; + + minutes = absFloor(seconds / 60); + data.minutes = minutes % 60; + + hours = absFloor(minutes / 60); + data.hours = hours % 24; + + days += absFloor(hours / 24); + + // convert days to months + monthsFromDays = absFloor(daysToMonths(days)); + months += monthsFromDays; + days -= absCeil(monthsToDays(monthsFromDays)); + + // 12 months -> 1 year + years = absFloor(months / 12); + months %= 12; + + data.days = days; + data.months = months; + data.years = years; + + return this; + } + + function daysToMonths(days) { + // 400 years have 146097 days (taking into account leap year rules) + // 400 years have 12 months === 4800 + return (days * 4800) / 146097; + } + + function monthsToDays(months) { + // the reverse of daysToMonths + return (months * 146097) / 4800; + } + + function as(units) { + if (!this.isValid()) { + return NaN; + } + var days, + months, + milliseconds = this._milliseconds; + + units = normalizeUnits(units); + + if (units === 'month' || units === 'quarter' || units === 'year') { + days = this._days + milliseconds / 864e5; + months = this._months + daysToMonths(days); + switch (units) { + case 'month': + return months; + case 'quarter': + return months / 3; + case 'year': + return months / 12; + } + } else { + // handle milliseconds separately because of floating point math errors (issue #1867) + days = this._days + Math.round(monthsToDays(this._months)); + switch (units) { + case 'week': + return days / 7 + milliseconds / 6048e5; + case 'day': + return days + milliseconds / 864e5; + case 'hour': + return days * 24 + milliseconds / 36e5; + case 'minute': + return days * 1440 + milliseconds / 6e4; + case 'second': + return days * 86400 + milliseconds / 1000; + // Math.floor prevents floating point math errors here + case 'millisecond': + return Math.floor(days * 864e5) + milliseconds; + default: + throw new Error('Unknown unit ' + units); + } + } + } + + // TODO: Use this.as('ms')? + function valueOf$1() { + if (!this.isValid()) { + return NaN; + } + return ( + this._milliseconds + + this._days * 864e5 + + (this._months % 12) * 2592e6 + + toInt(this._months / 12) * 31536e6 + ); + } + + function makeAs(alias) { + return function () { + return this.as(alias); + }; + } + + var asMilliseconds = makeAs('ms'), + asSeconds = makeAs('s'), + asMinutes = makeAs('m'), + asHours = makeAs('h'), + asDays = makeAs('d'), + asWeeks = makeAs('w'), + asMonths = makeAs('M'), + asQuarters = makeAs('Q'), + asYears = makeAs('y'); + + function clone$1() { + return createDuration(this); + } + + function get$2(units) { + units = normalizeUnits(units); + return this.isValid() ? this[units + 's']() : NaN; + } + + function makeGetter(name) { + return function () { + return this.isValid() ? this._data[name] : NaN; + }; + } + + var milliseconds = makeGetter('milliseconds'), + seconds = makeGetter('seconds'), + minutes = makeGetter('minutes'), + hours = makeGetter('hours'), + days = makeGetter('days'), + months = makeGetter('months'), + years = makeGetter('years'); + + function weeks() { + return absFloor(this.days() / 7); + } + + var round = Math.round, + thresholds = { + ss: 44, // a few seconds to seconds + s: 45, // seconds to minute + m: 45, // minutes to hour + h: 22, // hours to day + d: 26, // days to month/week + w: null, // weeks to month + M: 11, // months to year + }; + + // helper function for moment.fn.from, moment.fn.fromNow, and moment.duration.fn.humanize + function substituteTimeAgo(string, number, withoutSuffix, isFuture, locale) { + return locale.relativeTime(number || 1, !!withoutSuffix, string, isFuture); + } + + function relativeTime$1(posNegDuration, withoutSuffix, thresholds, locale) { + var duration = createDuration(posNegDuration).abs(), + seconds = round(duration.as('s')), + minutes = round(duration.as('m')), + hours = round(duration.as('h')), + days = round(duration.as('d')), + months = round(duration.as('M')), + weeks = round(duration.as('w')), + years = round(duration.as('y')), + a = + (seconds <= thresholds.ss && ['s', seconds]) || + (seconds < thresholds.s && ['ss', seconds]) || + (minutes <= 1 && ['m']) || + (minutes < thresholds.m && ['mm', minutes]) || + (hours <= 1 && ['h']) || + (hours < thresholds.h && ['hh', hours]) || + (days <= 1 && ['d']) || + (days < thresholds.d && ['dd', days]); + + if (thresholds.w != null) { + a = + a || + (weeks <= 1 && ['w']) || + (weeks < thresholds.w && ['ww', weeks]); + } + a = a || + (months <= 1 && ['M']) || + (months < thresholds.M && ['MM', months]) || + (years <= 1 && ['y']) || ['yy', years]; + + a[2] = withoutSuffix; + a[3] = +posNegDuration > 0; + a[4] = locale; + return substituteTimeAgo.apply(null, a); + } + + // This function allows you to set the rounding function for relative time strings + function getSetRelativeTimeRounding(roundingFunction) { + if (roundingFunction === undefined) { + return round; + } + if (typeof roundingFunction === 'function') { + round = roundingFunction; + return true; + } + return false; + } + + // This function allows you to set a threshold for relative time strings + function getSetRelativeTimeThreshold(threshold, limit) { + if (thresholds[threshold] === undefined) { + return false; + } + if (limit === undefined) { + return thresholds[threshold]; + } + thresholds[threshold] = limit; + if (threshold === 's') { + thresholds.ss = limit - 1; + } + return true; + } + + function humanize(argWithSuffix, argThresholds) { + if (!this.isValid()) { + return this.localeData().invalidDate(); + } + + var withSuffix = false, + th = thresholds, + locale, + output; + + if (typeof argWithSuffix === 'object') { + argThresholds = argWithSuffix; + argWithSuffix = false; + } + if (typeof argWithSuffix === 'boolean') { + withSuffix = argWithSuffix; + } + if (typeof argThresholds === 'object') { + th = Object.assign({}, thresholds, argThresholds); + if (argThresholds.s != null && argThresholds.ss == null) { + th.ss = argThresholds.s - 1; + } + } + + locale = this.localeData(); + output = relativeTime$1(this, !withSuffix, th, locale); + + if (withSuffix) { + output = locale.pastFuture(+this, output); + } + + return locale.postformat(output); + } + + var abs$1 = Math.abs; + + function sign(x) { + return (x > 0) - (x < 0) || +x; + } + + function toISOString$1() { + // for ISO strings we do not use the normal bubbling rules: + // * milliseconds bubble up until they become hours + // * days do not bubble at all + // * months bubble up until they become years + // This is because there is no context-free conversion between hours and days + // (think of clock changes) + // and also not between days and months (28-31 days per month) + if (!this.isValid()) { + return this.localeData().invalidDate(); + } + + var seconds = abs$1(this._milliseconds) / 1000, + days = abs$1(this._days), + months = abs$1(this._months), + minutes, + hours, + years, + s, + total = this.asSeconds(), + totalSign, + ymSign, + daysSign, + hmsSign; + + if (!total) { + // this is the same as C#'s (Noda) and python (isodate)... + // but not other JS (goog.date) + return 'P0D'; + } + + // 3600 seconds -> 60 minutes -> 1 hour + minutes = absFloor(seconds / 60); + hours = absFloor(minutes / 60); + seconds %= 60; + minutes %= 60; + + // 12 months -> 1 year + years = absFloor(months / 12); + months %= 12; + + // inspired by https://github.com/dordille/moment-isoduration/blob/master/moment.isoduration.js + s = seconds ? seconds.toFixed(3).replace(/\.?0+$/, '') : ''; + + totalSign = total < 0 ? '-' : ''; + ymSign = sign(this._months) !== sign(total) ? '-' : ''; + daysSign = sign(this._days) !== sign(total) ? '-' : ''; + hmsSign = sign(this._milliseconds) !== sign(total) ? '-' : ''; + + return ( + totalSign + + 'P' + + (years ? ymSign + years + 'Y' : '') + + (months ? ymSign + months + 'M' : '') + + (days ? daysSign + days + 'D' : '') + + (hours || minutes || seconds ? 'T' : '') + + (hours ? hmsSign + hours + 'H' : '') + + (minutes ? hmsSign + minutes + 'M' : '') + + (seconds ? hmsSign + s + 'S' : '') + ); + } + + var proto$2 = Duration.prototype; + + proto$2.isValid = isValid$1; + proto$2.abs = abs; + proto$2.add = add$1; + proto$2.subtract = subtract$1; + proto$2.as = as; + proto$2.asMilliseconds = asMilliseconds; + proto$2.asSeconds = asSeconds; + proto$2.asMinutes = asMinutes; + proto$2.asHours = asHours; + proto$2.asDays = asDays; + proto$2.asWeeks = asWeeks; + proto$2.asMonths = asMonths; + proto$2.asQuarters = asQuarters; + proto$2.asYears = asYears; + proto$2.valueOf = valueOf$1; + proto$2._bubble = bubble; + proto$2.clone = clone$1; + proto$2.get = get$2; + proto$2.milliseconds = milliseconds; + proto$2.seconds = seconds; + proto$2.minutes = minutes; + proto$2.hours = hours; + proto$2.days = days; + proto$2.weeks = weeks; + proto$2.months = months; + proto$2.years = years; + proto$2.humanize = humanize; + proto$2.toISOString = toISOString$1; + proto$2.toString = toISOString$1; + proto$2.toJSON = toISOString$1; + proto$2.locale = locale; + proto$2.localeData = localeData; + + proto$2.toIsoString = deprecate( + 'toIsoString() is deprecated. Please use toISOString() instead (notice the capitals)', + toISOString$1 + ); + proto$2.lang = lang; + + // FORMATTING + + addFormatToken('X', 0, 0, 'unix'); + addFormatToken('x', 0, 0, 'valueOf'); + + // PARSING + + addRegexToken('x', matchSigned); + addRegexToken('X', matchTimestamp); + addParseToken('X', function (input, array, config) { + config._d = new Date(parseFloat(input) * 1000); + }); + addParseToken('x', function (input, array, config) { + config._d = new Date(toInt(input)); + }); + + //! moment.js + + hooks.version = '2.29.4'; + + setHookCallback(createLocal); + + hooks.fn = proto; + hooks.min = min; + hooks.max = max; + hooks.now = now; + hooks.utc = createUTC; + hooks.unix = createUnix; + hooks.months = listMonths; + hooks.isDate = isDate; + hooks.locale = getSetGlobalLocale; + hooks.invalid = createInvalid; + hooks.duration = createDuration; + hooks.isMoment = isMoment; + hooks.weekdays = listWeekdays; + hooks.parseZone = createInZone; + hooks.localeData = getLocale; + hooks.isDuration = isDuration; + hooks.monthsShort = listMonthsShort; + hooks.weekdaysMin = listWeekdaysMin; + hooks.defineLocale = defineLocale; + hooks.updateLocale = updateLocale; + hooks.locales = listLocales; + hooks.weekdaysShort = listWeekdaysShort; + hooks.normalizeUnits = normalizeUnits; + hooks.relativeTimeRounding = getSetRelativeTimeRounding; + hooks.relativeTimeThreshold = getSetRelativeTimeThreshold; + hooks.calendarFormat = getCalendarFormat; + hooks.prototype = proto; + + // currently HTML5 input type only supports 24-hour formats + hooks.HTML5_FMT = { + DATETIME_LOCAL: 'YYYY-MM-DDTHH:mm', // + DATETIME_LOCAL_SECONDS: 'YYYY-MM-DDTHH:mm:ss', // + DATETIME_LOCAL_MS: 'YYYY-MM-DDTHH:mm:ss.SSS', // + DATE: 'YYYY-MM-DD', // + TIME: 'HH:mm', // + TIME_SECONDS: 'HH:mm:ss', // + TIME_MS: 'HH:mm:ss.SSS', // + WEEK: 'GGGG-[W]WW', // + MONTH: 'YYYY-MM', // + }; + + return hooks; + +}))); diff --git "a/qgis-app/styles/tests/stylefiles/\344\270\211\350\260\203\347\254\246\345\217\267\345\272\223.xml" "b/qgis-app/styles/tests/stylefiles/\344\270\211\350\260\203\347\254\246\345\217\267\345\272\223.xml" new file mode 100644 index 00000000..61483af6 --- /dev/null +++ "b/qgis-app/styles/tests/stylefiles/\344\270\211\350\260\203\347\254\246\345\217\267\345\272\223.xml" @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/qgis-app/styles/tests/test_views.py b/qgis-app/styles/tests/test_views.py index 545acda7..48d1b7fc 100644 --- a/qgis-app/styles/tests/test_views.py +++ b/qgis-app/styles/tests/test_views.py @@ -8,6 +8,9 @@ from django.test import Client, TestCase, override_settings from django.urls import reverse from styles.models import Style, StyleType +from django.utils.text import slugify +from django.utils.encoding import escape_uri_path + STYLE_DIR = os.path.join(os.path.dirname(__file__), "stylefiles") @@ -69,6 +72,13 @@ def test_upload_xml_file(self): }, ) self.assertEqual(self.response.status_code, 200) + + # Should send email to style managers + self.assertEqual( + mail.outbox[0].recipients(), + ['staff@email.com'] + ) + # style should be in Waiting Review url = reverse("style_unapproved") self.response = self.client.get(url) @@ -274,6 +284,18 @@ def setUp(self): approved=True, ) + self.newstyle_non_ascii = Style.objects.create( + pk=2, + creator=User.objects.get(pk=2), + style_type=StyleType.objects.get(pk=1), + name="三调符号库", + description="This file is saved in styles/tests/stylefiles folder", + thumbnail_image="thumbnail.png", + file="三调符号库.xml", + download_count=0, + approved=True, + ) + @override_settings(MEDIA_ROOT="styles/tests/stylefiles/") def test_anonymous_user_download(self): style = Style.objects.get(pk=1) @@ -286,6 +308,27 @@ def test_anonymous_user_download(self): style = Style.objects.get(pk=1) self.assertEqual(style.download_count, 1) + @override_settings(MEDIA_ROOT="styles/tests/stylefiles/") + def test_non_ascii_name_download(self): + style = Style.objects.get(pk=2) + self.assertEqual(style.download_count, 0) + self.client.logout() + url = reverse("style_download", kwargs={"pk": 2}) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + # Check if the Content-Disposition header is present in the response + self.assertTrue('Content-Disposition' in response) + + style_name = escape_uri_path(slugify(style.name, allow_unicode=True)) + # Extract the filename from the Content-Disposition header + content_disposition = response['Content-Disposition'] + _, params = content_disposition.split(';') + downloaded_filename = params.split('=')[1].strip(' "').split("utf-8''")[1] + + # Check if the downloaded filename matches the original filename + self.assertEqual(downloaded_filename, f"{style_name}.zip") + class TestStyleApprovalNotify(TestCase): fixtures = [ diff --git a/qgis-app/styles/views.py b/qgis-app/styles/views.py index 3e803a6c..84cfb874 100644 --- a/qgis-app/styles/views.py +++ b/qgis-app/styles/views.py @@ -100,7 +100,6 @@ def form_valid(self, form): symbol_type=xml_parse["type"] ).first() obj.require_action = False - obj.approved = False obj.save() resource_notify(obj, created=False, resource_type=self.resource_name) msg = _("The Style has been successfully updated.") @@ -149,7 +148,7 @@ class StyleReviewView(ResourceMixin, ResourceBaseReviewView): class StyleDownloadView(ResourceMixin, ResourceBaseDownload): - """Download a GeoPackage""" + """Download a style""" def style_nav_content(request): diff --git a/qgis-app/templates/base.html b/qgis-app/templates/base.html index 69195e7e..a702adc1 100644 --- a/qgis-app/templates/base.html +++ b/qgis-app/templates/base.html @@ -1,5 +1,6 @@ {% load i18n simplemenu_tags static %} {% load resources_custom_tags %} +{% load matomo_tags %} @@ -48,12 +49,8 @@ QGIS {% if user.is_authenticated %} - - {% else %} - {% endif %} @@ -136,7 +133,7 @@

Sustaining Members

- + + + \ No newline at end of file diff --git a/qgis-app/urls.py b/qgis-app/urls.py index 28e46ef0..7166f5c1 100644 --- a/qgis-app/urls.py +++ b/qgis-app/urls.py @@ -112,7 +112,6 @@ url(r"^__debug__/", include(debug_toolbar.urls)), ] - simplemenu.register( "/admin/", "/planet/", diff --git a/readme.md b/readme.md index 6aa42d92..ec2ea04c 100644 --- a/readme.md +++ b/readme.md @@ -31,6 +31,22 @@ To update QGIS versions, go to **[Admin](https://plugins.qgis.org/admin/)** -> * This application is based on Django, written in python and deployed on the server using docker and rancher. +## Token based authentication + +Users have the ability to generate a Simple JWT token by providing their credentials, which can then be utilized to access endpoints requiring authentication. +Users can create specific tokens for a plugin at `https://plugins.qgis.org//tokens/`. + + +```sh +# A specific plugin token can be used to upload or update a plugin version. For example: +curl \ + -H "Authorization: Bearer the_access_token" \ + https://plugins.qgis.org/plugins/api//version/add/ + +curl \ + -H "Authorization: Bearer the_access_token" \ + https://plugins.qgis.org/plugins/api//version//update +``` ## Contributing