diff --git a/GeoHealthCheck/app.py b/GeoHealthCheck/app.py index c50c80ef..0f944f00 100644 --- a/GeoHealthCheck/app.py +++ b/GeoHealthCheck/app.py @@ -31,6 +31,7 @@ import csv import logging +import json from datetime import datetime, timedelta from StringIO import StringIO from itertools import chain @@ -46,7 +47,7 @@ from healthcheck import sniff_test_resource, run_test_resource from init import App from enums import RESOURCE_TYPES -from models import Resource, Run, ProbeVars, CheckVars, Tag, User +from models import Resource, Run, ProbeVars, CheckVars, Tag, User, Recipient from factory import Factory from util import render_template2, send_email import views @@ -252,7 +253,8 @@ def export(): 'average_response_time': round(r.average_response_time, 2), 'max_response_time': round(r.max_response_time, 2), 'reliability': round(r.reliability, 2), - 'last_report': r.last_run.report + 'last_report': r.last_run.report, + 'recipients': r.dump_recipients() }) return jsonify(json_dict) elif request.url_rule.rule == '/csv': @@ -261,13 +263,18 @@ def export(): header = [ 'resource_type', 'title', 'url', 'ghc_url', 'ghc_json', 'ghc_csv', 'first_run', 'last_run', 'status', 'min_response_time', - 'average_response_time', 'max_response_time', 'reliability' + 'average_response_time', 'max_response_time', 'reliability', + 'recipients' ] writer.writerow(header) for r in response['resources']: ghc_url = '%s%s' % (CONFIG['GHC_SITE_URL'], url_for('get_resource_by_id', identifier=r.identifier)) + # serialize recipients into a string + recipients = r.dump_recipients() + recipients_str = json.dumps(recipients) + writer.writerow([ r.resource_type, r.title, @@ -281,7 +288,8 @@ def export(): '%Y-%m-%dT%H:%M:%SZ'), r.last_run.success, round(r.average_response_time, 2), - round(r.reliability, 2) + round(r.reliability, 2), + recipients_str ]) return output.getvalue(), 200, {'Content-type': 'text/csv'} @@ -324,7 +332,8 @@ def export_resource(identifier): '%Y-%m-%dT%H:%M:%SZ'), 'history_csv': history_csv, 'history_json': history_json, - 'last_report': resource.last_run.report + 'last_report': resource.last_run.report, + 'recipients': resource.dump_recipients() } return jsonify(json_dict) elif 'csv' in request.url_rule.rule: @@ -334,8 +343,13 @@ def export_resource(identifier): 'identifier', 'title', 'url', 'resource_type', 'owner', 'min_response_time', 'average_response_time', 'max_response_tie', 'reliability', 'status', 'first_run', 'last_run', 'history_csv', - 'history_json' + 'history_json', 'recipients', ] + + # serialize recipients into a string + recipients = resource.dump_recipients() + recipients_str = json.dumps(recipients) + writer.writerow(header) writer.writerow([ resource.identifier, @@ -353,7 +367,8 @@ def export_resource(identifier): resource.last_run.checked_datetime.strftime( '%Y-%m-%dT%H:%M:%SZ'), history_csv, - history_json + history_json, + recipients_str ]) return output.getvalue(), 200, {'Content-type': 'text/csv'} @@ -552,6 +567,9 @@ def add(): run_to_add = Run(resource_to_add, result) DB.session.add(resource_to_add) + # prepopulate notifications for current user + resource_to_add.set_recipients('email', [g.user.email]) + if probe_to_add: DB.session.add(probe_to_add) for check_to_add in checks_to_add: @@ -635,13 +653,19 @@ def update(resource_identifier): resource.probe_vars.append(probe_vars) update_counter += 1 - + elif key == 'notify_emails': + resource.set_recipients('email', + [v for v in value if v.strip()]) + elif key == 'notify_webhooks': + resource.set_recipients('webhook', + [v for v in value if v.strip()]) elif getattr(resource, key) != resource_identifier_dict[key]: # Update other resource attrs, mainly 'name' setattr(resource, key, resource_identifier_dict[key]) update_counter += 1 except Exception as err: + LOGGER.error("Cannot update resource: %s", err, exc_info=err) DB.session.rollback() status = str(err) update_counter = 0 @@ -695,8 +719,14 @@ def edit_resource(resource_identifier): probes_avail = views.get_probes_avail(resource.resource_type, resource) - return render_template('edit_resource.html', lang=g.current_lang, - resource=resource, probes_avail=probes_avail) + suggestions = json.dumps(Recipient.get_suggestions('email', + g.user.username)) + + return render_template('edit_resource.html', + lang=g.current_lang, + resource=resource, + suggestions=suggestions, + probes_avail=probes_avail) @APP.route('/resource//delete') @@ -718,7 +748,7 @@ def delete(resource_identifier): for run in runs: DB.session.delete(run) - + resource.clear_recipients() DB.session.delete(resource) try: diff --git a/GeoHealthCheck/migrations/__init__.py b/GeoHealthCheck/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/GeoHealthCheck/migrations/versions/2638c2a40625_.py b/GeoHealthCheck/migrations/versions/2638c2a40625_.py index 398c544f..b729043d 100644 --- a/GeoHealthCheck/migrations/versions/2638c2a40625_.py +++ b/GeoHealthCheck/migrations/versions/2638c2a40625_.py @@ -9,7 +9,7 @@ import sqlalchemy as sa import imp import os - +from GeoHealthCheck.migrations import alembic_helpers # revision identifiers, used by Alembic. revision = '2638c2a40625' @@ -17,9 +17,6 @@ branch_labels = None depends_on = None -alembic_helpers = imp.load_source('alembic_helpers', ( - os.getcwd() + '/' + op.get_context().script.dir + '/alembic_helpers.py')) - def upgrade(): if not alembic_helpers.table_has_column('resource', 'active'): diff --git a/GeoHealthCheck/migrations/versions/496427d03f87_.py b/GeoHealthCheck/migrations/versions/496427d03f87_.py index 9fa21838..c4d290c4 100644 --- a/GeoHealthCheck/migrations/versions/496427d03f87_.py +++ b/GeoHealthCheck/migrations/versions/496427d03f87_.py @@ -9,9 +9,7 @@ import sqlalchemy as sa import imp import os - -alembic_helpers = imp.load_source('alembic_helpers', ( - os.getcwd() + '/' + op.get_context().script.dir + '/alembic_helpers.py')) +from GeoHealthCheck.migrations import alembic_helpers # revision identifiers, used by Alembic. revision = '496427d03f87' diff --git a/GeoHealthCheck/migrations/versions/992013af402f_.py b/GeoHealthCheck/migrations/versions/992013af402f_.py index 25db2a31..cd221963 100644 --- a/GeoHealthCheck/migrations/versions/992013af402f_.py +++ b/GeoHealthCheck/migrations/versions/992013af402f_.py @@ -9,9 +9,7 @@ import sqlalchemy as sa import imp import os - -alembic_helpers = imp.load_source('alembic_helpers', ( - os.getcwd() + '/' + op.get_context().script.dir + '/alembic_helpers.py')) +from GeoHealthCheck.migrations import alembic_helpers # revision identifiers, used by Alembic. revision = '992013af402f' diff --git a/GeoHealthCheck/migrations/versions/__init__.py b/GeoHealthCheck/migrations/versions/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/GeoHealthCheck/migrations/versions/bb91fb332c36_.py b/GeoHealthCheck/migrations/versions/bb91fb332c36_.py new file mode 100644 index 00000000..f3f023c9 --- /dev/null +++ b/GeoHealthCheck/migrations/versions/bb91fb332c36_.py @@ -0,0 +1,46 @@ +"""empty message + +Revision ID: bb91fb332c36 +Revises: f2bac0a1cdaa +Create Date: 2018-02-13 17:08:09.313113 + +""" +from alembic import op +import sqlalchemy as sa +from GeoHealthCheck.migrations import alembic_helpers + + +# revision identifiers, used by Alembic. +revision = 'bb91fb332c36' +down_revision = '2638c2a40625' +branch_labels = None +depends_on = None + + +def upgrade(): + if not alembic_helpers.tables_exist(['recipient']): + print('creating Recipient table(s)') + + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('recipient', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('channel', sa.Enum('email', 'webhook', name='recipient_channel_types'), nullable=False), + sa.Column('location', sa.Text(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + + op.create_table('resourcenotification', + sa.Column('resource_id', sa.Integer(), nullable=False), + sa.Column('recipient_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['resource_id'], ['resource.identifier'], ), + sa.ForeignKeyConstraint(['recipient_id'], ['recipient.id'], ), + sa.PrimaryKeyConstraint('resource_id', 'recipient_id') + ) + # ### end Alembic commands ### + else: + print('Tables for recipient support already present, will not create them') + + +def downgrade(): + op.drop_table('resourcenotification') + op.drop_table('recipient') diff --git a/GeoHealthCheck/models.py b/GeoHealthCheck/models.py index 59faa443..d26ff7ee 100644 --- a/GeoHealthCheck/models.py +++ b/GeoHealthCheck/models.py @@ -30,16 +30,19 @@ import json import logging +from flask_babel import gettext as _ from datetime import datetime -from sqlalchemy import func +from sqlalchemy import func, and_ from sqlalchemy.orm import deferred +from sqlalchemy.orm.exc import MultipleResultsFound, NoResultFound import util from enums import RESOURCE_TYPES from factory import Factory from init import App from notifications import notify +from wtforms.validators import Email, ValidationError DB = App.get_db() LOGGER = logging.getLogger(__name__) @@ -186,6 +189,150 @@ def __repr__(self): DB.ForeignKey('resource.identifier'))) +def _validate_webhook(value): + from GeoHealthCheck.notifications import _parse_webhook_location + try: + _parse_webhook_location(value) + except ValueError, err: + raise ValidationError('{}: {}'.format(value, err)) + return value + + +def _validate_email(value): + if not value: + raise ValidationError("Email cannot be empty value") + try: + if not value.strip(): + raise ValidationError("Email cannot be empty value") + except AttributeError: + raise ValidationError("Email cannot be empty value") + + v = Email() + + class dummy_value(object): + data = value + + @staticmethod + def gettext(*args, **kwargs): + return _(*args, **kwargs) + dummy_form = None + v(dummy_form, dummy_value()) + + +class Recipient(DB.Model): + """ + Notification recipient + """ + TYPE_EMAIL = 'email' + TYPE_WEBHOOK = 'webhook' + TYPES = (TYPE_EMAIL, TYPE_WEBHOOK,) + __tablename__ = 'recipient' + VALIDATORS = {TYPE_EMAIL: [_validate_email], + TYPE_WEBHOOK: [_validate_webhook]} + + id = DB.Column(DB.Integer, primary_key=True) + # channel type. email for now, more may come later + channel = DB.Column(DB.Enum(*TYPES, name='recipient_channel_types'), + default=TYPE_EMAIL, nullable=False) + # recipient's identification, payload + # this can be url, or more rich configuration, depending on channel + location = DB.Column(DB.Text, nullable=False) + resources = DB.relationship('Resource', + secondary='resourcenotification', + lazy='dynamic', + backref=DB.backref('recipients', + lazy='dynamic')) + + def __init__(self, channel, location): + self.channel = channel + self.location = location + + @classmethod + def validate(cls, channel, value): + """ + Validate if provided value is correct for given channel + """ + try: + validators = cls.VALIDATORS[channel] + except KeyError: + return + + for v in validators: + try: + v(value) + except (ValidationError, TypeError), err: + raise ValueError("Bad value: {}".format(err), err) + + def is_email(self): + return self.channel == self.TYPE_EMAIL + + def is_webhook(self): + return self.channel == self.TYPE_WEBHOOK + + @classmethod + def burry_dead(cls): + RN = ResourceNotification + q = DB.session.query(cls)\ + .join(RN, + RN.recipient_id == cls.id, + isouter=True)\ + .filter(RN.recipient_id.is_(None)) + for item in q: + DB.session.delete(item) + + @classmethod + def get_or_create(cls, channel, location): + try: + cls.validate(channel, location) + except ValidationError, err: + raise ValueError("invalid value {}: {}".format(location, err)) + + try: + r = DB.session.query(cls)\ + .filter(and_(cls.channel == channel, + cls.location == location))\ + .one() + except (MultipleResultsFound, NoResultFound,): + r = cls(channel=channel, location=location) + DB.session.add(r) + DB.session.flush() + return r + + @classmethod + def get_suggestions(cls, channel, for_user): + """ + Return list of values to autocomplete for specific user + and channel. + """ + Rcp = cls + Res = Resource + ResNot = ResourceNotification + + q = DB.session.query(Rcp.location)\ + .join(ResNot, ResNot.recipient_id == Rcp.id)\ + .join(Res, Res.identifier == ResNot.resource_id)\ + .group_by(Rcp.location)\ + .filter(and_(Res.owner_identifier == for_user, + Rcp.channel == channel)) + return [item[0] for item in q] + + +class ResourceNotification(DB.Model): + """ + m2m for Recipient <-> Resource + """ + __tablename__ = 'resourcenotification' + + resource_id = DB.Column(DB.Integer, + DB.ForeignKey('resource.identifier'), + primary_key=True) + recipient_id = DB.Column(DB.Integer, + DB.ForeignKey('recipient.id'), + primary_key=True) + resource = DB.relationship('Resource', lazy=False) + recipient = DB.relationship('Recipient', lazy=False) + + class Resource(DB.Model): """HTTP accessible resource""" @@ -317,6 +464,68 @@ def success_to_colors(self): colors.append('#D9534F') # red return colors + def clear_recipients(self, channel=None, burry_dead=True): + RN = ResourceNotification + Rcp = Recipient + if channel: + # clear specific channel + to_delete = self.get_recipients(channel) + if to_delete: + to_del_rcp = DB.session.query(RN)\ + .join(Rcp, + Rcp.id == RN.recipient_id)\ + .filter( + and_(RN.resource_id == + self.identifier, + Rcp.channel == channel, + Rcp.location.in_(to_delete)) + ) + else: + to_del_rcp = [] + else: + # remove all m2m connections for Resource<->Recipient + to_del_rcp = DB.session.query(RN)\ + .join(Rcp, + Rcp.id == RN.recipient_id)\ + .filter( + RN.resource_id == + self.identifier, + ) + for rcp_ntf in to_del_rcp: + DB.session.delete(rcp_ntf) + if burry_dead: + Rcp.burry_dead() + DB.session.flush() + + def get_recipients(self, channel): + q = self.recipients + return [item.location for item in q if item.channel == channel] + + def set_recipients(self, channel, items): + + # create new rcp first + to_add = [] + for item in items: + to_add.append(Recipient.get_or_create(channel, item)) + + self.clear_recipients(channel, burry_dead=False) + + for r in to_add: + self.recipients.append(r) + DB.session.flush() + Recipient.burry_dead() + DB.session.flush() + return self.get_recipients(channel) + + def dump_recipients(self): + """ + Return dictionary with channel ->recipients mapping + """ + out = {} + for c in Recipient.TYPES: + out[c] = self.get_recipients(c) + return out + class User(DB.Model): """user accounts""" @@ -592,9 +801,9 @@ def db_commit(): try: notify(APP.config, resource, run1, last_run_success) except Exception as err: - # Don't bail out on failure in order to commit the Run - msg = str(err) - print('error notifying: %s' % msg) + LOGGER.error("Cannot send notifications: %s", + err, + exc_info=err) print('END - Running health check tests on %s' % datetime.utcnow().isoformat()) diff --git a/GeoHealthCheck/notifications.py b/GeoHealthCheck/notifications.py index 345485ca..82547169 100644 --- a/GeoHealthCheck/notifications.py +++ b/GeoHealthCheck/notifications.py @@ -1,3 +1,6 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + # ================================================================= # # Authors: Tom Kralidis @@ -28,46 +31,42 @@ # ================================================================= from email.mime.text import MIMEText +import types import email.utils import logging import smtplib +import json +import requests from flask_babel import gettext from util import render_template2 LOGGER = logging.getLogger(__name__) -def notify(config, resource, run, last_run_success): - """execute a notification""" - - status_changed = False - - this_run_success = run.success +def do_email(config, resource, run, status_changed, result): + # List of global email addresses to notify, may be list or + # comma-separated str "To" needs comma-separated list, + # while sendmail() requires list... - if last_run_success and not this_run_success: - result = gettext('Failing') - elif not last_run_success and this_run_success: - result = gettext('Fixed') - elif not last_run_success and not this_run_success: - result = gettext('Still Failing') - elif last_run_success and this_run_success: - result = gettext('Passing') + if isinstance(config['GHC_NOTIFICATIONS_EMAIL'], types.StringTypes): + config['GHC_NOTIFICATIONS_EMAIL'] = \ + config['GHC_NOTIFICATIONS_EMAIL'].split(',') - if result != gettext('Passing'): - status_changed = True + # this should always be a list + global_notifications = config['GHC_NOTIFICATIONS_EMAIL'] or [] + if not isinstance(global_notifications, (list, tuple, set,)): + raise TypeError("Cannot use {} as list of emails".format( + type(global_notifications))) - # Check if still 'Still Failing' result should be notified - if result == gettext('Still Failing') \ - and not config['GHC_NOTIFICATIONS_VERBOSITY']: - # Receive just 'Failing' and 'Fixed' notifications - status_changed = False + notifications_email = global_notifications +\ + resource.get_recipients('email') - if not status_changed: + if not notifications_email: + LOGGER.warning("No emails for notification set for resource %s", + resource.identifier) return - print('Notifying: status changed: result=%s' % result) - template_vars = { 'result': result, 'config': config, @@ -76,44 +75,195 @@ def notify(config, resource, run, last_run_success): } msgbody = render_template2('notification_email.txt', template_vars) - msg = MIMEText(msgbody) - msg['From'] = email.utils.formataddr((config['GHC_SITE_TITLE'], config['GHC_ADMIN_EMAIL'])) - - # List of email addresses to notify, may be list or comma-separated str - # "To" needs comma-separated list, while sendmail() requires list... - notifications_email = config['GHC_NOTIFICATIONS_EMAIL'] - if type(notifications_email) is list: - notifications_email = ','.join(config['GHC_NOTIFICATIONS_EMAIL']) - - if type(config['GHC_NOTIFICATIONS_EMAIL']) is str: - config['GHC_NOTIFICATIONS_EMAIL'] = \ - config['GHC_NOTIFICATIONS_EMAIL'].split(',') - - msg['To'] = notifications_email - + msg['To'] = ','.join(notifications_email) msg['Subject'] = '[%s] %s: %s' % (config['GHC_SITE_TITLE'], result, resource.title) + if not config.get('GHC_SMTP') or not\ + (any([config['GHC_SMTP'][k] for k in ('port', + 'server', + 'username', + 'password',)])): + + LOGGER.warning("No SMTP configuration. Not sending to %s", + notifications_email) + print(msg.as_string()) + return + server = smtplib.SMTP(config['GHC_SMTP']['server'], config['GHC_SMTP']['port']) if config['DEBUG']: server.set_debuglevel(True) - if config['GHC_SMTP']['tls']: - server.starttls() + try: + if config['GHC_SMTP']['tls']: + server.starttls() + else: + server.connect() + except Exception, err: + LOGGER.exception("Cannot connect to smtp: %s[:%s]: %s", + config['GHC_SMTP']['server'], + config['GHC_SMTP']['port'], + err, + exc_info=err) + return + try: server.login(config['GHC_SMTP']['username'], config['GHC_SMTP']['password']) + except Exception, err: + LOGGER.exception("Cannot log in to smtp: %s", err, + exc_info=err) try: server.sendmail(config['GHC_ADMIN_EMAIL'], config['GHC_NOTIFICATIONS_EMAIL'], msg.as_string()) except Exception as err: - LOGGER.exception(str(err)) + LOGGER.exception(str(err), exc_info=err) finally: server.quit() - return True + +def _parse_line(_line): + try: + k, v = _line.split('=', 1) + return {k: v} + except (IndexError, ValueError,): + raise ValueError("Invalid line: {}".format(_line)) + + +def _parse_webhook_location(value): + """ + Parse Recipient.location and returns tuple of url and params + + location should be in form + + URL + + PAYLOAD + + where PAYLOAD is a list of fields and values in form + + FIELD_NAME=FIELD_VALUE + + alternatively, it can be dictionary serialized as json + """ + if not value.strip(): + raise ValueError("No payload") + value = value.strip() + url = None + params = {} + lines = value.splitlines() + + for idx, line in enumerate(lines): + if idx == 0: + url = line + elif idx == 1: + if line.strip(): + raise ValueError("Second line should be empty") + elif idx == 2: + try: + params = json.loads('\n'.join(lines[2:])) + break + except (TypeError, ValueError,): + params.update(_parse_line(line)) + else: + params.update(_parse_line(line)) + + if url is None: + raise ValueError("Cannot parse url") + return url, params, + + +def do_webhook(config, resource, run, status_changed, result): + """ + Process webhook recipients for resource + + location should be in format: + + URL + + [PAYLOAD] + + There's blank line between URL and PAYLOAD. PAYLOAD + should be either json or list of field=value items + in each line. + + Webhook's request is POST send to url with payload containing + PAYLOAD + and fields: + + ghc.result=(result of test) + ghc.resource.url=(url of resource) + ghc.resource.title=(title of resource) + + """ + recipients = resource.get_recipients('webhook') + if not recipients: + return + for rcp in recipients: + try: + url, params = _parse_webhook_location(rcp) + except ValueError, err: + LOGGER.warning("Cannot send to {}: {}" + .format(rcp, err), exc_info=err) + + resource_view = '{}/resource/{}'.format( + config['GHC_SITE_URL'], + resource.identifier) + + params['ghc.result'] = result + params['ghc.resource.url'] = resource.url + params['ghc.resource.title'] = resource.title + params['ghc.resource.type'] = resource.resource_type + params['ghc.resource.view'] = resource_view + + try: + r = requests.post(url, params) + LOGGER.info("webhook deployed, got %s as reposnse", + r) + except requests.exceptions.RequestException, err: + LOGGER.warning("cannot deploy webhook %s: %s", + rcp, err, exc_info=err) + + +def notify(config, resource, run, last_run_success): + """execute a notification""" + + status_changed = False + + this_run_success = run.success + + if last_run_success and not this_run_success: + result = gettext('Failing') + elif not last_run_success and this_run_success: + result = gettext('Fixed') + elif not last_run_success and not this_run_success: + result = gettext('Still Failing') + elif last_run_success and this_run_success: + result = gettext('Passing') + + if result != gettext('Passing'): + status_changed = True + + # Check if still 'Still Failing' result should be notified + if result == gettext('Still Failing') \ + and not config['GHC_NOTIFICATIONS_VERBOSITY']: + # Receive just 'Failing' and 'Fixed' notifications + status_changed = False + + if not status_changed: + return + + print('Notifying: status changed: result=%s' % result) + + # run all channels, actual recipients will be filtered there + for chann_handler in (do_email, do_webhook,): + try: + chann_handler(config, resource, run, status_changed, result) + except Exception, err: + LOGGER.warning("couldn't run notification for %s: %s", + chann_handler.func_name, err, exc_info=err) diff --git a/GeoHealthCheck/static/lib/vendor/jqueryui/jquery-ui.css b/GeoHealthCheck/static/lib/vendor/jqueryui/jquery-ui.css new file mode 100644 index 00000000..4f52217c --- /dev/null +++ b/GeoHealthCheck/static/lib/vendor/jqueryui/jquery-ui.css @@ -0,0 +1,151 @@ +/*! jQuery UI - v1.12.1 - 2018-02-14 +* http://jqueryui.com +* Includes: core.css, autocomplete.css, menu.css +* Copyright jQuery Foundation and other contributors; Licensed MIT */ + +/* Layout helpers +----------------------------------*/ +.ui-helper-hidden { + display: none; +} +.ui-helper-hidden-accessible { + border: 0; + clip: rect(0 0 0 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + width: 1px; +} +.ui-helper-reset { + margin: 0; + padding: 0; + border: 0; + outline: 0; + line-height: 1.3; + text-decoration: none; + font-size: 100%; + list-style: none; +} +.ui-helper-clearfix:before, +.ui-helper-clearfix:after { + content: ""; + display: table; + border-collapse: collapse; +} +.ui-helper-clearfix:after { + clear: both; +} +.ui-helper-zfix { + width: 100%; + height: 100%; + top: 0; + left: 0; + position: absolute; + opacity: 0; + filter:Alpha(Opacity=0); /* support: IE8 */ +} + +.ui-front { + z-index: 100; +} + + +/* Interaction Cues +----------------------------------*/ +.ui-state-disabled { + cursor: default !important; + pointer-events: none; +} + + +/* Icons +----------------------------------*/ +.ui-icon { + display: inline-block; + vertical-align: middle; + margin-top: -.25em; + position: relative; + text-indent: -99999px; + overflow: hidden; + background-repeat: no-repeat; +} + +.ui-widget-icon-block { + left: 50%; + margin-left: -8px; + display: block; +} + +/* Misc visuals +----------------------------------*/ + +/* Overlays */ +.ui-widget-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; +} +.ui-autocomplete { + position: absolute; + top: 0; + left: 0; + cursor: default; +} +.ui-menu { + list-style: none; + padding: 0; + margin: 0; + display: block; + outline: 0; +} +.ui-menu .ui-menu { + position: absolute; +} +.ui-menu .ui-menu-item { + margin: 0; + cursor: pointer; + /* support: IE10, see #8844 */ + list-style-image: url("data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"); +} +.ui-menu .ui-menu-item-wrapper { + position: relative; + padding: 3px 1em 3px .4em; +} +.ui-menu .ui-menu-divider { + margin: 5px 0; + height: 0; + font-size: 0; + line-height: 0; + border-width: 1px 0 0 0; +} +.ui-menu .ui-state-focus, +.ui-menu .ui-state-active { + margin: -1px; +} + +/* icon support */ +.ui-menu-icons { + position: relative; +} +.ui-menu-icons .ui-menu-item-wrapper { + padding-left: 2em; +} + +/* left-aligned */ +.ui-menu .ui-icon { + position: absolute; + top: 0; + bottom: 0; + left: .2em; + margin: auto 0; +} + +/* right-aligned */ +.ui-menu .ui-menu-icon { + left: auto; + right: 0; +} diff --git a/GeoHealthCheck/static/lib/vendor/jqueryui/jquery-ui.js b/GeoHealthCheck/static/lib/vendor/jqueryui/jquery-ui.js new file mode 100644 index 00000000..4cfcb5fb --- /dev/null +++ b/GeoHealthCheck/static/lib/vendor/jqueryui/jquery-ui.js @@ -0,0 +1,2659 @@ +/*! jQuery UI - v1.12.1 - 2018-02-14 +* http://jqueryui.com +* Includes: widget.js, position.js, keycode.js, unique-id.js, widgets/autocomplete.js, widgets/menu.js +* Copyright jQuery Foundation and other contributors; Licensed MIT */ + +(function( factory ) { + if ( typeof define === "function" && define.amd ) { + + // AMD. Register as an anonymous module. + define([ "jquery" ], factory ); + } else { + + // Browser globals + factory( jQuery ); + } +}(function( $ ) { + +$.ui = $.ui || {}; + +var version = $.ui.version = "1.12.1"; + + +/*! + * jQuery UI Widget 1.12.1 + * http://jqueryui.com + * + * Copyright jQuery Foundation and other contributors + * Released under the MIT license. + * http://jquery.org/license + */ + +//>>label: Widget +//>>group: Core +//>>description: Provides a factory for creating stateful widgets with a common API. +//>>docs: http://api.jqueryui.com/jQuery.widget/ +//>>demos: http://jqueryui.com/widget/ + + + +var widgetUuid = 0; +var widgetSlice = Array.prototype.slice; + +$.cleanData = ( function( orig ) { + return function( elems ) { + var events, elem, i; + for ( i = 0; ( elem = elems[ i ] ) != null; i++ ) { + try { + + // Only trigger remove when necessary to save time + events = $._data( elem, "events" ); + if ( events && events.remove ) { + $( elem ).triggerHandler( "remove" ); + } + + // Http://bugs.jquery.com/ticket/8235 + } catch ( e ) {} + } + orig( elems ); + }; +} )( $.cleanData ); + +$.widget = function( name, base, prototype ) { + var existingConstructor, constructor, basePrototype; + + // ProxiedPrototype allows the provided prototype to remain unmodified + // so that it can be used as a mixin for multiple widgets (#8876) + var proxiedPrototype = {}; + + var namespace = name.split( "." )[ 0 ]; + name = name.split( "." )[ 1 ]; + var fullName = namespace + "-" + name; + + if ( !prototype ) { + prototype = base; + base = $.Widget; + } + + if ( $.isArray( prototype ) ) { + prototype = $.extend.apply( null, [ {} ].concat( prototype ) ); + } + + // Create selector for plugin + $.expr[ ":" ][ fullName.toLowerCase() ] = function( elem ) { + return !!$.data( elem, fullName ); + }; + + $[ namespace ] = $[ namespace ] || {}; + existingConstructor = $[ namespace ][ name ]; + constructor = $[ namespace ][ name ] = function( options, element ) { + + // Allow instantiation without "new" keyword + if ( !this._createWidget ) { + return new constructor( options, element ); + } + + // Allow instantiation without initializing for simple inheritance + // must use "new" keyword (the code above always passes args) + if ( arguments.length ) { + this._createWidget( options, element ); + } + }; + + // Extend with the existing constructor to carry over any static properties + $.extend( constructor, existingConstructor, { + version: prototype.version, + + // Copy the object used to create the prototype in case we need to + // redefine the widget later + _proto: $.extend( {}, prototype ), + + // Track widgets that inherit from this widget in case this widget is + // redefined after a widget inherits from it + _childConstructors: [] + } ); + + basePrototype = new base(); + + // We need to make the options hash a property directly on the new instance + // otherwise we'll modify the options hash on the prototype that we're + // inheriting from + basePrototype.options = $.widget.extend( {}, basePrototype.options ); + $.each( prototype, function( prop, value ) { + if ( !$.isFunction( value ) ) { + proxiedPrototype[ prop ] = value; + return; + } + proxiedPrototype[ prop ] = ( function() { + function _super() { + return base.prototype[ prop ].apply( this, arguments ); + } + + function _superApply( args ) { + return base.prototype[ prop ].apply( this, args ); + } + + return function() { + var __super = this._super; + var __superApply = this._superApply; + var returnValue; + + this._super = _super; + this._superApply = _superApply; + + returnValue = value.apply( this, arguments ); + + this._super = __super; + this._superApply = __superApply; + + return returnValue; + }; + } )(); + } ); + constructor.prototype = $.widget.extend( basePrototype, { + + // TODO: remove support for widgetEventPrefix + // always use the name + a colon as the prefix, e.g., draggable:start + // don't prefix for widgets that aren't DOM-based + widgetEventPrefix: existingConstructor ? ( basePrototype.widgetEventPrefix || name ) : name + }, proxiedPrototype, { + constructor: constructor, + namespace: namespace, + widgetName: name, + widgetFullName: fullName + } ); + + // If this widget is being redefined then we need to find all widgets that + // are inheriting from it and redefine all of them so that they inherit from + // the new version of this widget. We're essentially trying to replace one + // level in the prototype chain. + if ( existingConstructor ) { + $.each( existingConstructor._childConstructors, function( i, child ) { + var childPrototype = child.prototype; + + // Redefine the child widget using the same prototype that was + // originally used, but inherit from the new version of the base + $.widget( childPrototype.namespace + "." + childPrototype.widgetName, constructor, + child._proto ); + } ); + + // Remove the list of existing child constructors from the old constructor + // so the old child constructors can be garbage collected + delete existingConstructor._childConstructors; + } else { + base._childConstructors.push( constructor ); + } + + $.widget.bridge( name, constructor ); + + return constructor; +}; + +$.widget.extend = function( target ) { + var input = widgetSlice.call( arguments, 1 ); + var inputIndex = 0; + var inputLength = input.length; + var key; + var value; + + for ( ; inputIndex < inputLength; inputIndex++ ) { + for ( key in input[ inputIndex ] ) { + value = input[ inputIndex ][ key ]; + if ( input[ inputIndex ].hasOwnProperty( key ) && value !== undefined ) { + + // Clone objects + if ( $.isPlainObject( value ) ) { + target[ key ] = $.isPlainObject( target[ key ] ) ? + $.widget.extend( {}, target[ key ], value ) : + + // Don't extend strings, arrays, etc. with objects + $.widget.extend( {}, value ); + + // Copy everything else by reference + } else { + target[ key ] = value; + } + } + } + } + return target; +}; + +$.widget.bridge = function( name, object ) { + var fullName = object.prototype.widgetFullName || name; + $.fn[ name ] = function( options ) { + var isMethodCall = typeof options === "string"; + var args = widgetSlice.call( arguments, 1 ); + var returnValue = this; + + if ( isMethodCall ) { + + // If this is an empty collection, we need to have the instance method + // return undefined instead of the jQuery instance + if ( !this.length && options === "instance" ) { + returnValue = undefined; + } else { + this.each( function() { + var methodValue; + var instance = $.data( this, fullName ); + + if ( options === "instance" ) { + returnValue = instance; + return false; + } + + if ( !instance ) { + return $.error( "cannot call methods on " + name + + " prior to initialization; " + + "attempted to call method '" + options + "'" ); + } + + if ( !$.isFunction( instance[ options ] ) || options.charAt( 0 ) === "_" ) { + return $.error( "no such method '" + options + "' for " + name + + " widget instance" ); + } + + methodValue = instance[ options ].apply( instance, args ); + + if ( methodValue !== instance && methodValue !== undefined ) { + returnValue = methodValue && methodValue.jquery ? + returnValue.pushStack( methodValue.get() ) : + methodValue; + return false; + } + } ); + } + } else { + + // Allow multiple hashes to be passed on init + if ( args.length ) { + options = $.widget.extend.apply( null, [ options ].concat( args ) ); + } + + this.each( function() { + var instance = $.data( this, fullName ); + if ( instance ) { + instance.option( options || {} ); + if ( instance._init ) { + instance._init(); + } + } else { + $.data( this, fullName, new object( options, this ) ); + } + } ); + } + + return returnValue; + }; +}; + +$.Widget = function( /* options, element */ ) {}; +$.Widget._childConstructors = []; + +$.Widget.prototype = { + widgetName: "widget", + widgetEventPrefix: "", + defaultElement: "
", + + options: { + classes: {}, + disabled: false, + + // Callbacks + create: null + }, + + _createWidget: function( options, element ) { + element = $( element || this.defaultElement || this )[ 0 ]; + this.element = $( element ); + this.uuid = widgetUuid++; + this.eventNamespace = "." + this.widgetName + this.uuid; + + this.bindings = $(); + this.hoverable = $(); + this.focusable = $(); + this.classesElementLookup = {}; + + if ( element !== this ) { + $.data( element, this.widgetFullName, this ); + this._on( true, this.element, { + remove: function( event ) { + if ( event.target === element ) { + this.destroy(); + } + } + } ); + this.document = $( element.style ? + + // Element within the document + element.ownerDocument : + + // Element is window or document + element.document || element ); + this.window = $( this.document[ 0 ].defaultView || this.document[ 0 ].parentWindow ); + } + + this.options = $.widget.extend( {}, + this.options, + this._getCreateOptions(), + options ); + + this._create(); + + if ( this.options.disabled ) { + this._setOptionDisabled( this.options.disabled ); + } + + this._trigger( "create", null, this._getCreateEventData() ); + this._init(); + }, + + _getCreateOptions: function() { + return {}; + }, + + _getCreateEventData: $.noop, + + _create: $.noop, + + _init: $.noop, + + destroy: function() { + var that = this; + + this._destroy(); + $.each( this.classesElementLookup, function( key, value ) { + that._removeClass( value, key ); + } ); + + // We can probably remove the unbind calls in 2.0 + // all event bindings should go through this._on() + this.element + .off( this.eventNamespace ) + .removeData( this.widgetFullName ); + this.widget() + .off( this.eventNamespace ) + .removeAttr( "aria-disabled" ); + + // Clean up events and states + this.bindings.off( this.eventNamespace ); + }, + + _destroy: $.noop, + + widget: function() { + return this.element; + }, + + option: function( key, value ) { + var options = key; + var parts; + var curOption; + var i; + + if ( arguments.length === 0 ) { + + // Don't return a reference to the internal hash + return $.widget.extend( {}, this.options ); + } + + if ( typeof key === "string" ) { + + // Handle nested keys, e.g., "foo.bar" => { foo: { bar: ___ } } + options = {}; + parts = key.split( "." ); + key = parts.shift(); + if ( parts.length ) { + curOption = options[ key ] = $.widget.extend( {}, this.options[ key ] ); + for ( i = 0; i < parts.length - 1; i++ ) { + curOption[ parts[ i ] ] = curOption[ parts[ i ] ] || {}; + curOption = curOption[ parts[ i ] ]; + } + key = parts.pop(); + if ( arguments.length === 1 ) { + return curOption[ key ] === undefined ? null : curOption[ key ]; + } + curOption[ key ] = value; + } else { + if ( arguments.length === 1 ) { + return this.options[ key ] === undefined ? null : this.options[ key ]; + } + options[ key ] = value; + } + } + + this._setOptions( options ); + + return this; + }, + + _setOptions: function( options ) { + var key; + + for ( key in options ) { + this._setOption( key, options[ key ] ); + } + + return this; + }, + + _setOption: function( key, value ) { + if ( key === "classes" ) { + this._setOptionClasses( value ); + } + + this.options[ key ] = value; + + if ( key === "disabled" ) { + this._setOptionDisabled( value ); + } + + return this; + }, + + _setOptionClasses: function( value ) { + var classKey, elements, currentElements; + + for ( classKey in value ) { + currentElements = this.classesElementLookup[ classKey ]; + if ( value[ classKey ] === this.options.classes[ classKey ] || + !currentElements || + !currentElements.length ) { + continue; + } + + // We are doing this to create a new jQuery object because the _removeClass() call + // on the next line is going to destroy the reference to the current elements being + // tracked. We need to save a copy of this collection so that we can add the new classes + // below. + elements = $( currentElements.get() ); + this._removeClass( currentElements, classKey ); + + // We don't use _addClass() here, because that uses this.options.classes + // for generating the string of classes. We want to use the value passed in from + // _setOption(), this is the new value of the classes option which was passed to + // _setOption(). We pass this value directly to _classes(). + elements.addClass( this._classes( { + element: elements, + keys: classKey, + classes: value, + add: true + } ) ); + } + }, + + _setOptionDisabled: function( value ) { + this._toggleClass( this.widget(), this.widgetFullName + "-disabled", null, !!value ); + + // If the widget is becoming disabled, then nothing is interactive + if ( value ) { + this._removeClass( this.hoverable, null, "ui-state-hover" ); + this._removeClass( this.focusable, null, "ui-state-focus" ); + } + }, + + enable: function() { + return this._setOptions( { disabled: false } ); + }, + + disable: function() { + return this._setOptions( { disabled: true } ); + }, + + _classes: function( options ) { + var full = []; + var that = this; + + options = $.extend( { + element: this.element, + classes: this.options.classes || {} + }, options ); + + function processClassString( classes, checkOption ) { + var current, i; + for ( i = 0; i < classes.length; i++ ) { + current = that.classesElementLookup[ classes[ i ] ] || $(); + if ( options.add ) { + current = $( $.unique( current.get().concat( options.element.get() ) ) ); + } else { + current = $( current.not( options.element ).get() ); + } + that.classesElementLookup[ classes[ i ] ] = current; + full.push( classes[ i ] ); + if ( checkOption && options.classes[ classes[ i ] ] ) { + full.push( options.classes[ classes[ i ] ] ); + } + } + } + + this._on( options.element, { + "remove": "_untrackClassesElement" + } ); + + if ( options.keys ) { + processClassString( options.keys.match( /\S+/g ) || [], true ); + } + if ( options.extra ) { + processClassString( options.extra.match( /\S+/g ) || [] ); + } + + return full.join( " " ); + }, + + _untrackClassesElement: function( event ) { + var that = this; + $.each( that.classesElementLookup, function( key, value ) { + if ( $.inArray( event.target, value ) !== -1 ) { + that.classesElementLookup[ key ] = $( value.not( event.target ).get() ); + } + } ); + }, + + _removeClass: function( element, keys, extra ) { + return this._toggleClass( element, keys, extra, false ); + }, + + _addClass: function( element, keys, extra ) { + return this._toggleClass( element, keys, extra, true ); + }, + + _toggleClass: function( element, keys, extra, add ) { + add = ( typeof add === "boolean" ) ? add : extra; + var shift = ( typeof element === "string" || element === null ), + options = { + extra: shift ? keys : extra, + keys: shift ? element : keys, + element: shift ? this.element : element, + add: add + }; + options.element.toggleClass( this._classes( options ), add ); + return this; + }, + + _on: function( suppressDisabledCheck, element, handlers ) { + var delegateElement; + var instance = this; + + // No suppressDisabledCheck flag, shuffle arguments + if ( typeof suppressDisabledCheck !== "boolean" ) { + handlers = element; + element = suppressDisabledCheck; + suppressDisabledCheck = false; + } + + // No element argument, shuffle and use this.element + if ( !handlers ) { + handlers = element; + element = this.element; + delegateElement = this.widget(); + } else { + element = delegateElement = $( element ); + this.bindings = this.bindings.add( element ); + } + + $.each( handlers, function( event, handler ) { + function handlerProxy() { + + // Allow widgets to customize the disabled handling + // - disabled as an array instead of boolean + // - disabled class as method for disabling individual parts + if ( !suppressDisabledCheck && + ( instance.options.disabled === true || + $( this ).hasClass( "ui-state-disabled" ) ) ) { + return; + } + return ( typeof handler === "string" ? instance[ handler ] : handler ) + .apply( instance, arguments ); + } + + // Copy the guid so direct unbinding works + if ( typeof handler !== "string" ) { + handlerProxy.guid = handler.guid = + handler.guid || handlerProxy.guid || $.guid++; + } + + var match = event.match( /^([\w:-]*)\s*(.*)$/ ); + var eventName = match[ 1 ] + instance.eventNamespace; + var selector = match[ 2 ]; + + if ( selector ) { + delegateElement.on( eventName, selector, handlerProxy ); + } else { + element.on( eventName, handlerProxy ); + } + } ); + }, + + _off: function( element, eventName ) { + eventName = ( eventName || "" ).split( " " ).join( this.eventNamespace + " " ) + + this.eventNamespace; + element.off( eventName ).off( eventName ); + + // Clear the stack to avoid memory leaks (#10056) + this.bindings = $( this.bindings.not( element ).get() ); + this.focusable = $( this.focusable.not( element ).get() ); + this.hoverable = $( this.hoverable.not( element ).get() ); + }, + + _delay: function( handler, delay ) { + function handlerProxy() { + return ( typeof handler === "string" ? instance[ handler ] : handler ) + .apply( instance, arguments ); + } + var instance = this; + return setTimeout( handlerProxy, delay || 0 ); + }, + + _hoverable: function( element ) { + this.hoverable = this.hoverable.add( element ); + this._on( element, { + mouseenter: function( event ) { + this._addClass( $( event.currentTarget ), null, "ui-state-hover" ); + }, + mouseleave: function( event ) { + this._removeClass( $( event.currentTarget ), null, "ui-state-hover" ); + } + } ); + }, + + _focusable: function( element ) { + this.focusable = this.focusable.add( element ); + this._on( element, { + focusin: function( event ) { + this._addClass( $( event.currentTarget ), null, "ui-state-focus" ); + }, + focusout: function( event ) { + this._removeClass( $( event.currentTarget ), null, "ui-state-focus" ); + } + } ); + }, + + _trigger: function( type, event, data ) { + var prop, orig; + var callback = this.options[ type ]; + + data = data || {}; + event = $.Event( event ); + event.type = ( type === this.widgetEventPrefix ? + type : + this.widgetEventPrefix + type ).toLowerCase(); + + // The original event may come from any element + // so we need to reset the target on the new event + event.target = this.element[ 0 ]; + + // Copy original event properties over to the new event + orig = event.originalEvent; + if ( orig ) { + for ( prop in orig ) { + if ( !( prop in event ) ) { + event[ prop ] = orig[ prop ]; + } + } + } + + this.element.trigger( event, data ); + return !( $.isFunction( callback ) && + callback.apply( this.element[ 0 ], [ event ].concat( data ) ) === false || + event.isDefaultPrevented() ); + } +}; + +$.each( { show: "fadeIn", hide: "fadeOut" }, function( method, defaultEffect ) { + $.Widget.prototype[ "_" + method ] = function( element, options, callback ) { + if ( typeof options === "string" ) { + options = { effect: options }; + } + + var hasOptions; + var effectName = !options ? + method : + options === true || typeof options === "number" ? + defaultEffect : + options.effect || defaultEffect; + + options = options || {}; + if ( typeof options === "number" ) { + options = { duration: options }; + } + + hasOptions = !$.isEmptyObject( options ); + options.complete = callback; + + if ( options.delay ) { + element.delay( options.delay ); + } + + if ( hasOptions && $.effects && $.effects.effect[ effectName ] ) { + element[ method ]( options ); + } else if ( effectName !== method && element[ effectName ] ) { + element[ effectName ]( options.duration, options.easing, callback ); + } else { + element.queue( function( next ) { + $( this )[ method ](); + if ( callback ) { + callback.call( element[ 0 ] ); + } + next(); + } ); + } + }; +} ); + +var widget = $.widget; + + +/*! + * jQuery UI Position 1.12.1 + * http://jqueryui.com + * + * Copyright jQuery Foundation and other contributors + * Released under the MIT license. + * http://jquery.org/license + * + * http://api.jqueryui.com/position/ + */ + +//>>label: Position +//>>group: Core +//>>description: Positions elements relative to other elements. +//>>docs: http://api.jqueryui.com/position/ +//>>demos: http://jqueryui.com/position/ + + +( function() { +var cachedScrollbarWidth, + max = Math.max, + abs = Math.abs, + rhorizontal = /left|center|right/, + rvertical = /top|center|bottom/, + roffset = /[\+\-]\d+(\.[\d]+)?%?/, + rposition = /^\w+/, + rpercent = /%$/, + _position = $.fn.position; + +function getOffsets( offsets, width, height ) { + return [ + parseFloat( offsets[ 0 ] ) * ( rpercent.test( offsets[ 0 ] ) ? width / 100 : 1 ), + parseFloat( offsets[ 1 ] ) * ( rpercent.test( offsets[ 1 ] ) ? height / 100 : 1 ) + ]; +} + +function parseCss( element, property ) { + return parseInt( $.css( element, property ), 10 ) || 0; +} + +function getDimensions( elem ) { + var raw = elem[ 0 ]; + if ( raw.nodeType === 9 ) { + return { + width: elem.width(), + height: elem.height(), + offset: { top: 0, left: 0 } + }; + } + if ( $.isWindow( raw ) ) { + return { + width: elem.width(), + height: elem.height(), + offset: { top: elem.scrollTop(), left: elem.scrollLeft() } + }; + } + if ( raw.preventDefault ) { + return { + width: 0, + height: 0, + offset: { top: raw.pageY, left: raw.pageX } + }; + } + return { + width: elem.outerWidth(), + height: elem.outerHeight(), + offset: elem.offset() + }; +} + +$.position = { + scrollbarWidth: function() { + if ( cachedScrollbarWidth !== undefined ) { + return cachedScrollbarWidth; + } + var w1, w2, + div = $( "
" + + "
" ), + innerDiv = div.children()[ 0 ]; + + $( "body" ).append( div ); + w1 = innerDiv.offsetWidth; + div.css( "overflow", "scroll" ); + + w2 = innerDiv.offsetWidth; + + if ( w1 === w2 ) { + w2 = div[ 0 ].clientWidth; + } + + div.remove(); + + return ( cachedScrollbarWidth = w1 - w2 ); + }, + getScrollInfo: function( within ) { + var overflowX = within.isWindow || within.isDocument ? "" : + within.element.css( "overflow-x" ), + overflowY = within.isWindow || within.isDocument ? "" : + within.element.css( "overflow-y" ), + hasOverflowX = overflowX === "scroll" || + ( overflowX === "auto" && within.width < within.element[ 0 ].scrollWidth ), + hasOverflowY = overflowY === "scroll" || + ( overflowY === "auto" && within.height < within.element[ 0 ].scrollHeight ); + return { + width: hasOverflowY ? $.position.scrollbarWidth() : 0, + height: hasOverflowX ? $.position.scrollbarWidth() : 0 + }; + }, + getWithinInfo: function( element ) { + var withinElement = $( element || window ), + isWindow = $.isWindow( withinElement[ 0 ] ), + isDocument = !!withinElement[ 0 ] && withinElement[ 0 ].nodeType === 9, + hasOffset = !isWindow && !isDocument; + return { + element: withinElement, + isWindow: isWindow, + isDocument: isDocument, + offset: hasOffset ? $( element ).offset() : { left: 0, top: 0 }, + scrollLeft: withinElement.scrollLeft(), + scrollTop: withinElement.scrollTop(), + width: withinElement.outerWidth(), + height: withinElement.outerHeight() + }; + } +}; + +$.fn.position = function( options ) { + if ( !options || !options.of ) { + return _position.apply( this, arguments ); + } + + // Make a copy, we don't want to modify arguments + options = $.extend( {}, options ); + + var atOffset, targetWidth, targetHeight, targetOffset, basePosition, dimensions, + target = $( options.of ), + within = $.position.getWithinInfo( options.within ), + scrollInfo = $.position.getScrollInfo( within ), + collision = ( options.collision || "flip" ).split( " " ), + offsets = {}; + + dimensions = getDimensions( target ); + if ( target[ 0 ].preventDefault ) { + + // Force left top to allow flipping + options.at = "left top"; + } + targetWidth = dimensions.width; + targetHeight = dimensions.height; + targetOffset = dimensions.offset; + + // Clone to reuse original targetOffset later + basePosition = $.extend( {}, targetOffset ); + + // Force my and at to have valid horizontal and vertical positions + // if a value is missing or invalid, it will be converted to center + $.each( [ "my", "at" ], function() { + var pos = ( options[ this ] || "" ).split( " " ), + horizontalOffset, + verticalOffset; + + if ( pos.length === 1 ) { + pos = rhorizontal.test( pos[ 0 ] ) ? + pos.concat( [ "center" ] ) : + rvertical.test( pos[ 0 ] ) ? + [ "center" ].concat( pos ) : + [ "center", "center" ]; + } + pos[ 0 ] = rhorizontal.test( pos[ 0 ] ) ? pos[ 0 ] : "center"; + pos[ 1 ] = rvertical.test( pos[ 1 ] ) ? pos[ 1 ] : "center"; + + // Calculate offsets + horizontalOffset = roffset.exec( pos[ 0 ] ); + verticalOffset = roffset.exec( pos[ 1 ] ); + offsets[ this ] = [ + horizontalOffset ? horizontalOffset[ 0 ] : 0, + verticalOffset ? verticalOffset[ 0 ] : 0 + ]; + + // Reduce to just the positions without the offsets + options[ this ] = [ + rposition.exec( pos[ 0 ] )[ 0 ], + rposition.exec( pos[ 1 ] )[ 0 ] + ]; + } ); + + // Normalize collision option + if ( collision.length === 1 ) { + collision[ 1 ] = collision[ 0 ]; + } + + if ( options.at[ 0 ] === "right" ) { + basePosition.left += targetWidth; + } else if ( options.at[ 0 ] === "center" ) { + basePosition.left += targetWidth / 2; + } + + if ( options.at[ 1 ] === "bottom" ) { + basePosition.top += targetHeight; + } else if ( options.at[ 1 ] === "center" ) { + basePosition.top += targetHeight / 2; + } + + atOffset = getOffsets( offsets.at, targetWidth, targetHeight ); + basePosition.left += atOffset[ 0 ]; + basePosition.top += atOffset[ 1 ]; + + return this.each( function() { + var collisionPosition, using, + elem = $( this ), + elemWidth = elem.outerWidth(), + elemHeight = elem.outerHeight(), + marginLeft = parseCss( this, "marginLeft" ), + marginTop = parseCss( this, "marginTop" ), + collisionWidth = elemWidth + marginLeft + parseCss( this, "marginRight" ) + + scrollInfo.width, + collisionHeight = elemHeight + marginTop + parseCss( this, "marginBottom" ) + + scrollInfo.height, + position = $.extend( {}, basePosition ), + myOffset = getOffsets( offsets.my, elem.outerWidth(), elem.outerHeight() ); + + if ( options.my[ 0 ] === "right" ) { + position.left -= elemWidth; + } else if ( options.my[ 0 ] === "center" ) { + position.left -= elemWidth / 2; + } + + if ( options.my[ 1 ] === "bottom" ) { + position.top -= elemHeight; + } else if ( options.my[ 1 ] === "center" ) { + position.top -= elemHeight / 2; + } + + position.left += myOffset[ 0 ]; + position.top += myOffset[ 1 ]; + + collisionPosition = { + marginLeft: marginLeft, + marginTop: marginTop + }; + + $.each( [ "left", "top" ], function( i, dir ) { + if ( $.ui.position[ collision[ i ] ] ) { + $.ui.position[ collision[ i ] ][ dir ]( position, { + targetWidth: targetWidth, + targetHeight: targetHeight, + elemWidth: elemWidth, + elemHeight: elemHeight, + collisionPosition: collisionPosition, + collisionWidth: collisionWidth, + collisionHeight: collisionHeight, + offset: [ atOffset[ 0 ] + myOffset[ 0 ], atOffset [ 1 ] + myOffset[ 1 ] ], + my: options.my, + at: options.at, + within: within, + elem: elem + } ); + } + } ); + + if ( options.using ) { + + // Adds feedback as second argument to using callback, if present + using = function( props ) { + var left = targetOffset.left - position.left, + right = left + targetWidth - elemWidth, + top = targetOffset.top - position.top, + bottom = top + targetHeight - elemHeight, + feedback = { + target: { + element: target, + left: targetOffset.left, + top: targetOffset.top, + width: targetWidth, + height: targetHeight + }, + element: { + element: elem, + left: position.left, + top: position.top, + width: elemWidth, + height: elemHeight + }, + horizontal: right < 0 ? "left" : left > 0 ? "right" : "center", + vertical: bottom < 0 ? "top" : top > 0 ? "bottom" : "middle" + }; + if ( targetWidth < elemWidth && abs( left + right ) < targetWidth ) { + feedback.horizontal = "center"; + } + if ( targetHeight < elemHeight && abs( top + bottom ) < targetHeight ) { + feedback.vertical = "middle"; + } + if ( max( abs( left ), abs( right ) ) > max( abs( top ), abs( bottom ) ) ) { + feedback.important = "horizontal"; + } else { + feedback.important = "vertical"; + } + options.using.call( this, props, feedback ); + }; + } + + elem.offset( $.extend( position, { using: using } ) ); + } ); +}; + +$.ui.position = { + fit: { + left: function( position, data ) { + var within = data.within, + withinOffset = within.isWindow ? within.scrollLeft : within.offset.left, + outerWidth = within.width, + collisionPosLeft = position.left - data.collisionPosition.marginLeft, + overLeft = withinOffset - collisionPosLeft, + overRight = collisionPosLeft + data.collisionWidth - outerWidth - withinOffset, + newOverRight; + + // Element is wider than within + if ( data.collisionWidth > outerWidth ) { + + // Element is initially over the left side of within + if ( overLeft > 0 && overRight <= 0 ) { + newOverRight = position.left + overLeft + data.collisionWidth - outerWidth - + withinOffset; + position.left += overLeft - newOverRight; + + // Element is initially over right side of within + } else if ( overRight > 0 && overLeft <= 0 ) { + position.left = withinOffset; + + // Element is initially over both left and right sides of within + } else { + if ( overLeft > overRight ) { + position.left = withinOffset + outerWidth - data.collisionWidth; + } else { + position.left = withinOffset; + } + } + + // Too far left -> align with left edge + } else if ( overLeft > 0 ) { + position.left += overLeft; + + // Too far right -> align with right edge + } else if ( overRight > 0 ) { + position.left -= overRight; + + // Adjust based on position and margin + } else { + position.left = max( position.left - collisionPosLeft, position.left ); + } + }, + top: function( position, data ) { + var within = data.within, + withinOffset = within.isWindow ? within.scrollTop : within.offset.top, + outerHeight = data.within.height, + collisionPosTop = position.top - data.collisionPosition.marginTop, + overTop = withinOffset - collisionPosTop, + overBottom = collisionPosTop + data.collisionHeight - outerHeight - withinOffset, + newOverBottom; + + // Element is taller than within + if ( data.collisionHeight > outerHeight ) { + + // Element is initially over the top of within + if ( overTop > 0 && overBottom <= 0 ) { + newOverBottom = position.top + overTop + data.collisionHeight - outerHeight - + withinOffset; + position.top += overTop - newOverBottom; + + // Element is initially over bottom of within + } else if ( overBottom > 0 && overTop <= 0 ) { + position.top = withinOffset; + + // Element is initially over both top and bottom of within + } else { + if ( overTop > overBottom ) { + position.top = withinOffset + outerHeight - data.collisionHeight; + } else { + position.top = withinOffset; + } + } + + // Too far up -> align with top + } else if ( overTop > 0 ) { + position.top += overTop; + + // Too far down -> align with bottom edge + } else if ( overBottom > 0 ) { + position.top -= overBottom; + + // Adjust based on position and margin + } else { + position.top = max( position.top - collisionPosTop, position.top ); + } + } + }, + flip: { + left: function( position, data ) { + var within = data.within, + withinOffset = within.offset.left + within.scrollLeft, + outerWidth = within.width, + offsetLeft = within.isWindow ? within.scrollLeft : within.offset.left, + collisionPosLeft = position.left - data.collisionPosition.marginLeft, + overLeft = collisionPosLeft - offsetLeft, + overRight = collisionPosLeft + data.collisionWidth - outerWidth - offsetLeft, + myOffset = data.my[ 0 ] === "left" ? + -data.elemWidth : + data.my[ 0 ] === "right" ? + data.elemWidth : + 0, + atOffset = data.at[ 0 ] === "left" ? + data.targetWidth : + data.at[ 0 ] === "right" ? + -data.targetWidth : + 0, + offset = -2 * data.offset[ 0 ], + newOverRight, + newOverLeft; + + if ( overLeft < 0 ) { + newOverRight = position.left + myOffset + atOffset + offset + data.collisionWidth - + outerWidth - withinOffset; + if ( newOverRight < 0 || newOverRight < abs( overLeft ) ) { + position.left += myOffset + atOffset + offset; + } + } else if ( overRight > 0 ) { + newOverLeft = position.left - data.collisionPosition.marginLeft + myOffset + + atOffset + offset - offsetLeft; + if ( newOverLeft > 0 || abs( newOverLeft ) < overRight ) { + position.left += myOffset + atOffset + offset; + } + } + }, + top: function( position, data ) { + var within = data.within, + withinOffset = within.offset.top + within.scrollTop, + outerHeight = within.height, + offsetTop = within.isWindow ? within.scrollTop : within.offset.top, + collisionPosTop = position.top - data.collisionPosition.marginTop, + overTop = collisionPosTop - offsetTop, + overBottom = collisionPosTop + data.collisionHeight - outerHeight - offsetTop, + top = data.my[ 1 ] === "top", + myOffset = top ? + -data.elemHeight : + data.my[ 1 ] === "bottom" ? + data.elemHeight : + 0, + atOffset = data.at[ 1 ] === "top" ? + data.targetHeight : + data.at[ 1 ] === "bottom" ? + -data.targetHeight : + 0, + offset = -2 * data.offset[ 1 ], + newOverTop, + newOverBottom; + if ( overTop < 0 ) { + newOverBottom = position.top + myOffset + atOffset + offset + data.collisionHeight - + outerHeight - withinOffset; + if ( newOverBottom < 0 || newOverBottom < abs( overTop ) ) { + position.top += myOffset + atOffset + offset; + } + } else if ( overBottom > 0 ) { + newOverTop = position.top - data.collisionPosition.marginTop + myOffset + atOffset + + offset - offsetTop; + if ( newOverTop > 0 || abs( newOverTop ) < overBottom ) { + position.top += myOffset + atOffset + offset; + } + } + } + }, + flipfit: { + left: function() { + $.ui.position.flip.left.apply( this, arguments ); + $.ui.position.fit.left.apply( this, arguments ); + }, + top: function() { + $.ui.position.flip.top.apply( this, arguments ); + $.ui.position.fit.top.apply( this, arguments ); + } + } +}; + +} )(); + +var position = $.ui.position; + + +/*! + * jQuery UI Keycode 1.12.1 + * http://jqueryui.com + * + * Copyright jQuery Foundation and other contributors + * Released under the MIT license. + * http://jquery.org/license + */ + +//>>label: Keycode +//>>group: Core +//>>description: Provide keycodes as keynames +//>>docs: http://api.jqueryui.com/jQuery.ui.keyCode/ + + +var keycode = $.ui.keyCode = { + BACKSPACE: 8, + COMMA: 188, + DELETE: 46, + DOWN: 40, + END: 35, + ENTER: 13, + ESCAPE: 27, + HOME: 36, + LEFT: 37, + PAGE_DOWN: 34, + PAGE_UP: 33, + PERIOD: 190, + RIGHT: 39, + SPACE: 32, + TAB: 9, + UP: 38 +}; + + +/*! + * jQuery UI Unique ID 1.12.1 + * http://jqueryui.com + * + * Copyright jQuery Foundation and other contributors + * Released under the MIT license. + * http://jquery.org/license + */ + +//>>label: uniqueId +//>>group: Core +//>>description: Functions to generate and remove uniqueId's +//>>docs: http://api.jqueryui.com/uniqueId/ + + + +var uniqueId = $.fn.extend( { + uniqueId: ( function() { + var uuid = 0; + + return function() { + return this.each( function() { + if ( !this.id ) { + this.id = "ui-id-" + ( ++uuid ); + } + } ); + }; + } )(), + + removeUniqueId: function() { + return this.each( function() { + if ( /^ui-id-\d+$/.test( this.id ) ) { + $( this ).removeAttr( "id" ); + } + } ); + } +} ); + + + +var safeActiveElement = $.ui.safeActiveElement = function( document ) { + var activeElement; + + // Support: IE 9 only + // IE9 throws an "Unspecified error" accessing document.activeElement from an