├── autocompeter ├── __init__.py ├── api │ ├── __init__.py │ ├── models.py │ ├── migrations │ │ └── __init__.py │ ├── apps.py │ ├── urls.py │ ├── tests.py │ └── views.py ├── authentication │ ├── __init__.py │ ├── context_processors.py │ ├── apps.py │ ├── urls.py │ └── views.py ├── main │ ├── management │ │ ├── __init__.py │ │ └── commands │ │ │ ├── __init__.py │ │ │ └── create-index.py │ ├── migrations │ │ ├── __init__.py │ │ ├── 0003_auto_20180215_0101.py │ │ ├── 0002_key_user.py │ │ └── 0001_initial.py │ ├── __init__.py │ ├── static │ │ └── main │ │ │ ├── img │ │ │ ├── dog.png │ │ │ ├── ipad.png │ │ │ ├── intro-bg.jpg │ │ │ ├── phones.png │ │ │ └── banner-bg.jpg │ │ │ └── css │ │ │ └── landing-page.css │ ├── urls.py │ ├── apps.py │ ├── models.py │ ├── search.py │ ├── views.py │ └── templates │ │ └── main │ │ └── home.html ├── static │ ├── main │ │ ├── dog.png │ │ ├── ipad.png │ │ ├── img │ │ │ ├── dog.png │ │ │ ├── ipad.png │ │ │ ├── intro-bg.jpg │ │ │ ├── phones.png │ │ │ └── banner-bg.jpg │ │ ├── phones.png │ │ ├── banner-bg.jpg │ │ ├── intro-bg.jpg │ │ └── css │ │ │ └── landing-page.css │ ├── autocompeter.min.css │ ├── autocompeter.css │ ├── autocompeter.min.js │ └── autocompeter.js ├── wsgi.py ├── urls.py └── settings.py ├── .gitignore ├── logo.png ├── public ├── favicon.ico └── dist │ ├── autocompeter.min.css │ ├── autocompeter.css │ ├── autocompeter.min.js │ └── autocompeter.js ├── bin └── travis │ ├── lint.sh │ ├── install.sh │ ├── test.sh │ └── setup.sh ├── mkdocs.yml ├── Dockerfile ├── docs ├── index.md ├── changelog.md ├── css.md ├── javascript.md └── api.md ├── .travis.yml ├── sampleloader ├── README.md ├── addkey.py ├── benchmark.sh ├── benchmark-results.txt └── populate.py ├── contribute.json ├── manage.py ├── package.json ├── bower.json ├── docker-compose.yml ├── README.md ├── src ├── autocompeter.scss └── autocompeter.js ├── gulpfile.js ├── Makefile ├── requirements.txt └── LICENSE /autocompeter/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /autocompeter/api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /autocompeter/api/models.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /TODO 2 | node_modules/ 3 | -------------------------------------------------------------------------------- /autocompeter/api/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /autocompeter/authentication/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /autocompeter/main/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /autocompeter/main/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /autocompeter/main/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterbe/autocompeter/HEAD/logo.png -------------------------------------------------------------------------------- /autocompeter/main/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = 'autocompeter.main.apps.MainConfig' 2 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterbe/autocompeter/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /autocompeter/static/main/dog.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterbe/autocompeter/HEAD/autocompeter/static/main/dog.png -------------------------------------------------------------------------------- /autocompeter/static/main/ipad.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterbe/autocompeter/HEAD/autocompeter/static/main/ipad.png -------------------------------------------------------------------------------- /autocompeter/api/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class ApiConfig(AppConfig): 5 | name = 'api' 6 | -------------------------------------------------------------------------------- /autocompeter/static/main/img/dog.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterbe/autocompeter/HEAD/autocompeter/static/main/img/dog.png -------------------------------------------------------------------------------- /autocompeter/static/main/phones.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterbe/autocompeter/HEAD/autocompeter/static/main/phones.png -------------------------------------------------------------------------------- /autocompeter/static/main/banner-bg.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterbe/autocompeter/HEAD/autocompeter/static/main/banner-bg.jpg -------------------------------------------------------------------------------- /autocompeter/static/main/img/ipad.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterbe/autocompeter/HEAD/autocompeter/static/main/img/ipad.png -------------------------------------------------------------------------------- /autocompeter/static/main/intro-bg.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterbe/autocompeter/HEAD/autocompeter/static/main/intro-bg.jpg -------------------------------------------------------------------------------- /autocompeter/main/static/main/img/dog.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterbe/autocompeter/HEAD/autocompeter/main/static/main/img/dog.png -------------------------------------------------------------------------------- /autocompeter/static/main/img/intro-bg.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterbe/autocompeter/HEAD/autocompeter/static/main/img/intro-bg.jpg -------------------------------------------------------------------------------- /autocompeter/static/main/img/phones.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterbe/autocompeter/HEAD/autocompeter/static/main/img/phones.png -------------------------------------------------------------------------------- /autocompeter/main/static/main/img/ipad.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterbe/autocompeter/HEAD/autocompeter/main/static/main/img/ipad.png -------------------------------------------------------------------------------- /autocompeter/static/main/img/banner-bg.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterbe/autocompeter/HEAD/autocompeter/static/main/img/banner-bg.jpg -------------------------------------------------------------------------------- /autocompeter/main/static/main/img/intro-bg.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterbe/autocompeter/HEAD/autocompeter/main/static/main/img/intro-bg.jpg -------------------------------------------------------------------------------- /autocompeter/main/static/main/img/phones.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterbe/autocompeter/HEAD/autocompeter/main/static/main/img/phones.png -------------------------------------------------------------------------------- /autocompeter/main/static/main/img/banner-bg.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterbe/autocompeter/HEAD/autocompeter/main/static/main/img/banner-bg.jpg -------------------------------------------------------------------------------- /bin/travis/lint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | pip install --quiet -U flake8 3 | flake8 autocompeter --exclude=*/migrations/* 4 | 5 | # should really eslint here too 6 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Autocompeter 2 | pages: 3 | - [index.md, Home] 4 | - [javascript.md, The Javascript] 5 | - [css.md, The CSS] 6 | - [api.md, The API] 7 | - [changelog.md, Changelog] 8 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.5 2 | ENV PYTHONUNBUFFERED 1 3 | RUN mkdir /code 4 | WORKDIR /code 5 | ADD requirements.txt /code/ 6 | RUN pip install -r requirements.txt 7 | ADD . /code/ 8 | EXPOSE 8000 9 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Autocompeter 2 | 3 | Basically, the way Autocompeter works is: 4 | 5 | 1. You put our [Javascript](javascript) and [CSS](css) on your site 6 | 2. You post your titles and URLs to our [API](api) 7 | 3. You profit! 8 | -------------------------------------------------------------------------------- /autocompeter/main/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | 3 | from autocompeter.main import views 4 | 5 | 6 | urlpatterns = [ 7 | url( 8 | r'^$', 9 | views.home, 10 | name='home' 11 | ), 12 | ] 13 | -------------------------------------------------------------------------------- /autocompeter/authentication/context_processors.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | 4 | def auth0(request): 5 | return { 6 | 'AUTH0_CLIENT_ID': settings.AUTH0_CLIENT_ID, 7 | 'AUTH0_DOMAIN': settings.AUTH0_DOMAIN, 8 | } 9 | -------------------------------------------------------------------------------- /autocompeter/authentication/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.conf import settings 3 | 4 | 5 | class AuthConfig(AppConfig): 6 | name = 'auth' 7 | 8 | def ready(self): 9 | assert settings.AUTH0_CLIENT_SECRET 10 | -------------------------------------------------------------------------------- /autocompeter/main/management/commands/create-index.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | 3 | from autocompeter.main.search import index 4 | 5 | 6 | class Command(BaseCommand): 7 | 8 | def handle(self, **options): 9 | index.delete(ignore=404) 10 | index.create() 11 | -------------------------------------------------------------------------------- /autocompeter/main/apps.py: -------------------------------------------------------------------------------- 1 | from elasticsearch_dsl.connections import connections 2 | 3 | from django.conf import settings 4 | from django.apps import AppConfig 5 | 6 | 7 | class MainConfig(AppConfig): 8 | name = 'autocompeter.main' 9 | 10 | def ready(self): 11 | connections.configure(**settings.ES_CONNECTIONS) 12 | -------------------------------------------------------------------------------- /bin/travis/install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # pwd is the git repo. 3 | set -e 4 | 5 | echo "Upgrade pip & wheel" 6 | pip install --quiet -U pip wheel 7 | 8 | echo "Installing Python dependencies" 9 | pip install --quiet --require-hashes -r requirements.txt 10 | 11 | echo "Creating a test database" 12 | psql -c 'create database autocompeter;' -U postgres 13 | -------------------------------------------------------------------------------- /bin/travis/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # pwd is the git repo. 3 | set -e 4 | 5 | curl -v http://localhost:9200/ 6 | 7 | export CACHE_BACKEND="django.core.cache.backends.locmem.LocMemCache" 8 | export DATABASE_URL="postgres://travis:@localhost/autocompeter" 9 | export SECRET_KEY="anything" 10 | export ALLOWED_HOSTS="localhost" 11 | 12 | python manage.py test 13 | -------------------------------------------------------------------------------- /bin/travis/setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # pwd is the git repo. 3 | set -e 4 | 5 | export CACHE_BACKEND="django.core.cache.backends.locmem.LocMemCache" 6 | export DATABASE_URL="postgres://travis:@localhost/autocompeter" 7 | export SECRET_KEY="anything" 8 | export ALLOWED_HOSTS="localhost" 9 | 10 | echo "Running collectstatic" 11 | python manage.py collectstatic --noinput 12 | -------------------------------------------------------------------------------- /autocompeter/authentication/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | 3 | from autocompeter.authentication import views 4 | 5 | 6 | urlpatterns = [ 7 | url( 8 | r'^callback/?$', 9 | views.callback, 10 | name='callback' 11 | ), 12 | url( 13 | r'^signout/$', 14 | views.signout, 15 | name='signout' 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /autocompeter/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for autocompeter project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.10/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "autocompeter.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: true 2 | language: python 3 | python: 3.5 4 | branches: 5 | only: 6 | - master 7 | addons: 8 | apt: 9 | packages: 10 | - oracle-java8-set-default 11 | before_install: 12 | - curl -O https://artifacts.elastic.co/downloads/elasticsearch/elasticsearch-6.2.1.deb && sudo dpkg -i --force-confnew elasticsearch-6.2.1.deb && sudo service elasticsearch start 13 | install: bin/travis/install.sh 14 | before_script: 15 | - bin/travis/lint.sh 16 | - bin/travis/setup.sh 17 | script: 18 | - bin/travis/test.sh 19 | -------------------------------------------------------------------------------- /autocompeter/main/migrations/0003_auto_20180215_0101.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.7 on 2018-02-15 01:01 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('main', '0002_key_user'), 12 | ] 13 | 14 | operations = [ 15 | migrations.RemoveField( 16 | model_name='title', 17 | name='domain', 18 | ), 19 | migrations.DeleteModel( 20 | name='Title', 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /autocompeter/api/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | 3 | from autocompeter.api import views 4 | 5 | 6 | urlpatterns = [ 7 | url( 8 | r'^bulk$', 9 | views.bulk, 10 | name='bulk' 11 | ), 12 | url( 13 | r'^ping$', 14 | views.ping, 15 | name='ping' 16 | ), 17 | url( 18 | r'^stats$', 19 | views.stats, 20 | name='stats' 21 | ), 22 | url( 23 | r'^flush$', 24 | views.flush, 25 | name='flush' 26 | ), 27 | url( 28 | r'', 29 | views.home, 30 | name='home' 31 | ), 32 | ] 33 | -------------------------------------------------------------------------------- /sampleloader/README.md: -------------------------------------------------------------------------------- 1 | This is just some scripts I use to load in some sample data on the 2 | autocompeter.com home page. 3 | 4 | 5 | How I load in all blog posts for local development: 6 | 7 | ./populate.py --flush -d 8 --destination="http://localhost:3000" --domain="localhost:3000" 8 | 9 | 10 | Then to check it run: 11 | 12 | curl "http://localhost:3000/v1?d=localhost:3000&q=trag" 13 | 14 | 15 | How I load all the movies: 16 | 17 | ./populate.py -d 8 --destination="http://localhost:3000" --domain="movies-2014" --dataset=movies-2014.json --bulk 18 | 19 | How to check it: 20 | 21 | curl "http://localhost:3000/v1?d=movies-2014&q=the" 22 | -------------------------------------------------------------------------------- /sampleloader/addkey.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import random 4 | import hashlib 5 | 6 | import click 7 | import redis 8 | 9 | 10 | @click.command() 11 | @click.argument('domain') 12 | @click.argument('key', default='') 13 | @click.option('--database', '-d', default=8) 14 | @click.option('--username', '-u', default='peterbe') 15 | def run(domain, key, database, username): 16 | c = redis.StrictRedis(host='localhost', port=6379, db=database) 17 | if not key: 18 | key = hashlib.md5(str(random.random())).hexdigest() 19 | print "Key for %s is: %s" % (domain, key) 20 | c.hset('$domainkeys', key, domain) 21 | c.sadd('$userdomains$%s' % username, key) 22 | 23 | 24 | if __name__ == '__main__': 25 | run() 26 | -------------------------------------------------------------------------------- /sampleloader/benchmark.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | export URL="http://localhost:3000" 4 | 5 | echo "Bulk loading" 6 | ./populate.py -d 8 --destination=$URL --domain="mydomain" --bulk 7 | echo "The home page" 8 | wrk -c 10 -d5 -t5 $URL | grep Requests 9 | echo "Single word search 'p'" 10 | wrk -c 10 -d5 -t5 "$URL/v1?q=p&d=mydomain" | grep Requests 11 | echo "Single word search 'python'" 12 | wrk -c 10 -d5 -t5 "$URL/v1?q=python&d=mydomain" | grep Requests 13 | echo "Single word search 'xxxxxx'" 14 | wrk -c 10 -d5 -t5 "$URL/v1?q=xxxxx&d=mydomain" | grep Requests 15 | echo "Double word search 'python', 'te'" 16 | wrk -c 10 -d5 -t5 "$URL/v1?q=python%20te&d=mydomain" | grep Requests 17 | echo "Double word search 'xxxxxxxx', 'yyyyyyy'" 18 | wrk -c 10 -d5 -t5 "$URL/v1?q=xxxxxxxx%20yyyyyy&d=mydomain" | grep Requests 19 | -------------------------------------------------------------------------------- /autocompeter/main/migrations/0002_key_user.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.4 on 2016-12-26 20:43 3 | from __future__ import unicode_literals 4 | 5 | from django.conf import settings 6 | from django.db import migrations, models 7 | import django.db.models.deletion 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | dependencies = [ 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ('main', '0001_initial'), 15 | ] 16 | 17 | operations = [ 18 | migrations.AddField( 19 | model_name='key', 20 | name='user', 21 | field=models.ForeignKey(default='', on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), 22 | preserve_default=False, 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /autocompeter/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url, include 2 | from django.conf import settings 3 | from django.conf.urls.static import static 4 | 5 | import autocompeter.main.urls 6 | import autocompeter.api.urls 7 | import autocompeter.authentication.urls 8 | 9 | 10 | urlpatterns = [ 11 | url( 12 | '', 13 | include(autocompeter.main.urls.urlpatterns, namespace='main') 14 | ), 15 | url( 16 | r'^auth/', 17 | include(autocompeter.authentication.urls.urlpatterns, namespace='auth') 18 | ), 19 | url( 20 | r'^v1/?', 21 | include(autocompeter.api.urls.urlpatterns, namespace='api') 22 | ), 23 | ] 24 | 25 | if settings.DEBUG: 26 | urlpatterns += static( 27 | settings.STATIC_URL, 28 | document_root=settings.STATIC_ROOT 29 | ) 30 | -------------------------------------------------------------------------------- /contribute.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Autocompeter", 3 | "description": "A really fast AJAX autocomplete service and widget", 4 | "repository": { 5 | "url": "https://github.com/peterbe/autocompeter", 6 | "license": "BSD", 7 | "tests": "https://travis-ci.org/peterbe/autocompeter/" 8 | }, 9 | "participate": { 10 | "home": "https://autocompeter.com", 11 | "docs": "https://autocompeter.readthedocs.io/" 12 | }, 13 | "bugs": { 14 | "list": "https://github.com/peterbe/autocompeter/issues", 15 | "report": "https://github.com/peterbe/autocompeter/issues/new" 16 | }, 17 | "urls": { 18 | "prod": "https://autocompeter.com" 19 | }, 20 | "keywords": [ 21 | "django", 22 | "elasticsearch", 23 | "javascript" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /public/dist/autocompeter.min.css: -------------------------------------------------------------------------------- 1 | /* Autocompeter.com 1.2.3 */ 2 | ._ac-wrap{position:relative;display:inline-block} 3 | ._ac-wrap ._ac-hint{position:absolute;top:0;left:0;border-color:transparent;box-shadow:none;opacity:1;color:#b4b4b4;background:#fff} 4 | ._ac-wrap ._ac-foreground{background-color:transparent;position:relative;vertical-align:top} 5 | ._ac-wrap ._ac-results{z-index:10;position:absolute;background-color:#fff;border:1px solid #ebebeb;width:400px;display:none;border-radius:3px;box-shadow:0 1px 5px rgba(0,0,0,.25)} 6 | ._ac-wrap ._ac-results p{padding:5px;margin:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;border-bottom:1px solid #ebebeb;cursor:pointer;text-align:left} 7 | ._ac-wrap ._ac-results p a{display:block} 8 | ._ac-wrap ._ac-results p:last-child{border-bottom:none} 9 | ._ac-wrap ._ac-results p.selected{background-color:#f2f2f2} -------------------------------------------------------------------------------- /autocompeter/static/autocompeter.min.css: -------------------------------------------------------------------------------- 1 | /* Autocompeter.com 1.2.3 */ 2 | ._ac-wrap{position:relative;display:inline-block} 3 | ._ac-wrap ._ac-hint{position:absolute;top:0;left:0;border-color:transparent;box-shadow:none;opacity:1;color:#b4b4b4;background:#fff} 4 | ._ac-wrap ._ac-foreground{background-color:transparent;position:relative;vertical-align:top} 5 | ._ac-wrap ._ac-results{z-index:10;position:absolute;background-color:#fff;border:1px solid #ebebeb;width:400px;display:none;border-radius:3px;box-shadow:0 1px 5px rgba(0,0,0,.25)} 6 | ._ac-wrap ._ac-results p{padding:5px;margin:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;border-bottom:1px solid #ebebeb;cursor:pointer;text-align:left} 7 | ._ac-wrap ._ac-results p a{display:block} 8 | ._ac-wrap ._ac-results p:last-child{border-bottom:none} 9 | ._ac-wrap ._ac-results p.selected{background-color:#f2f2f2} -------------------------------------------------------------------------------- /autocompeter/main/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.contrib.auth.models import User 3 | 4 | 5 | class Domain(models.Model): 6 | name = models.CharField(max_length=100) 7 | created = models.DateTimeField(auto_now_add=True) 8 | modified = models.DateTimeField(auto_now=True) 9 | 10 | def __str__(self): 11 | return self.name 12 | 13 | 14 | class Key(models.Model): 15 | domain = models.ForeignKey(Domain) 16 | key = models.TextField(db_index=True, unique=True) 17 | user = models.ForeignKey(User) 18 | modified = models.DateTimeField(auto_now=True) 19 | 20 | def __str__(self): 21 | return self.key 22 | 23 | 24 | class Search(models.Model): 25 | domain = models.ForeignKey(Domain) 26 | term = models.TextField() 27 | results = models.IntegerField(default=0) 28 | created = models.DateTimeField(auto_now_add=True) 29 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "autocompeter.settings") 7 | try: 8 | from django.core.management import execute_from_command_line 9 | except ImportError: 10 | # The above import may fail for some other reason. Ensure that the 11 | # issue is really that Django is missing to avoid masking other 12 | # exceptions on Python 2. 13 | try: 14 | import django 15 | except ImportError: 16 | raise ImportError( 17 | "Couldn't import Django. Are you sure it's installed and " 18 | "available on your PYTHONPATH environment variable? Did you " 19 | "forget to activate a virtual environment?" 20 | ) 21 | raise 22 | execute_from_command_line(sys.argv) 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "autocompeter", 3 | "version": "1.2.3", 4 | "description": "A really fast AJAX autocomplete service and widget", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/peterbe/autocompeter.git" 12 | }, 13 | "author": "Peter Bengtsson", 14 | "license": "MPL-2.0", 15 | "bugs": { 16 | "url": "https://github.com/peterbe/autocompeter/issues" 17 | }, 18 | "homepage": "https://github.com/peterbe/autocompeter", 19 | "devDependencies": { 20 | "gulp": "3.9.1", 21 | "gulp-clean-css": "2.0.10", 22 | "gulp-concat": "2.6.0", 23 | "gulp-header": "1.8.7", 24 | "gulp-jshint": "2.0.1", 25 | "gulp-less": "3.1.0", 26 | "gulp-rename": "1.2.2", 27 | "gulp-sass": "2.3.1", 28 | "gulp-uglify": "1.5.4", 29 | "jshint": "2.9.2", 30 | "node-sass": "2.1.1" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "autocompeter", 3 | "version": "1.2.3", 4 | "homepage": "https://autocompeter.com", 5 | "authors": [ 6 | "Peter Bengtsson " 7 | ], 8 | "description": "A really fast AJAX autocomplete service and widget", 9 | "main": [ 10 | "public/dist/autocompeter.min.js", 11 | "public/dist/autocompeter.min.css" 12 | ], 13 | "keywords": [ 14 | "autocomplete", 15 | "livesearch" 16 | ], 17 | "license": "BSD", 18 | "ignore": [ 19 | "**/.*", 20 | "node_modules", 21 | "bower_components", 22 | "sampleloader", 23 | "public/README.md", 24 | "public/examples", 25 | "public/js", 26 | "public/css", 27 | "public/img", 28 | "public/fonts", 29 | "public/font-awesome", 30 | ".gitignore", 31 | "LICENSE", 32 | ".travis.yml", 33 | "requirements.txt", 34 | "gulpfile.js", 35 | "package.json", 36 | "*.go", 37 | "templates", 38 | "src", 39 | "*.py" 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | services: 3 | elasticsearch: 4 | image: docker.elastic.co/elasticsearch/elasticsearch:6.2.1 5 | environment: 6 | - cluster.name=docker-cluster 7 | - bootstrap.memory_lock=true 8 | - "ES_JAVA_OPTS=-Xms512m -Xmx512m" 9 | - xpack.security.enabled=false 10 | ulimits: 11 | memlock: 12 | soft: -1 13 | hard: -1 14 | mem_limit: 1g 15 | # volumes: 16 | # - esdata:/usr/share/elasticsearch/data 17 | ports: 18 | - 9200:9200 19 | db: 20 | image: postgres:9.5 21 | web: 22 | # environment: 23 | # - ELASTICSEARCH_URL=http://elasticsearch:9200 24 | # environment: 25 | # # PORT: 2204 26 | # DATABASE_URL: 'postgres://postgres:@0.0.0.0:5432/postgres' 27 | build: . 28 | command: python manage.py runserver 0.0.0.0:8000 29 | volumes: 30 | - .:/code 31 | ports: 32 | - "8000:8000" 33 | - "5544:5544" 34 | links: 35 | - db 36 | - elasticsearch 37 | depends_on: 38 | - db 39 | - elasticsearch 40 | -------------------------------------------------------------------------------- /autocompeter/main/search.py: -------------------------------------------------------------------------------- 1 | from elasticsearch_dsl import ( 2 | DocType, 3 | Float, 4 | Text, 5 | Index, 6 | analyzer, 7 | Keyword, 8 | token_filter, 9 | ) 10 | 11 | from django.conf import settings 12 | 13 | 14 | edge_ngram_analyzer = analyzer( 15 | 'edge_ngram_analyzer', 16 | type='custom', 17 | tokenizer='standard', 18 | filter=[ 19 | 'lowercase', 20 | token_filter( 21 | 'edge_ngram_filter', type='edgeNGram', 22 | min_gram=1, max_gram=20 23 | ) 24 | ] 25 | ) 26 | 27 | 28 | class TitleDoc(DocType): 29 | id = Keyword() 30 | domain = Keyword(required=True) 31 | url = Keyword(required=True, index=False) 32 | title = Text( 33 | required=True, 34 | analyzer=edge_ngram_analyzer, 35 | search_analyzer='standard' 36 | ) 37 | popularity = Float() 38 | group = Keyword() 39 | 40 | 41 | # create an index and register the doc types 42 | index = Index(settings.ES_INDEX) 43 | index.settings(**settings.ES_INDEX_SETTINGS) 44 | index.doc_type(TitleDoc) 45 | -------------------------------------------------------------------------------- /public/dist/autocompeter.css: -------------------------------------------------------------------------------- 1 | ._ac-wrap { 2 | position: relative; 3 | display: inline-block; } 4 | ._ac-wrap ._ac-hint { 5 | position: absolute; 6 | top: 0; 7 | left: 0; 8 | border-color: transparent; 9 | box-shadow: none; 10 | opacity: 1; 11 | color: #b4b4b4; 12 | background: none repeat scroll 0% 0% white; } 13 | ._ac-wrap ._ac-foreground { 14 | background-color: transparent; 15 | position: relative; 16 | vertical-align: top; } 17 | ._ac-wrap ._ac-results { 18 | z-index: 10; 19 | position: absolute; 20 | background-color: white; 21 | border: 1px solid #ebebeb; 22 | width: 400px; 23 | display: none; 24 | border-radius: 3px; 25 | box-shadow: 0px 1px 5px rgba(0, 0, 0, 0.25); } 26 | ._ac-wrap ._ac-results p { 27 | padding: 5px; 28 | margin: 0px; 29 | white-space: nowrap; 30 | overflow: hidden; 31 | text-overflow: ellipsis; 32 | border-bottom: 1px solid #ebebeb; 33 | cursor: pointer; 34 | text-align: left; } 35 | ._ac-wrap ._ac-results p a { 36 | display: block; } 37 | ._ac-wrap ._ac-results p:last-child { 38 | border-bottom: none; } 39 | ._ac-wrap ._ac-results p.selected { 40 | background-color: #f2f2f2; } 41 | -------------------------------------------------------------------------------- /autocompeter/static/autocompeter.css: -------------------------------------------------------------------------------- 1 | ._ac-wrap { 2 | position: relative; 3 | display: inline-block; } 4 | ._ac-wrap ._ac-hint { 5 | position: absolute; 6 | top: 0; 7 | left: 0; 8 | border-color: transparent; 9 | box-shadow: none; 10 | opacity: 1; 11 | color: #b4b4b4; 12 | background: none repeat scroll 0% 0% white; } 13 | ._ac-wrap ._ac-foreground { 14 | background-color: transparent; 15 | position: relative; 16 | vertical-align: top; } 17 | ._ac-wrap ._ac-results { 18 | z-index: 10; 19 | position: absolute; 20 | background-color: white; 21 | border: 1px solid #ebebeb; 22 | width: 400px; 23 | display: none; 24 | border-radius: 3px; 25 | box-shadow: 0px 1px 5px rgba(0, 0, 0, 0.25); } 26 | ._ac-wrap ._ac-results p { 27 | padding: 5px; 28 | margin: 0px; 29 | white-space: nowrap; 30 | overflow: hidden; 31 | text-overflow: ellipsis; 32 | border-bottom: 1px solid #ebebeb; 33 | cursor: pointer; 34 | text-align: left; } 35 | ._ac-wrap ._ac-results p a { 36 | display: block; } 37 | ._ac-wrap ._ac-results p:last-child { 38 | border-bottom: none; } 39 | ._ac-wrap ._ac-results p.selected { 40 | background-color: #f2f2f2; } 41 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | An ElasticSearch autocomplete Django server 2 | =========================================== 3 | 4 | [![Build Status](https://travis-ci.org/peterbe/autocompeter.svg?branch=master)](https://travis-ci.org/peterbe/autocompeter) 5 | 6 | Documentation 7 | ------------- 8 | 9 | [Documentation on Read the Docs](https://autocompeter.readthedocs.io) 10 | 11 | Running tests 12 | ------------- 13 | 14 | To run the unit tests run: 15 | 16 | ./manage.py test 17 | 18 | Using Docker 19 | ------------ 20 | 21 | First you need to create your own `.env` file. It should look something like 22 | this: 23 | 24 | DEBUG=True 25 | SECRET_KEY=somethingx 26 | #DATABASE_URL=postgresql://localhost/autocompeter 27 | ALLOWED_HOSTS=localhost 28 | ES_CONNECTIONS_URLS=elasticsearch:9200 29 | AUTH0_CLIENT_SECRET="optional" 30 | 31 | Simply run: 32 | 33 | docker-compose build 34 | docker-compose up 35 | 36 | And now you should have a server running on `http://localhost:8000` 37 | 38 | 39 | And to run the tests with Docker: 40 | 41 | docker-compose run web /usr/local/bin/python manage.py test 42 | 43 | Writing documentation 44 | --------------------- 45 | 46 | If you want to work on the documentation, cd into the directory `./doc` 47 | and make sure you have `mkdocs` pip installed. (see 48 | `./requirements.txt`). 49 | 50 | Then simple run: 51 | 52 | mkdocs build 53 | open site/index.html 54 | 55 | If you have a bunch of changes you want to make and don't want to run 56 | `mkdocs build` every time you can use this trick: 57 | 58 | mkdocs serve 59 | open http://localhost:8000/ 60 | -------------------------------------------------------------------------------- /docs/changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | 4 | ## 1.1.12 5 | 6 | * Highlight the whole word in the results. 7 | See [this blog post](http://www.peterbe.com/plog/match-the-whole-word). 8 | 9 | ## 1.1.11 10 | 11 | * Same as 1.1.10. Just mismanaged the git command to tag the version. 12 | 13 | ## 1.1.10 14 | 15 | * Version number in minified assets. 16 | 17 | ## 1.1.9 18 | 19 | * Ping only on the first onfocus per session. 20 | 21 | ## 1.1.8 22 | 23 | * Don't be too eager to re-display results during typing during 24 | waiting for the AJAX request to finish. Resolves the problem of 25 | possible "flickering". 26 | 27 | ## 1.1.7 28 | 29 | * Re-release for minified dist files. 30 | 31 | ## 1.1.6 32 | 33 | * Ping feature on by default. 34 | 35 | ## 1.1.5 36 | 37 | * Don't put the hint value behind typed text if it's identical. This 38 | prevents strangeness when you type longer than the input field such 39 | that the text becomes right-aligned. 40 | 41 | ## 1.1.4 42 | 43 | * Ping starts when you put focus on the search widget instead. 44 | 45 | ## 1.1.3 46 | 47 | * Option to set `{ping: true}` which will pre-flight an AJAX get to the 48 | server pre-emptively for extra performance. Off by default. 49 | 50 | ## 1.1.2 51 | 52 | * The Autocompeter doesn't show onload if there is some text in the input 53 | widget it works on. 54 | 55 | ## 1.1 56 | 57 | * If the server is slow, the filtering of which results to display is instead 58 | done using the last result from XHR. This avoids hints from appearing 59 | that no longer match what you're typing. 60 | 61 | ## 1.0 62 | 63 | * Inception and the start of maintaining a changelog. 64 | -------------------------------------------------------------------------------- /src/autocompeter.scss: -------------------------------------------------------------------------------- 1 | $item-border-color: rgb(235,235,235); 2 | $selected-color: rgb(242,242,242); 3 | $hint-foreground-color: rgb(180, 180, 180); 4 | $hint-background-color: rgb(255, 255, 255); 5 | $results-background-color: white; 6 | 7 | ._ac-wrap { 8 | position: relative; 9 | display: inline-block; 10 | 11 | ._ac-hint { 12 | position: absolute; 13 | top: 0; 14 | left: 0; 15 | border-color: transparent; 16 | box-shadow: none; 17 | opacity: 1; 18 | color: $hint-foreground-color; 19 | background: none repeat scroll 0% 0% $hint-background-color; 20 | } 21 | ._ac-foreground { 22 | background-color: transparent; 23 | position: relative; 24 | vertical-align: top; 25 | } 26 | ._ac-results { 27 | z-index: 10; 28 | position: absolute; 29 | background-color: $results-background-color; 30 | border: 1px solid $item-border-color; 31 | width: 400px; 32 | display: none; 33 | border-radius: 3px; 34 | box-shadow: 0px 1px 5px rgba(0, 0, 0, 0.25); 35 | 36 | p { 37 | padding: 5px; 38 | margin: 0px; 39 | white-space: nowrap; 40 | overflow: hidden; 41 | text-overflow: ellipsis; 42 | border-bottom: 1px solid $item-border-color; 43 | cursor: pointer; 44 | text-align: left; 45 | } 46 | p a { 47 | display: block; 48 | } 49 | p:last-child { 50 | border-bottom: none; 51 | } 52 | p.selected { 53 | background-color: $selected-color; 54 | } 55 | 56 | } 57 | 58 | } 59 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | // Include gulp 2 | var gulp = require('gulp'); 3 | 4 | // Include Our Plugins 5 | var jshint = require('gulp-jshint'); 6 | var sass = require('gulp-sass'); 7 | var concat = require('gulp-concat'); 8 | var uglify = require('gulp-uglify'); 9 | var rename = require('gulp-rename'); 10 | var cleanCSS = require('gulp-clean-css'); 11 | var header = require('gulp-header'); 12 | 13 | var bowerPkg = require('./bower.json'); 14 | 15 | var banner = '/* Autocompeter.com <%= pkg.version %> */\n'; 16 | 17 | // Lint Task 18 | gulp.task('lint', function() { 19 | return gulp.src('src/*.js') 20 | .pipe(jshint()) 21 | .pipe(jshint.reporter('default')); 22 | }); 23 | 24 | // Compile Our Sass 25 | gulp.task('sass', function() { 26 | return gulp.src('src/*.scss') 27 | .pipe(sass()) 28 | .pipe(gulp.dest('public/dist')) 29 | .pipe(rename('autocompeter.min.css')) 30 | .pipe(cleanCSS({keepBreaks:true})) 31 | .pipe(header(banner, {pkg: bowerPkg})) 32 | .pipe(gulp.dest('public/dist')); 33 | }); 34 | 35 | // Concatenate & Minify JS 36 | gulp.task('scripts', function() { 37 | return gulp.src('src/*.js') 38 | .pipe(concat('autocompeter.js')) 39 | .pipe(gulp.dest('public/dist')) 40 | .pipe(rename('autocompeter.min.js')) 41 | .pipe(uglify()) 42 | .pipe(header(banner, {pkg: bowerPkg})) 43 | .pipe(gulp.dest('public/dist')); 44 | }); 45 | 46 | // Watch Files For Changes 47 | gulp.task('watch', function() { 48 | gulp.watch('src/*.js', ['lint', 'scripts']); 49 | gulp.watch('src/*.scss', ['sass']); 50 | }); 51 | 52 | // Default Task 53 | gulp.task('default', ['lint', 'sass', 'scripts', 'watch']); 54 | gulp.task('build', ['lint', 'sass', 'scripts']); 55 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build clean migrate shell currentshell stop test run django-shell docs psql 2 | 3 | help: 4 | @echo "Welcome to the django-peterbe\n" 5 | @echo "The list of commands for local development:\n" 6 | @echo " build Builds the docker images for the docker-compose setup" 7 | @echo " ci Run the test with the CI specific Docker setup" 8 | @echo " clean Stops and removes all docker containers" 9 | @echo " migrate Runs the Django database migrations" 10 | @echo " shell Opens a Bash shell" 11 | @echo " currentshell Opens a Bash shell into existing running 'web' container" 12 | @echo " test Runs the Python test suite" 13 | @echo " run Runs the whole stack, served on http://localhost:8000/" 14 | @echo " stop Stops the docker containers" 15 | @echo " django-shell Django integrative shell" 16 | @echo " psql Open the psql cli" 17 | 18 | 19 | clean: stop 20 | docker-compose rm -f 21 | rm -fr .docker-build 22 | 23 | migrate: 24 | docker-compose run web python manage.py migrate --run-syncdb 25 | 26 | shell: 27 | # Use `-u 0` to automatically become root in the shell 28 | docker-compose run --user 0 web bash 29 | 30 | currentshell: 31 | # Use `-u 0` to automatically become root in the shell 32 | docker-compose exec --user 0 web bash 33 | 34 | psql: 35 | docker-compose run db psql -h db -U postgres 36 | 37 | stop: 38 | docker-compose stop 39 | 40 | test: 41 | @bin/test.sh 42 | 43 | run: 44 | docker-compose up web 45 | 46 | django-shell: 47 | docker-compose run web python manage.py shell 48 | 49 | make-index: 50 | docker-compose run web python manage.py create-index 51 | # docker-compose run web /usr/local/bin/python manage.py create-index 52 | -------------------------------------------------------------------------------- /docs/css.md: -------------------------------------------------------------------------------- 1 | # The CSS 2 | 3 | Just like with [downloading the Javascript](javascript), you can do with the CSS. 4 | 5 | [**https://raw.githubusercontent.com/peterbe/autocompeter/master/public/dist/autocompeter.min.css**](https://raw.githubusercontent.com/peterbe/autocompeter/master/public/dist/autocompeter.min.css) 6 | 7 | Or... 8 | 9 | bower install autocompeter 10 | ls bower_components/autocompeter/public/dist/*.css 11 | 12 | Or... 13 | 14 | 15 | 16 | There is also another alternative. If you already use [Sass (aka. SCSS)](http://sass-lang.com/) 17 | you can download [autocompeter.scss](https://github.com/peterbe/autocompeter/blob/master/src/autocompeter.scss) 18 | instead and incorporate that into your own build system. 19 | 20 | ## Overriding 21 | 22 | It's very possible that on your site, the CSS doesn't fit in perfectly. Either 23 | you don't exactly like the way it looks or it just doesn't work as expected. 24 | The recommended way to deal with this is to override certain selectors. For 25 | example it might look like this: 26 | 27 | 28 | 34 | 35 | As an example, with the design being used on 36 | [autocompeter.com](http://autocompeter.com) some CSS had to be overridden. 37 | 38 | 39 | ## About using a CDN for CSS 40 | 41 | Note, if performance is important to you, note that it's actually not a good 42 | idea to use the CDN URL to reference the stylesheet. The reason for that is 43 | that oftentimes, a DNS lookup is actually slower than CSS download from a 44 | lesser fast server. 45 | 46 | There's a lot of resources online, [like this one](http://csswizardry.com/2013/01/front-end-performance-for-web-designers-and-front-end-developers/#section:css-and-performance), 47 | that elaborate this in much more detail. 48 | -------------------------------------------------------------------------------- /autocompeter/main/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.4 on 2016-12-12 18:26 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies = [ 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='Domain', 19 | fields=[ 20 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 21 | ('name', models.CharField(max_length=100)), 22 | ('created', models.DateTimeField(auto_now_add=True)), 23 | ('modified', models.DateTimeField(auto_now=True)), 24 | ], 25 | ), 26 | migrations.CreateModel( 27 | name='Key', 28 | fields=[ 29 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 30 | ('key', models.TextField(db_index=True, unique=True)), 31 | ('modified', models.DateTimeField(auto_now=True)), 32 | ('domain', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='main.Domain')), 33 | ], 34 | ), 35 | migrations.CreateModel( 36 | name='Search', 37 | fields=[ 38 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 39 | ('term', models.TextField()), 40 | ('results', models.IntegerField(default=0)), 41 | ('created', models.DateTimeField(auto_now_add=True)), 42 | ('domain', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='main.Domain')), 43 | ], 44 | ), 45 | migrations.CreateModel( 46 | name='Title', 47 | fields=[ 48 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 49 | ('value', models.TextField()), 50 | ('url', models.URLField(max_length=500)), 51 | ('popularity', models.FloatField(default=0.0)), 52 | ('group', models.CharField(max_length=100, null=True)), 53 | ('modified', models.DateTimeField(auto_now=True)), 54 | ('domain', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='main.Domain')), 55 | ], 56 | ), 57 | ] 58 | -------------------------------------------------------------------------------- /sampleloader/benchmark-results.txt: -------------------------------------------------------------------------------- 1 | PROCS=1: 2 | -------- 3 | Bulk loading 4 | KEY 2506e9deedfa0a2a707a4246e29993eb 5 | DOMAIN mydomain 6 | TOOK 0.476712942123 7 | 8 | The home page 9 | Requests/sec: 5729.46 10 | Single word search 'p' 11 | Requests/sec: 3178.71 12 | Single word search 'python' 13 | Requests/sec: 3098.61 14 | Single word search 'xxxxxx' 15 | Requests/sec: 5420.96 16 | Double word search 'python', 'te' 17 | Requests/sec: 2797.01 18 | Double word search 'xxxxxxxx', 'yyyyyyy' 19 | Requests/sec: 4773.27 20 | Double word search 'xxxxxxxx', 'yyyyyyy' 21 | Requests/sec: 4842.77 22 | 23 | 24 | Bulk loading 25 | KEY 2506e9deedfa0a2a707a4246e29993eb 26 | DOMAIN mydomain 27 | TOOK 0.434735059738 28 | The home page 29 | Requests/sec: 5724.72 30 | Single word search 'p' 31 | Requests/sec: 3338.85 32 | Single word search 'python' 33 | Requests/sec: 3121.03 34 | Single word search 'xxxxxx' 35 | Requests/sec: 5878.72 36 | Double word search 'python', 'te' 37 | Requests/sec: 2921.58 38 | Double word search 'xxxxxxxx', 'yyyyyyy' 39 | Requests/sec: 5053.93 40 | Double word search 'xxxxxxxx', 'yyyyyyy' 41 | Requests/sec: 5171.05 42 | 43 | 44 | PROCS=2: 45 | -------- 46 | Bulk loading 47 | KEY 2506e9deedfa0a2a707a4246e29993eb 48 | DOMAIN mydomain 49 | TOOK 0.463844060898 50 | The home page 51 | Requests/sec: 7590.63 52 | Single word search 'p' 53 | Requests/sec: 4137.35 54 | Single word search 'python' 55 | Requests/sec: 4056.33 56 | Single word search 'xxxxxx' 57 | Requests/sec: 7845.38 58 | Double word search 'python', 'te' 59 | Requests/sec: 2872.08 60 | Double word search 'xxxxxxxx', 'yyyyyyy' 61 | Requests/sec: 6565.15 62 | Double word search 'xxxxxxxx', 'yyyyyyy' 63 | Requests/sec: 6892.79 64 | 65 | 66 | Bulk loading 67 | KEY 2506e9deedfa0a2a707a4246e29993eb 68 | DOMAIN mydomain 69 | TOOK 0.477176904678 70 | The home page 71 | Requests/sec: 8300.69 72 | Single word search 'p' 73 | Requests/sec: 4279.47 74 | Single word search 'python' 75 | Requests/sec: 4392.82 76 | Single word search 'xxxxxx' 77 | Requests/sec: 8114.31 78 | Double word search 'python', 'te' 79 | Requests/sec: 3258.71 80 | Double word search 'xxxxxxxx', 'yyyyyyy' 81 | Requests/sec: 6720.35 82 | Double word search 'xxxxxxxx', 'yyyyyyy' 83 | Requests/sec: 7036.91 84 | 85 | 86 | PROCS=8: 87 | -------- 88 | 89 | Bulk loading 90 | KEY 2506e9deedfa0a2a707a4246e29993eb 91 | DOMAIN mydomain 92 | TOOK 0.493665933609 93 | The home page 94 | Requests/sec: 13270.65 95 | Single word search 'p' 96 | Requests/sec: 5156.74 97 | Single word search 'python' 98 | Requests/sec: 4463.62 99 | Single word search 'xxxxxx' 100 | Requests/sec: 11166.61 101 | Double word search 'python', 'te' 102 | Requests/sec: 3304.49 103 | Double word search 'xxxxxxxx', 'yyyyyyy' 104 | Requests/sec: 8546.56 105 | Double word search 'xxxxxxxx', 'yyyyyyy' 106 | Requests/sec: 8390.73 107 | 108 | 109 | Bulk loading 110 | KEY 2506e9deedfa0a2a707a4246e29993eb 111 | DOMAIN mydomain 112 | TOOK 0.445960998535 113 | The home page 114 | Requests/sec: 12748.08 115 | Single word search 'p' 116 | Requests/sec: 5318.87 117 | Single word search 'python' 118 | Requests/sec: 4869.59 119 | Single word search 'xxxxxx' 120 | Requests/sec: 10846.48 121 | Double word search 'python', 'te' 122 | Requests/sec: 3206.88 123 | Double word search 'xxxxxxxx', 'yyyyyyy' 124 | Requests/sec: 8446.68 125 | Double word search 'xxxxxxxx', 'yyyyyyy' 126 | Requests/sec: 8686.12 127 | -------------------------------------------------------------------------------- /autocompeter/main/views.py: -------------------------------------------------------------------------------- 1 | import random 2 | from urllib.parse import urlparse 3 | 4 | from django.conf import settings 5 | from django.shortcuts import render, redirect 6 | from django.contrib import messages 7 | 8 | from autocompeter.main.models import Domain, Key 9 | from autocompeter.api.views import stats_by_domain 10 | 11 | 12 | def generate_new_key(length=24): 13 | pool = list('abcdefghjkmnopqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ123456789') 14 | random.shuffle(pool) 15 | return ''.join(pool[:length]) 16 | 17 | 18 | def home(request): 19 | context = {} 20 | print(request.user) 21 | if request.method == 'POST': 22 | assert request.user.is_authenticated 23 | if 'domain' in request.POST: 24 | d = request.POST['domain'].strip() 25 | if '://' in d: 26 | d = urlparse(d).netloc 27 | if d: 28 | domain, created = Domain.objects.get_or_create( 29 | name=d, 30 | ) 31 | if created: 32 | Key.objects.create( 33 | domain=domain, 34 | key=generate_new_key(), 35 | user=request.user, 36 | ) 37 | messages.success( 38 | request, 39 | 'New domain (and key) created.' 40 | ) 41 | else: 42 | Key.objects.create( 43 | domain=domain, 44 | key=generate_new_key(), 45 | user=request.user, 46 | ) 47 | messages.success( 48 | request, 49 | 'New key created.' 50 | ) 51 | return redirect('main:home') 52 | 53 | else: 54 | messages.error( 55 | request, 56 | 'No domain specified' 57 | ) 58 | elif request.POST.get('delete'): 59 | count, _ = Key.objects.filter( 60 | key=request.POST['delete'], 61 | user=request.user 62 | ).delete() 63 | messages.success( 64 | request, 65 | '{} key deleted'.format(count) 66 | ) 67 | else: 68 | raise NotImplementedError 69 | 70 | if request.user.is_authenticated: 71 | context['keys'] = Key.objects.filter( 72 | user=request.user 73 | ).order_by('domain__name', 'key') 74 | domains = set(x.domain for x in context['keys']) 75 | context['domains'] = domains 76 | for domain in domains: 77 | fetches, no_documents = stats_by_domain(domain) 78 | domain.no_documents = no_documents 79 | total = 0 80 | fetch_months = [] 81 | for year in sorted(fetches): 82 | for month in sorted(fetches[year], key=lambda x: int(x)): 83 | count = fetches[year][month] 84 | fetch_months.append({ 85 | 'year': int(year), 86 | 'month': int(month), 87 | 'fetches': count 88 | }) 89 | total += count 90 | domain.fetch_months = fetch_months 91 | domain.fetch_total = total 92 | else: 93 | context['keys'] = [] 94 | 95 | context['DEBUG'] = settings.DEBUG 96 | return render(request, 'main/home.html', context) 97 | -------------------------------------------------------------------------------- /autocompeter/static/main/css/landing-page.css: -------------------------------------------------------------------------------- 1 | body, 2 | html { 3 | width: 100%; 4 | height: 100%; 5 | } 6 | 7 | body, 8 | h1, 9 | h2, 10 | h3, 11 | h4, 12 | h5, 13 | h6 { 14 | font-family: "Lato","Helvetica Neue",Helvetica,Arial,sans-serif; 15 | font-weight: 700; 16 | } 17 | 18 | .topnav { 19 | font-size: 14px; 20 | } 21 | 22 | .lead { 23 | font-size: 18px; 24 | font-weight: 400; 25 | } 26 | 27 | .intro-header { 28 | padding-top: 50px; /* If you're making other pages, make sure there is 50px of padding to make sure the navbar doesn't overlap content! */ 29 | padding-bottom: 50px; 30 | text-align: center; 31 | color: #f8f8f8; 32 | background: url(../img/intro-bg.jpg) no-repeat center center; 33 | background-size: cover; 34 | } 35 | 36 | .intro-message { 37 | position: relative; 38 | padding-top: 20%; 39 | padding-bottom: 20%; 40 | } 41 | 42 | .intro-message > h1 { 43 | margin: 0; 44 | text-shadow: 2px 2px 3px rgba(0,0,0,0.6); 45 | font-size: 5em; 46 | } 47 | 48 | .intro-divider { 49 | width: 400px; 50 | border-top: 1px solid #f8f8f8; 51 | border-bottom: 1px solid rgba(0,0,0,0.2); 52 | } 53 | 54 | .intro-message > h3 { 55 | text-shadow: 2px 2px 3px rgba(0,0,0,0.6); 56 | } 57 | 58 | @media(max-width:767px) { 59 | .intro-message { 60 | padding-bottom: 15%; 61 | } 62 | 63 | .intro-message > h1 { 64 | font-size: 3em; 65 | } 66 | 67 | ul.intro-social-buttons > li { 68 | display: block; 69 | margin-bottom: 20px; 70 | padding: 0; 71 | } 72 | 73 | ul.intro-social-buttons > li:last-child { 74 | margin-bottom: 0; 75 | } 76 | 77 | .intro-divider { 78 | width: 100%; 79 | } 80 | } 81 | 82 | .network-name { 83 | text-transform: uppercase; 84 | font-size: 14px; 85 | font-weight: 400; 86 | letter-spacing: 2px; 87 | } 88 | 89 | .content-section-a { 90 | padding: 50px 0; 91 | background-color: #f8f8f8; 92 | } 93 | 94 | .content-section-b { 95 | padding: 50px 0; 96 | border-top: 1px solid #e7e7e7; 97 | border-bottom: 1px solid #e7e7e7; 98 | } 99 | 100 | .section-heading { 101 | margin-bottom: 30px; 102 | } 103 | 104 | .section-heading-spacer { 105 | float: left; 106 | width: 200px; 107 | border-top: 3px solid #e7e7e7; 108 | } 109 | 110 | .banner { 111 | padding: 100px 0; 112 | color: #f8f8f8; 113 | background: url(../img/banner-bg.jpg) no-repeat center center; 114 | background-size: cover; 115 | } 116 | 117 | .banner h2 { 118 | margin: 0; 119 | text-shadow: 2px 2px 3px rgba(0,0,0,0.6); 120 | font-size: 3em; 121 | } 122 | 123 | .banner ul { 124 | margin-bottom: 0; 125 | } 126 | 127 | .banner-social-buttons { 128 | float: right; 129 | margin-top: 0; 130 | } 131 | 132 | @media(max-width:1199px) { 133 | ul.banner-social-buttons { 134 | float: left; 135 | margin-top: 15px; 136 | } 137 | } 138 | 139 | @media(max-width:767px) { 140 | .banner h2 { 141 | margin: 0; 142 | text-shadow: 2px 2px 3px rgba(0,0,0,0.6); 143 | font-size: 3em; 144 | } 145 | 146 | ul.banner-social-buttons > li { 147 | display: block; 148 | margin-bottom: 20px; 149 | padding: 0; 150 | } 151 | 152 | ul.banner-social-buttons > li:last-child { 153 | margin-bottom: 0; 154 | } 155 | } 156 | 157 | footer { 158 | padding: 50px 0; 159 | background-color: #f8f8f8; 160 | } 161 | 162 | p.copyright { 163 | margin: 15px 0 0; 164 | } 165 | -------------------------------------------------------------------------------- /autocompeter/main/static/main/css/landing-page.css: -------------------------------------------------------------------------------- 1 | body, 2 | html { 3 | width: 100%; 4 | height: 100%; 5 | } 6 | 7 | p, 8 | li, 9 | h1, 10 | h2, 11 | h3, 12 | h4, 13 | h5, 14 | h6 { 15 | font-family: "Lato","Helvetica Neue",Helvetica,Arial,sans-serif; 16 | font-weight: 600; 17 | } 18 | 19 | .topnav { 20 | font-size: 14px; 21 | } 22 | 23 | .lead { 24 | font-size: 18px; 25 | font-weight: 400; 26 | } 27 | 28 | .intro-header { 29 | padding-top: 50px; /* If you're making other pages, make sure there is 50px of padding to make sure the navbar doesn't overlap content! */ 30 | padding-bottom: 50px; 31 | text-align: center; 32 | color: #f8f8f8; 33 | background: url(../img/intro-bg.jpg) no-repeat center center; 34 | background-size: cover; 35 | } 36 | 37 | .intro-message { 38 | position: relative; 39 | padding-top: 20%; 40 | padding-bottom: 20%; 41 | } 42 | 43 | .intro-message > h1 { 44 | margin: 0; 45 | text-shadow: 2px 2px 3px rgba(0,0,0,0.6); 46 | font-size: 5em; 47 | } 48 | 49 | .intro-divider { 50 | width: 400px; 51 | border-top: 1px solid #f8f8f8; 52 | border-bottom: 1px solid rgba(0,0,0,0.2); 53 | } 54 | 55 | .intro-message > h3 { 56 | text-shadow: 2px 2px 3px rgba(0,0,0,0.6); 57 | } 58 | 59 | @media(max-width:767px) { 60 | .intro-message { 61 | padding-bottom: 15%; 62 | } 63 | 64 | .intro-message > h1 { 65 | font-size: 3em; 66 | } 67 | 68 | ul.intro-social-buttons > li { 69 | display: block; 70 | margin-bottom: 20px; 71 | padding: 0; 72 | } 73 | 74 | ul.intro-social-buttons > li:last-child { 75 | margin-bottom: 0; 76 | } 77 | 78 | .intro-divider { 79 | width: 100%; 80 | } 81 | } 82 | 83 | .network-name { 84 | text-transform: uppercase; 85 | font-size: 14px; 86 | font-weight: 400; 87 | letter-spacing: 2px; 88 | } 89 | 90 | .content-section-a { 91 | padding: 50px 0; 92 | background-color: #f8f8f8; 93 | } 94 | 95 | .content-section-b { 96 | padding: 50px 0; 97 | border-top: 1px solid #e7e7e7; 98 | border-bottom: 1px solid #e7e7e7; 99 | } 100 | 101 | .section-heading { 102 | margin-bottom: 30px; 103 | } 104 | 105 | .section-heading-spacer { 106 | float: left; 107 | width: 200px; 108 | border-top: 3px solid #e7e7e7; 109 | } 110 | 111 | .banner { 112 | padding: 100px 0; 113 | color: #f8f8f8; 114 | background: url(../img/banner-bg.jpg) no-repeat center center; 115 | background-size: cover; 116 | } 117 | 118 | .banner h2 { 119 | margin: 0; 120 | text-shadow: 2px 2px 3px rgba(0,0,0,0.6); 121 | font-size: 3em; 122 | } 123 | 124 | .banner ul { 125 | margin-bottom: 0; 126 | } 127 | 128 | .banner-social-buttons { 129 | float: right; 130 | margin-top: 0; 131 | } 132 | 133 | @media(max-width:1199px) { 134 | ul.banner-social-buttons { 135 | float: left; 136 | margin-top: 15px; 137 | } 138 | } 139 | 140 | @media(max-width:767px) { 141 | .banner h2 { 142 | margin: 0; 143 | text-shadow: 2px 2px 3px rgba(0,0,0,0.6); 144 | font-size: 3em; 145 | } 146 | 147 | ul.banner-social-buttons > li { 148 | display: block; 149 | margin-bottom: 20px; 150 | padding: 0; 151 | } 152 | 153 | ul.banner-social-buttons > li:last-child { 154 | margin-bottom: 0; 155 | } 156 | } 157 | 158 | footer { 159 | padding: 50px 0; 160 | background-color: #f8f8f8; 161 | } 162 | 163 | p.copyright { 164 | margin: 15px 0 0; 165 | } 166 | 167 | table.fetches td { 168 | text-align: right; 169 | } 170 | -------------------------------------------------------------------------------- /sampleloader/populate.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | import json 5 | import hashlib 6 | import time 7 | import glob 8 | 9 | import click 10 | import requests 11 | import redis 12 | 13 | 14 | _base = 'http://localhost:3000' 15 | _here = os.path.dirname(__file__) 16 | 17 | 18 | def get_blogposts(): 19 | items = json.load(open(os.path.join(_here, 'blogposts.json')))['items'] 20 | for i, item in enumerate(items): 21 | url = 'http://www.peterbe.com/plog/%s' % item['slug'] 22 | yield (item['title'], url, len(items) - i, None) 23 | 24 | def get_events(): 25 | items = json.load(open(os.path.join(_here, 'airmoevents.json')))['items'] 26 | for i, item in enumerate(items): 27 | group = item['group'] 28 | group = group != 'public' and group or '' 29 | yield (item['title'], item['url'], item['popularity'], group) 30 | 31 | 32 | def get_items(jsonfile): 33 | items = json.load(open(jsonfile))['items'] 34 | for i, item in enumerate(items): 35 | if 'group' in item: 36 | group = item['group'] 37 | group = group != 'public' and group or '' 38 | else: 39 | group = None 40 | yield (item['title'], item['url'], item.get('popularity'), group) 41 | 42 | 43 | def populate(database, destination, domain, jsonfile, flush=False, bulk=False): 44 | c = redis.StrictRedis(host='localhost', port=6379, db=database) 45 | if flush: 46 | c.flushdb() 47 | key = hashlib.md5(open(__file__).read()).hexdigest() 48 | 49 | print "KEY", key 50 | print "DOMAIN", domain 51 | c.hset('$domainkeys', key, domain) 52 | c.sadd('$userdomains$peterbe', key) 53 | 54 | items = get_items(jsonfile) 55 | # items = get_blogposts() 56 | #items = get_events() 57 | t0 = time.time() 58 | if bulk: 59 | _in_bulk(destination, items, key) 60 | else: 61 | _one_at_a_time(destination, items, key) 62 | t1 = time.time() 63 | print "TOOK", t1 - t0 64 | 65 | 66 | def _one_at_a_time(destination, items, key): 67 | for title, url, popularity, group in items: 68 | _url = destination + '/v1' 69 | data = { 70 | 'title': title, 71 | 'url': url, 72 | 'popularity': popularity, 73 | 'group': group, 74 | } 75 | #print (url, title, popularity, group) 76 | r = requests.post( 77 | _url, 78 | data=data, 79 | headers={'Auth-Key': key} 80 | ) 81 | #print r.status_code 82 | assert r.status_code == 201, r.status_code 83 | 84 | 85 | def _in_bulk(destination, items, key): 86 | data = { 87 | 'documents': [ 88 | dict( 89 | title=t, 90 | url=u, 91 | popularity=p, 92 | group=g 93 | ) 94 | for t, u, p, g in items 95 | ] 96 | } 97 | _url = destination + '/v1/bulk' 98 | r = requests.post( 99 | _url, 100 | data=json.dumps(data), 101 | headers={'Auth-Key': key} 102 | ) 103 | assert r.status_code == 201, r.status_code 104 | 105 | 106 | _json_files = glob.glob(os.path.join(_here, '*.json')) 107 | 108 | @click.command() 109 | @click.option('--database', '-d', default=8) 110 | @click.option('--destination', default='https://autocompeter.com') 111 | @click.option('--domain', default='autocompeter.com') 112 | @click.option('--dataset', default='blogposts.json') 113 | @click.option('--flush', default=False, is_flag=True) 114 | @click.option('--no-bulk', default=False, is_flag=True) 115 | def run(database, destination, domain, dataset, flush=False, no_bulk=True): 116 | # print (database, domain, flush) 117 | bulk = not no_bulk 118 | for filename in _json_files: 119 | if os.path.basename(filename) == dataset: 120 | jsonfile = filename 121 | break 122 | else: 123 | raise ValueError("dataset %r not recognized" % dataset) 124 | populate( 125 | database, 126 | destination, 127 | domain, 128 | jsonfile, 129 | flush=flush, 130 | bulk=bulk, 131 | ) 132 | 133 | if __name__ == '__main__': 134 | run() 135 | -------------------------------------------------------------------------------- /public/dist/autocompeter.min.js: -------------------------------------------------------------------------------- 1 | /* Autocompeter.com 1.2.3 */ 2 | !function(e,t){"use strict";function n(e,n){var a=t.createElement(e);for(var l in n)a[l]=n[l];return a}function a(e,t,n){e.addEventListener?e.addEventListener(t,n,!1):e.attachEvent(t,n)}function l(){var e,t,n,a,l,r,u,o=[].slice;for(a=arguments[0],n=2<=arguments.length?o.call(arguments,1):[],r=0,u=n.length;u>r;r++){t=n[r];for(e in t)l=t[e],a[e]=l}return a}function r(r,u){function o(e){return e.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g,"\\$&")}function s(e){var t=M.map(o),n=new RegExp("\\b(("+t.join("|")+")[\\S']*)","gi");return e.replace(n,"$1")}function i(e){"P"===e.target.tagName&&w!==+e.target.dataset.i&&(w=+e.target.dataset.i,p())}function c(e,t){for(var n=[],a=t.map(o),l=new RegExp("\\b("+a.join("|")+")","gi"),r=0,u=e.length;u>r;r++)e[r][1].search(l)>-1&&n.push(e[r]);return n}function p(a){a=a||!1,!a&&X&&e.clearTimeout(X);var l,u;if(null!==k){if(a&&(k=c(k,M)),!k.length)return void(L.style.display="none");L.style.display="block";var i=L.getElementsByTagName("p");for(l=i.length-1;l>=0;l--)i[l].remove();b=[];var p,v,d=null,f=[];k.length||(N.value="");var m=M.map(o),g=new RegExp("\\b("+m.join("|")+")(\\w+)\\b","gi"),h=t.createDocumentFragment();for(l=0,u=k.length;u>l;l++){for(var y,E;null!==(y=g.exec(k[l][1]));)E=new RegExp("\\b"+o(y[0])+"\\b","gi"),d=y[y.length-1],void 0===d||E.test(r.value)||(w===l||-1===w&&0===l)&&f.push(d);p=n("p"),l===w&&x&&p.classList.add("selected"),p.dataset.i=l,v=n("a",{innerHTML:s(k[l][1]),href:k[l][0]}),p.appendChild(v),h.appendChild(p),b.push(p)}L.appendChild(h),f.length&&" "!==r.value.charAt(r.value.length-1)?(d=f[Math.max(0,w)%f.length],N.value=r.value+d):N.value=""}}function v(e){var t=e.parentNode;return null===t?void console.warn("Form can not be found. Nothing to submit"):"FORM"===t.nodeName?t:v(t)}function d(t){var n,a;if("tab"===t)N.value!==r.value&&(r.value=N.value+" "),r.value!==N.value&&g(),x=!0;else if("down"===t||"up"===t){for("down"===t?w=Math.min(b.length-1,++w):"up"===t&&(w=Math.max(0,--w)),n=0,a=b.length;a>n;n++)n===w&&x?b[n].classList.add("selected"):b[n].classList.remove("selected");x=!0,p()}else if("enter"===t){if(!b.length||!x){var l=v(r);return l&&l.submit(),!0}var u=b[Math.max(0,w)],o=u.getElementsByTagName("a")[0];r.value=N.value=o.textContent,L.style.display="none",e.location=o.href}else"esc"===t&&(L.style.display="none");return!1}function f(e){var t={13:"enter",9:"tab",38:"up",40:"down",27:"esc"};return t[e.keyCode]?(e.preventDefault(),d(t[e.keyCode])):!1}function m(){L.style.display="none"}function g(){if(!r.value.trim())return N.value="",void(L.style.display="none");if(N.value.length&&(-1===N.value.indexOf(r.value.trim())&&(N.value=r.value),"block"===L.style.display&&(M=r.value.trim().split(/\s+/),X=e.setTimeout(function(){p(!0)},150))),w=-1,x=!1,j[r.value.trim()]){var t=j[r.value.trim()];M=t.terms,k=t.results,p()}else{if(T&&T.abort(),e.XMLHttpRequest)T=new e.XMLHttpRequest;else{if(!e.ActiveXObject)return;T=new e.ActiveXObject("Microsoft.XMLHTTP")}if(r.value.trim().length){var n=r.value.trim().substring(0,r.value.trim().length-1);if(j[n]&&!j[n].results.length)return void(j[r.value.trim()]={results:[]})}T.onreadystatechange=function(){if(4===T.readyState)if(200===T.status){if(R===r.value){var e=JSON.parse(T.responseText);j[r.value.trim()]=e,M=e.terms,k=e.results,p()}}else 0!==T.status&&(console.warn(T.status,T.responseText),m())},T.open("GET",u.url+encodeURIComponent(r.value.trim()),!0),R=r.value,T.send()}}function h(e){N.value=r.value,setTimeout(function(){L.style.display="none"},200),e.preventDefault()}function y(){if(A&&u.ping&&e.XMLHttpRequest){var t=new e.XMLHttpRequest;t.open("GET",u.url.split("?")[0]+"/ping"),t.send(),A=!1}r.value.length&&b.length&&(L.style.display="block")}u=l({url:"https://autocompeter.com/v1",domain:location.host,groups:null,ping:!0},u||{}),u.url+=u.url.indexOf("?")>-1?"&":"?",u.number&&(u.url+="n="+u.number+"&"),u.groups&&(Array.isArray(u.groups)&&(u.groups=u.groups.join(",")),u.url+="g="+encodeURIComponent(u.groups)+"&"),u.url+="d="+u.domain+"&q=";var b=[],w=-1,x=!1;r.spellcheck=!1,r.autocomplete="off";var E=n("span",{className:"_ac-wrap"}),N=n("input",{tabindex:-1,spellcheck:!1,autocomplete:"off",readonly:"readonly",type:r.type||"text",className:r.className+" _ac-hint"});r.classList.add("_ac-foreground"),E.appendChild(N);var C=r.cloneNode(!0);E.appendChild(C);var L=n("div",{className:"_ac-results"});a(L,"mouseover",i),E.appendChild(L),r.parentElement.insertBefore(E,r),r.parentNode.removeChild(r),r=C;var M,T,R,k=null,X=null,j={},A=!0;a(r,"input",g),a(r,"keydown",f),a(r,"blur",h),a(r,"focus",y)}e.Autocompeter=r}(window,document); -------------------------------------------------------------------------------- /autocompeter/static/autocompeter.min.js: -------------------------------------------------------------------------------- 1 | /* Autocompeter.com 1.2.3 */ 2 | !function(e,t){"use strict";function n(e,n){var a=t.createElement(e);for(var l in n)a[l]=n[l];return a}function a(e,t,n){e.addEventListener?e.addEventListener(t,n,!1):e.attachEvent(t,n)}function l(){var e,t,n,a,l,r,u,o=[].slice;for(a=arguments[0],n=2<=arguments.length?o.call(arguments,1):[],r=0,u=n.length;u>r;r++){t=n[r];for(e in t)l=t[e],a[e]=l}return a}function r(r,u){function o(e){return e.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g,"\\$&")}function s(e){var t=M.map(o),n=new RegExp("\\b(("+t.join("|")+")[\\S']*)","gi");return e.replace(n,"$1")}function i(e){"P"===e.target.tagName&&w!==+e.target.dataset.i&&(w=+e.target.dataset.i,p())}function c(e,t){for(var n=[],a=t.map(o),l=new RegExp("\\b("+a.join("|")+")","gi"),r=0,u=e.length;u>r;r++)e[r][1].search(l)>-1&&n.push(e[r]);return n}function p(a){a=a||!1,!a&&X&&e.clearTimeout(X);var l,u;if(null!==k){if(a&&(k=c(k,M)),!k.length)return void(L.style.display="none");L.style.display="block";var i=L.getElementsByTagName("p");for(l=i.length-1;l>=0;l--)i[l].remove();b=[];var p,v,d=null,f=[];k.length||(N.value="");var m=M.map(o),g=new RegExp("\\b("+m.join("|")+")(\\w+)\\b","gi"),h=t.createDocumentFragment();for(l=0,u=k.length;u>l;l++){for(var y,E;null!==(y=g.exec(k[l][1]));)E=new RegExp("\\b"+o(y[0])+"\\b","gi"),d=y[y.length-1],void 0===d||E.test(r.value)||(w===l||-1===w&&0===l)&&f.push(d);p=n("p"),l===w&&x&&p.classList.add("selected"),p.dataset.i=l,v=n("a",{innerHTML:s(k[l][1]),href:k[l][0]}),p.appendChild(v),h.appendChild(p),b.push(p)}L.appendChild(h),f.length&&" "!==r.value.charAt(r.value.length-1)?(d=f[Math.max(0,w)%f.length],N.value=r.value+d):N.value=""}}function v(e){var t=e.parentNode;return null===t?void console.warn("Form can not be found. Nothing to submit"):"FORM"===t.nodeName?t:v(t)}function d(t){var n,a;if("tab"===t)N.value!==r.value&&(r.value=N.value+" "),r.value!==N.value&&g(),x=!0;else if("down"===t||"up"===t){for("down"===t?w=Math.min(b.length-1,++w):"up"===t&&(w=Math.max(0,--w)),n=0,a=b.length;a>n;n++)n===w&&x?b[n].classList.add("selected"):b[n].classList.remove("selected");x=!0,p()}else if("enter"===t){if(!b.length||!x){var l=v(r);return l&&l.submit(),!0}var u=b[Math.max(0,w)],o=u.getElementsByTagName("a")[0];r.value=N.value=o.textContent,L.style.display="none",e.location=o.href}else"esc"===t&&(L.style.display="none");return!1}function f(e){var t={13:"enter",9:"tab",38:"up",40:"down",27:"esc"};return t[e.keyCode]?(e.preventDefault(),d(t[e.keyCode])):!1}function m(){L.style.display="none"}function g(){if(!r.value.trim())return N.value="",void(L.style.display="none");if(N.value.length&&(-1===N.value.indexOf(r.value.trim())&&(N.value=r.value),"block"===L.style.display&&(M=r.value.trim().split(/\s+/),X=e.setTimeout(function(){p(!0)},150))),w=-1,x=!1,j[r.value.trim()]){var t=j[r.value.trim()];M=t.terms,k=t.results,p()}else{if(T&&T.abort(),e.XMLHttpRequest)T=new e.XMLHttpRequest;else{if(!e.ActiveXObject)return;T=new e.ActiveXObject("Microsoft.XMLHTTP")}if(r.value.trim().length){var n=r.value.trim().substring(0,r.value.trim().length-1);if(j[n]&&!j[n].results.length)return void(j[r.value.trim()]={results:[]})}T.onreadystatechange=function(){if(4===T.readyState)if(200===T.status){if(R===r.value){var e=JSON.parse(T.responseText);j[r.value.trim()]=e,M=e.terms,k=e.results,p()}}else 0!==T.status&&(console.warn(T.status,T.responseText),m())},T.open("GET",u.url+encodeURIComponent(r.value.trim()),!0),R=r.value,T.send()}}function h(e){N.value=r.value,setTimeout(function(){L.style.display="none"},200),e.preventDefault()}function y(){if(A&&u.ping&&e.XMLHttpRequest){var t=new e.XMLHttpRequest;t.open("GET",u.url.split("?")[0]+"/ping"),t.send(),A=!1}r.value.length&&b.length&&(L.style.display="block")}u=l({url:"https://autocompeter.com/v1",domain:location.host,groups:null,ping:!0},u||{}),u.url+=u.url.indexOf("?")>-1?"&":"?",u.number&&(u.url+="n="+u.number+"&"),u.groups&&(Array.isArray(u.groups)&&(u.groups=u.groups.join(",")),u.url+="g="+encodeURIComponent(u.groups)+"&"),u.url+="d="+u.domain+"&q=";var b=[],w=-1,x=!1;r.spellcheck=!1,r.autocomplete="off";var E=n("span",{className:"_ac-wrap"}),N=n("input",{tabindex:-1,spellcheck:!1,autocomplete:"off",readonly:"readonly",type:r.type||"text",className:r.className+" _ac-hint"});r.classList.add("_ac-foreground"),E.appendChild(N);var C=r.cloneNode(!0);E.appendChild(C);var L=n("div",{className:"_ac-results"});a(L,"mouseover",i),E.appendChild(L),r.parentElement.insertBefore(E,r),r.parentNode.removeChild(r),r=C;var M,T,R,k=null,X=null,j={},A=!0;a(r,"input",g),a(r,"keydown",f),a(r,"blur",h),a(r,"focus",y)}e.Autocompeter=r}(window,document); -------------------------------------------------------------------------------- /autocompeter/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | from decouple import config, Csv 5 | from unipath import Path 6 | import dj_database_url 7 | 8 | 9 | BASE_DIR = Path(__file__).parent 10 | # See https://docs.djangoproject.com/en/1.10/howto/deployment/checklist/ 11 | 12 | SECRET_KEY = config('SECRET_KEY') 13 | 14 | DEBUG = config('DEBUG', default=False, cast=bool) 15 | DEBUG_PROPAGATE_EXCEPTIONS = config( 16 | 'DEBUG_PROPAGATE_EXCEPTIONS', 17 | False, 18 | cast=bool, 19 | ) 20 | 21 | ALLOWED_HOSTS = config('ALLOWED_HOSTS', cast=Csv()) 22 | 23 | # Application definition 24 | 25 | INSTALLED_APPS = [ 26 | 'django.contrib.auth', 27 | 'django.contrib.contenttypes', 28 | 'django.contrib.sessions', 29 | 'django.contrib.messages', 30 | 'django.contrib.postgres', 31 | 'django.contrib.staticfiles', 32 | 33 | 'autocompeter.main', 34 | 'autocompeter.api', 35 | ] 36 | 37 | MIDDLEWARE = [ 38 | 'django.middleware.security.SecurityMiddleware', 39 | 'django.contrib.sessions.middleware.SessionMiddleware', 40 | 'django.middleware.common.CommonMiddleware', 41 | 'django.middleware.csrf.CsrfViewMiddleware', 42 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 43 | 'django.contrib.messages.middleware.MessageMiddleware', 44 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 45 | 'django.contrib.sites.middleware.CurrentSiteMiddleware', 46 | ] 47 | 48 | ROOT_URLCONF = 'autocompeter.urls' 49 | 50 | TEMPLATES = [ 51 | { 52 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 53 | 'DIRS': [], 54 | 'APP_DIRS': True, 55 | 'OPTIONS': { 56 | 'context_processors': [ 57 | 'django.template.context_processors.debug', 58 | 'django.template.context_processors.request', 59 | 'django.contrib.messages.context_processors.messages', 60 | 'autocompeter.authentication.context_processors.auth0', 61 | ], 62 | }, 63 | }, 64 | ] 65 | 66 | WSGI_APPLICATION = 'autocompeter.wsgi.application' 67 | 68 | 69 | DATABASES = { 70 | 'default': config( 71 | 'DATABASE_URL', 72 | default='postgres://postgres@db:5432/postgres', 73 | cast=dj_database_url.parse 74 | ) 75 | } 76 | 77 | CACHES = { 78 | 'default': { 79 | 'BACKEND': config( 80 | 'CACHE_BACKEND', 81 | 'django.core.cache.backends.memcached.MemcachedCache', 82 | ), 83 | 'LOCATION': config('CACHE_LOCATION', '127.0.0.1:11211'), 84 | 'TIMEOUT': config('CACHE_TIMEOUT', 500), 85 | 'KEY_PREFIX': config('CACHE_KEY_PREFIX', 'autocompeter'), 86 | } 87 | } 88 | 89 | 90 | LANGUAGE_CODE = 'en-us' 91 | 92 | TIME_ZONE = 'UTC' 93 | 94 | USE_I18N = False 95 | 96 | USE_L10N = False 97 | 98 | USE_TZ = True 99 | 100 | 101 | SESSION_COOKIE_AGE = 60 * 60 * 24 * 365 102 | 103 | 104 | # Static files (CSS, JavaScript, Images) 105 | # https://docs.djangoproject.com/en/1.10/howto/static-files/ 106 | 107 | STATIC_URL = '/static/' 108 | 109 | STATICFILES_DIRS = [ 110 | os.path.abspath(os.path.join(BASE_DIR, '../public/dist')), 111 | ] 112 | 113 | 114 | STATIC_ROOT = os.path.join(BASE_DIR, 'static') 115 | 116 | 117 | # ElasticSearch 118 | 119 | ES_INDEX = 'autocompeter' 120 | 121 | ES_INDEX_SETTINGS = { 122 | 'number_of_shards': 1, 123 | 'number_of_replicas': 0, 124 | } 125 | 126 | ES_CONNECTIONS = { 127 | 'default': { 128 | 'hosts': config( 129 | 'ES_CONNECTIONS_URLS', 130 | default='localhost:9200', 131 | cast=Csv() 132 | ) 133 | }, 134 | } 135 | 136 | AUTH0_CLIENT_ID = config('AUTH0_CLIENT_ID', 'FCTzKEnD2H88IuYWJredjYH6fWgp0FlM') 137 | AUTH0_DOMAIN = config('AUTH0_DOMAIN', 'peterbecom.auth0.com') 138 | AUTH0_CALLBACK_URL = config('AUTH0_CALLBACK_URL', '/auth/callback') 139 | AUTH0_SIGNOUT_URL = config('AUTH0_SIGNOUT_URL', '/') 140 | AUTH0_SUCCESS_URL = config('AUTH0_SUCCESS_URL', 'main:home') 141 | AUTH0_CLIENT_SECRET = config('AUTH0_CLIENT_SECRET', '') 142 | AUTH0_PATIENCE_TIMEOUT = config('AUTH0_PATIENCE_TIMEOUT', 5, cast=int) 143 | 144 | 145 | if 'test' in sys.argv[1:2]: 146 | # os.environ['OPBEAT_DISABLE_SEND'] = 'true' 147 | CACHES = { 148 | 'default': { 149 | 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', 150 | 'LOCATION': 'unique-snowflake', 151 | } 152 | } 153 | ES_INDEX = 'test_autocompeter' 154 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Django==1.11.7 \ 2 | --hash=sha256:75ce405d60f092f6adf904058d023eeea0e6d380f8d9c36134bac73da736023d \ 3 | --hash=sha256:8918e392530d8fc6965a56af6504229e7924c27265893f3949aa0529cd1d4b99 4 | elasticsearch-dsl==6.1.0 \ 5 | --hash=sha256:5114a38a88e93a4663782eae07a1e8084ba333c49887335c83de8b8043bc72b2 \ 6 | --hash=sha256:d6d974cd2289543a3350690494a43fe9996485b8dc6f1d8758cb56bee01244bd 7 | elasticsearch==6.1.1 \ 8 | --hash=sha256:307055861d0290b830bd1ec4b82d41ce0f19f6a4899635956bd16bc61e3e90b1 \ 9 | --hash=sha256:8d91a3fce12123a187b673f18c23bcffa6e7b49ba057555d59eeeded0ba15dce 10 | python-decouple==3.1 \ 11 | --hash=sha256:1317df14b43efee4337a4aa02914bf004f010cd56d6c4bd894e6474ec8c4fe2d 12 | Unipath==1.1 \ 13 | --hash=sha256:e6257e508d8abbfb6ddd8ec357e33589f1f48b1599127f23b017124d90b0fff7 \ 14 | --hash=sha256:09839adcc72e8a24d4f76d63656f30b5a1f721fc40c9bcd79d8c67bdd8b47dae 15 | psycopg2==2.6.2 \ 16 | --hash=sha256:60f29f91a88fe7c2d1eb7fb64f3ea99b0bd3d06ea7169e187ccb2cb653f91311 \ 17 | --hash=sha256:48c1648d090ca72cf430920fb62f138cd02f9d2b035d2d2654af0a38f28bdc27 \ 18 | --hash=sha256:6b6f745fb3a94a8d48b2e225e14808768ed33c52993ad6319b8f9cb972fec4dd \ 19 | --hash=sha256:53973aea916a92a172e46b3181fc8f904c9013ae17513ee3029386084449ef07 \ 20 | --hash=sha256:224bd45f838f8a714b8e711b4167158d86d01f398c678c46330caf59684a608f \ 21 | --hash=sha256:ceee85d0b05e2b6e178e8aaa1d7e7ee679e5b712ef7a34798f5136321fe6bb3c \ 22 | --hash=sha256:83afd42c95ac9e745ba9dcd28c20142ffa85a2ecc628d40fdc85342018ac016b \ 23 | --hash=sha256:1ee3f027684db469e3aafa9d4897ed1ca19c599b772e12dca7e61ed1b30ce26e \ 24 | --hash=sha256:863fae11c31f5a7b9ce1e738149793214aad36cff4ca92d7111562e2fdbd7b57 \ 25 | --hash=sha256:e03e5df05f85768af112e287cd89eecfce8a8ca2d6db3531402f7f0b0704d749 \ 26 | --hash=sha256:8ffbd1128df23c9fdfc3499084021055b3df7818f12ef87af5b3f33e27d58b0a \ 27 | --hash=sha256:8c3b69d743e408527208d5ed6aa136b821bbd3cb1e236aa8479ff47ea986769c \ 28 | --hash=sha256:70490e12ed9c5c818ecd85d185d363335cc8a8cbf7212e3c185431c79ff8c05c 29 | python-dateutil==2.6.1 \ 30 | --hash=sha256:891c38b2a02f5bb1be3e4793866c8df49c7d19baabf9c1bad62547e0b4866aca \ 31 | --hash=sha256:95511bae634d69bc7329ba55e646499a842bc4ec342ad54a8cdb65645a0aad3c 32 | six==1.11.0 \ 33 | --hash=sha256:832dc0e10feb1aa2c68dcc57dbb658f1c7e65b9b61af69048abc87a2db00a0eb \ 34 | --hash=sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9 35 | urllib3==1.22 \ 36 | --hash=sha256:06330f386d6e4b195fbfc736b297f58c5a892e4440e54d294d7004e3a9bbea1b \ 37 | --hash=sha256:cc44da8e1145637334317feebd728bd869a35285b93cbb4cca2577da7e62db4f 38 | dj-database-url==0.4.2 \ 39 | --hash=sha256:a6832d8445ee9d788c5baa48aef8130bf61fdc442f7d9a548424d25cd85c9f08 \ 40 | --hash=sha256:e16d94c382ea0564c48038fa7fe8d9c890ef1ab1a8ec4cb48e732c124b9482fd 41 | requests==2.18.4 \ 42 | --hash=sha256:6a1b267aa90cac58ac3a765d067950e7dbbf75b1da07e895d1f594193a40a38b \ 43 | --hash=sha256:9c443e7324ba5b85070c4a818ade28bfabedf16ea10206da1132edaa6dda237e 44 | pytz==2018.3 \ 45 | --hash=sha256:ed6509d9af298b7995d69a440e2822288f2eca1681b8cce37673dbb10091e5fe \ 46 | --hash=sha256:f93ddcdd6342f94cea379c73cddb5724e0d6d0a1c91c9bdef364dc0368ba4fda \ 47 | --hash=sha256:61242a9abc626379574a166dc0e96a66cd7c3b27fc10868003fa210be4bff1c9 \ 48 | --hash=sha256:ba18e6a243b3625513d85239b3e49055a2f0318466e0b8a92b8fb8ca7ccdf55f \ 49 | --hash=sha256:07edfc3d4d2705a20a6e99d97f0c4b61c800b8232dc1c04d87e8554f130148dd \ 50 | --hash=sha256:3a47ff71597f821cd84a162e71593004286e5be07a340fd462f0d33a760782b5 \ 51 | --hash=sha256:5bd55c744e6feaa4d599a6cbd8228b4f8f9ba96de2c38d56f08e534b3c9edf0d \ 52 | --hash=sha256:887ab5e5b32e4d0c86efddd3d055c1f363cbaa583beb8da5e22d2fa2f64d51ef \ 53 | --hash=sha256:410bcd1d6409026fbaa65d9ed33bf6dd8b1e94a499e32168acfc7b332e4095c0 54 | ipaddress==1.0.19 \ 55 | --hash=sha256:200d8686011d470b5e4de207d803445deee427455cd0cb7c982b68cf82524f81 56 | chardet==3.0.4 \ 57 | --hash=sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691 \ 58 | --hash=sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae 59 | idna==2.6 \ 60 | --hash=sha256:8c7309c718f94b3a625cb648ace320157ad16ff131ae0af362c9f21b80ef6ec4 \ 61 | --hash=sha256:2c6a5de3089009e3da7c5dde64a141dbc8551d5b7f6cf4ed7c2568d0cc520a8f 62 | certifi==2018.1.18 \ 63 | --hash=sha256:14131608ad2fd56836d33a71ee60fa1c82bc9d2c8d98b7bdbc631fe1b3cd1296 \ 64 | --hash=sha256:edbc3f203427eef571f79a7692bb160a2b0f7ccaa31953e99bd17e307cf63f7d 65 | -------------------------------------------------------------------------------- /docs/javascript.md: -------------------------------------------------------------------------------- 1 | # The Javascript 2 | 3 | ## Regular download 4 | 5 | You can either download the javascript code and put it into your own site 6 | or you can leave it to Autocompeter to host it on its CDN (backed by 7 | AWS CloudFront). To download, go to: 8 | 9 | [**https://raw.githubusercontent.com/peterbe/autocompeter/master/public/dist/autocompeter.min.js**](https://raw.githubusercontent.com/peterbe/autocompeter/master/public/dist/autocompeter.min.js) 10 | 11 | This is the optimized version. If you want to, you can download [the 12 | non-minified file](https://raw.githubusercontent.com/peterbe/autocompeter/master/public/dist/autocompeter.js) instead which is easier to debug or hack on. 13 | 14 | ## Bower 15 | 16 | You can use [Bower](http://bower.io/) to download the code too. 17 | This will only install the files in a local directory called 18 | `bower_components`. This is how you download and install with Bower: 19 | 20 | bower install autocompeter 21 | ls bower_components/autocompeter/public/dist/ 22 | 23 | It's up to you if you leave it there or if you copy those files where you 24 | prefer them to be. 25 | 26 | ## Hosted 27 | 28 | To use our CDN the URL is: 29 | 30 | [http://cdn.jsdelivr.net/autocompeter/1/autocompeter.min.js](http://cdn.jsdelivr.net/autocompeter/1/autocompeter.min.js) (or [https version](https://cdn.jsdelivr.net/autocompeter/1/autocompeter.min.js)) 31 | 32 | But it's recommended that when you insert this URL into your site, instead 33 | of prefixing it with `http://` or `https://` prefix it with just `//`. E.g. 34 | like this: 35 | 36 | 37 | 38 | If you want to use a more specific version on the 39 | [jsdelivr.net CDN](http://www.jsdelivr.com/) with more aggressive cache headers 40 | then go to [http://www.jsdelivr.com/#!autocompeter](http://www.jsdelivr.com/#!autocompeter) 41 | and pick up the latest version. 42 | 43 | ## Configure the Javascript 44 | 45 | ### Basics 46 | 47 | So, for the widget to work you need to have an `` field 48 | somewhere. You can put an attributes you like on it like `name="something"` 49 | or `class="mysearch"` for example. But you need to be able to retrieve it as 50 | a HTML DOM node because when you activate `Autocompeter` the first and only 51 | required argument is the input DOM node. For example: 52 | 53 | 56 | 57 | or... 58 | 59 | 62 | 63 | ### Custom domain 64 | 65 | By default it uses the domain that is currently being used. It retrieves this 66 | by simply using `window.location.host`. If you for example, have a dev or 67 | staging version of your site but you want to use the production domain, you 68 | can manually set the domain like this: 69 | 70 | 75 | 76 | ### Number of results to display 77 | 78 | There are a couple of other options you can override. For example the 79 | maximum number of items to be returned. The default is 10.: 80 | 81 | 87 | 88 | ### Limiting results by 'groups' 89 | 90 | Another thing you might want to set is the `groups` parameter. Note that 91 | this can be an array or a comma separated string. Suppose that this information 92 | is set by the server-side rendering, you can use it like this for example, 93 | assuming here some server-side template rendering code like Django or Jinja 94 | for example: 95 | 96 | 110 | 111 | So, suppose you set `groups: "private,admins"` it will still search on titles 112 | that were defined with no group. Doing it like this will just return 113 | potentially more titles. 114 | 115 | ### Send a 'ping' first 116 | 117 | For the web performance freaks. 118 | 119 | Oftentimes the slowest part of a web service is DNS. The best way to avoid 120 | that is to "warm up" the connection between users of your site and 121 | `autocompeter.com`. This includes a first DNS lookup, the SSL certificate 122 | negotiation and just making a TCP connection. 123 | 124 | As soon as your user puts focus in the search widget you have 125 | configured a ping is sent to `https://autocompeter.com/v1/ping` and basically 126 | does nothing but it does prepare the connection for that first typing in 127 | of the first character which starts the first autocomplete search. 128 | 129 | This feature is enabled by default, to undo it set it to false like this: 130 | 131 | 136 | -------------------------------------------------------------------------------- /autocompeter/authentication/views.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import base64 3 | from urllib.parse import urlencode 4 | 5 | import requests 6 | from requests.exceptions import ConnectTimeout, ReadTimeout 7 | 8 | from django import http 9 | from django.conf import settings 10 | from django.contrib import messages 11 | from django.shortcuts import redirect 12 | from django.utils.encoding import smart_bytes 13 | from django.contrib.auth import get_user_model, login, logout 14 | from django.views.decorators.http import require_POST 15 | 16 | 17 | User = get_user_model() 18 | 19 | 20 | def callback(request): 21 | code = request.GET.get('code', '') 22 | if not code: 23 | # If the user is blocked, we will never be called back with a code. 24 | # What Auth0 does is that it calls the callback but with extra 25 | # query string parameters. 26 | if request.GET.get('error'): 27 | messages.error( 28 | request, 29 | "Unable to sign in because of an error from Auth0. " 30 | "({})".format( 31 | request.GET.get( 32 | 'error_description', 33 | request.GET['error'] 34 | ) 35 | ) 36 | ) 37 | return redirect('main:home') 38 | return http.HttpResponseBadRequest("Missing 'code'") 39 | token_url = 'https://{}/oauth/token'.format(settings.AUTH0_DOMAIN) 40 | 41 | callback_url = settings.AUTH0_CALLBACK_URL 42 | if '://' not in callback_url: 43 | callback_url = '{}://{}{}'.format( 44 | 'https' if request.is_secure() else 'http', 45 | request.site.domain, 46 | callback_url, 47 | ) 48 | token_payload = { 49 | 'client_id': settings.AUTH0_CLIENT_ID, 50 | 'client_secret': settings.AUTH0_CLIENT_SECRET, 51 | 'redirect_uri': callback_url, 52 | 'code': code, 53 | 'grant_type': 'authorization_code', 54 | } 55 | try: 56 | token_info = requests.post( 57 | token_url, 58 | json=token_payload, 59 | timeout=settings.AUTH0_PATIENCE_TIMEOUT, 60 | ).json() 61 | except (ConnectTimeout, ReadTimeout): 62 | raise 63 | messages.error( 64 | request, 65 | 'Unable to authenticate with Auth0. The Auth0 service timed out. ' 66 | 'This is most likely temporary so you can try again in a couple ' 67 | 'of minutes.' 68 | ) 69 | return redirect('main:home') 70 | 71 | if not token_info.get('access_token'): 72 | messages.error( 73 | request, 74 | 'Unable to authenticate with Auth0. Most commonly this ' 75 | 'happens because the authentication token has expired. ' 76 | 'Please refresh and try again.' 77 | ) 78 | return redirect('main:home') 79 | 80 | user_url = 'https://{}/userinfo'.format( 81 | settings.AUTH0_DOMAIN, 82 | ) 83 | user_url += '?' + urlencode({ 84 | 'access_token': token_info['access_token'], 85 | }) 86 | try: 87 | user_response = requests.get( 88 | user_url, 89 | timeout=settings.AUTH0_PATIENCE_TIMEOUT, 90 | ) 91 | except (ConnectTimeout, ReadTimeout): 92 | messages.error( 93 | request, 94 | 'Unable to authenticate with Auth0. The Auth0 service timed out. ' 95 | 'This is most likely temporary so you can try again in a couple ' 96 | 'of minutes.' 97 | ) 98 | return redirect('main:home') 99 | if user_response.status_code != 200: 100 | messages.error( 101 | request, 102 | 'Unable to retrieve user info from Auth0 ({}, {!r})'.format( 103 | user_response.status_code, 104 | user_response.text 105 | ) 106 | ) 107 | return redirect('main:home') 108 | 109 | user_info = user_response.json() 110 | assert user_info['email'], user_info 111 | 112 | if not user_info['email_verified']: 113 | messages.error( 114 | request, 115 | 'Email {} not verified.'.format( 116 | user_info['email'] 117 | ) 118 | ) 119 | return redirect('main:home') 120 | user = get_user(user_info) 121 | 122 | if not user.is_active: 123 | messages.error( 124 | request, 125 | "User account ({}) found but it has been made inactive.".format( 126 | user.email, 127 | ) 128 | ) 129 | return redirect('main:home') 130 | else: 131 | user.backend = 'django.contrib.auth.backends.ModelBackend' 132 | print("logging in user", user) 133 | print(login(request, user)) 134 | messages.success( 135 | request, 136 | 'Signed in with email: {}'.format(user.email) 137 | ) 138 | return redirect(settings.AUTH0_SUCCESS_URL) 139 | 140 | 141 | def default_username(email): 142 | # Store the username as a base64 encoded sha1 of the email address 143 | # this protects against data leakage because usernames are often 144 | # treated as public identifiers (so we can't use the email address). 145 | return base64.urlsafe_b64encode( 146 | hashlib.sha1(smart_bytes(email)).digest() 147 | ).rstrip(b'=') 148 | 149 | 150 | def get_user(user_info): 151 | if 'api.github.com' in user_info.get('url', ''): 152 | # It's a GitHub user 153 | username = user_info['nickname'] 154 | 155 | try: 156 | return User.objects.get(username=username) 157 | except User.DoesNotExist: 158 | first_name = last_name = '' 159 | if user_info.get('name'): 160 | first_name, last_name = user_info['name'].rsplit(None, 1) 161 | return User.objects.create( 162 | email=user_info['email'], 163 | username=username, 164 | first_name=first_name, 165 | last_name=last_name, 166 | ) 167 | else: 168 | email = user_info['email'] 169 | 170 | try: 171 | return User.objects.get(email__iexact=email) 172 | except User.DoesNotExist: 173 | return User.objects.create( 174 | email=email, 175 | username=default_username(email), 176 | first_name=user_info.get('given_name') or '', 177 | last_name=user_info.get('family_name') or '', 178 | ) 179 | 180 | 181 | @require_POST 182 | def signout(request): 183 | signout_url = settings.AUTH0_SIGNOUT_URL 184 | if '://' not in signout_url: 185 | signout_url = '{}://{}{}'.format( 186 | 'https' if request.is_secure() else 'http', 187 | request.site.domain, 188 | signout_url, 189 | ) 190 | logout(request) 191 | messages.success(request, 'Signed out') 192 | url = 'https://' + settings.AUTH0_DOMAIN + '/v2/logout' 193 | url += '?' + urlencode({ 194 | 'returnTo': signout_url, 195 | 'client_id': settings.AUTH0_CLIENT_ID, 196 | }) 197 | return redirect(url) 198 | -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | # The API 2 | 3 | ## Getting an Auth Key 4 | 5 | To generate an authentication key, you have to go to 6 | [autocompeter.com](https://autocompeter.com/#login) and sign in using GitHub. 7 | 8 | Once you've done that you get access to a form where you can type in your 9 | domain name and generate a key. Copy-n-paste that somewhere secure and use 10 | when you access private API endpoints. 11 | 12 | Every Auth Key belongs to one single domain. 13 | E.g. `yoursecurekey->www.peterbe.com`. 14 | 15 | ## Submitting titles 16 | 17 | You have to submit one title at a time. (This might change in the near future) 18 | 19 | You'll need an Auth Key, a title, a URL, optionally a popularity number and 20 | optionally a group for access control.. 21 | 22 | The URL you need to do a **HTTP POST** to is: 23 | 24 | https://autocompeter.com/v1 25 | 26 | The Auth Key needs to be set as a HTTP header called `Auth-Key`. 27 | 28 | The parameters need to be sent as `application/x-www-form-urlencoded`. 29 | 30 | The keys you need to send are: 31 | 32 | | Key | Required | Example | 33 | |--------------|----------|----------------------------------| 34 | | `title` | Yes | A blog post example | 35 | | `url` | Yes | http://www.example.com/page.html | 36 | | `group` | No | loggedin | 37 | | `popularity` | No | 105 | 38 | 39 | Here's an example using `curl`: 40 | 41 | curl -X POST -H "Auth-Key: yoursecurekey" 42 | -d url=http://www.example.com/page.html \ 43 | -d title="A blog post example" \ 44 | -d group="loggedin" \ 45 | -d popularity="105" \ 46 | https://autocompeter.com/v1 47 | 48 | Here's the same example using Python [requests](https://requests.readthedocs.io/): 49 | 50 | response = requests.post( 51 | 'https://autocompeter.com/v1', 52 | data={ 53 | 'title': 'A blog post example', 54 | 'url': 'http://www.example.com/page.html', 55 | 'group': 'loggedin', 56 | 'popularity': 105 57 | }, 58 | headers={ 59 | 'Auth-Key': 'yoursecurekey' 60 | } 61 | ) 62 | assert response.status_code == 201 63 | 64 | The response code will always be `201` and the response content will be 65 | `application/json` that simple looks like this: 66 | 67 | {"message": "OK"} 68 | 69 | ## Uniqueness of the URL 70 | 71 | You can submit two "documents" that have the same title but you can not submit 72 | two documents that have the same URL. If you submit: 73 | 74 | curl -X POST -H "Auth-Key: yoursecurekey" \ 75 | -d url=http://www.example.com/page.html \ 76 | -d title="This is the first title" \ 77 | https://autocompeter.com/v1 78 | # now the same URL, different title 79 | curl -X POST -H "Auth-Key: yoursecurekey" \ 80 | -d url=http://www.example.com/page.html \ 81 | -d title="A different title the second time" \ 82 | https://autocompeter.com/v1 83 | 84 | Then, the first title will be overwritten and replaced with the second title. 85 | 86 | ## About the popularity 87 | 88 | If you omit the `popularity` key, it's the same as sending it as `0`. 89 | 90 | The search will always be sorted by the `popularity` and the higher the number 91 | the higher the document title will appear in the search results. 92 | 93 | If you don't really have the concept of ranking your titles by a popularity 94 | or hits or score or anything like that, then use the titles "date" so that 95 | the most recent ones have higher priority. That way more fresh titles appear 96 | first. 97 | 98 | ## About the groups and access control and privacy 99 | 100 | Suppose your site visitors should see different things depending how they're 101 | signed in. Well, first of all you **can't do it on per-user basis**. 102 | 103 | However, suppose you have a set of titles for all visitors of the site 104 | and some extra just for people who are signed in, then you can use `group` 105 | as a parameter per title. 106 | 107 | **Note: There is no way to securely protect this information. You can 108 | make it so that restricted titles don't appear to people who shouldn't see 109 | it but it's impossible to prevent people from manually querying by a 110 | specific group on the command line for example. ** 111 | 112 | Note that you can have multiple groups. For example, the titles that is 113 | publically available you submit with no `group` set (or leave it as 114 | an empty string) and then you submit some as `group="private"` and some 115 | as `group="admins"`. 116 | 117 | ## How to delete a title/URL 118 | 119 | If a URL hasn't changed by the title has, you can simply submit it again. 120 | Or if neither the title or the URL has changed but the popularity has changed 121 | you can simply submit it again. 122 | 123 | However, suppose a title needs to be remove you send a **HTTP DELETE**. Send 124 | it to the same URL you use to submit a title. E.g. 125 | 126 | curl -X DELETE -H "Auth-Key: yoursecurekey" \ 127 | https://autocompeter.com/v1?url=http%3A//www.example.com/page.html 128 | 129 | Note that you can't use `application/x-www-form-urlencoded` with HTTP DELETE. 130 | So you have to put the `?url=...` into the URL. 131 | 132 | Note also that in this example the `url` is URL encoded. The `:` becomes `%3A`. 133 | 134 | ## How to remove all your documents 135 | 136 | You can start over and flush all the documents you have sent it by doing 137 | a HTTP DELETE request to the url `/v1/flush`. Like this: 138 | 139 | curl -X DELETE -H "Auth-Key: yoursecurekey" \ 140 | https://autocompeter.com/v1/flush 141 | 142 | This will reset the counts all related to your domain. The only thing that 143 | isn't removed is your auth key. 144 | 145 | ## Bulk upload 146 | 147 | Instead of submitting one "document" at a time you can instead send in a 148 | whole big JSON blob. The struct needs to be like this example: 149 | 150 | { 151 | "documents": [ 152 | { 153 | "url": "http://example.com/page1", 154 | "title": "Page One" 155 | }, 156 | { 157 | "url": "http://example.com/page2", 158 | "title": "Other page", 159 | "popularity": 123 160 | }, 161 | { 162 | "url": "http://example.com/page3", 163 | "title": "Last page", 164 | "group": "admins" 165 | }, 166 | ] 167 | } 168 | 169 | Note that the `popularity` and the `group` keys are optional. Each 170 | dictionary in the array called `documents` needs to have a `url` and `title`. 171 | 172 | The endpoint to use `https://autocompeter.com/v1/bulk` and you need to do a 173 | HTTP POST or a HTTP PUT. 174 | 175 | Here's an example using curl: 176 | 177 | curl -X POST -H "Auth-Key: 3b14d7c280bf525b779d0a01c601fe44" \ 178 | -d '{"documents": [{"url":"/url", "title":"My Title", "popularity":1001}]}' \ 179 | https://autocompeter.com/v1/bulk 180 | 181 | And here's an example using 182 | Python [requests](https://requests.readthedocs.io/en/latest/): 183 | 184 | 185 | ```python 186 | import json 187 | import requests 188 | 189 | documents = [ 190 | { 191 | 'url': '/some/page', 192 | 'title': 'Some title', 193 | 'popularity': 10 194 | }, 195 | { 196 | 'url': '/other/page', 197 | 'title': 'Other title', 198 | }, 199 | { 200 | 'url': '/private/page', 201 | 'title': 'Other private page', 202 | 'group': 'private' 203 | }, 204 | ] 205 | print requests.post( 206 | 'https://autocompeter.com/v1/bulk', 207 | data=json.dumps({'documents': documents}), 208 | headers={ 209 | 'Auth-Key': '3b14d7c280bf525b779d0a01c601fe44', 210 | } 211 | ) 212 | ``` 213 | -------------------------------------------------------------------------------- /autocompeter/api/tests.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from elasticsearch_dsl.connections import connections 4 | 5 | from django.core.urlresolvers import reverse 6 | from django.test import TestCase 7 | from django.core.management import call_command 8 | from django.contrib.auth.models import User 9 | from django.utils import timezone 10 | 11 | from autocompeter.main.models import Domain, Key, Search 12 | 13 | 14 | class IntegrationTestBase(TestCase): 15 | 16 | def setUp(self): 17 | super(IntegrationTestBase, self).setUp() 18 | call_command('create-index', verbosity=0, interactive=False) 19 | status = connections.get_connection().cluster.health()['status'] 20 | assert status == 'green', status 21 | 22 | @staticmethod 23 | def _refresh(): 24 | connections.get_connection().indices.refresh() 25 | 26 | 27 | class TestIntegrationAPI(IntegrationTestBase): 28 | 29 | def post_json(self, url, payload=None, **extra): 30 | payload = payload or {} 31 | extra['content_type'] = 'application/json' 32 | return self.client.post(url, json.dumps(payload), **extra) 33 | 34 | def test_happy_path_search(self): 35 | url = reverse('api:home') 36 | 37 | # This won't work because the domain is not recognized yet 38 | response = self.client.get(url, { 39 | 'q': 'fo', 40 | 'd': 'example.com', 41 | }) 42 | self.assertEqual(response.status_code, 400) 43 | 44 | domain = Domain.objects.create(name='example.com') 45 | key = Key.objects.create( 46 | domain=domain, 47 | key='mykey', 48 | user=User.objects.create(username='dude'), 49 | ) 50 | response = self.client.get(url, { 51 | 'q': 'fo', 52 | 'd': 'example.com', 53 | }) 54 | self.assertEqual(response.status_code, 200) 55 | self.assertTrue(not response.json()['results']) 56 | 57 | # Index some. 58 | 59 | # First without a key 60 | response = self.client.post(url, { 61 | 'title': 'Foo Bar One', 62 | 'url': 'https://example.com/one', 63 | }) 64 | self.assertEqual(response.status_code, 400) 65 | 66 | # Now with an invalid key 67 | response = self.client.post(url, { 68 | 'title': 'Foo Bar One', 69 | 'url': 'https://example.com/one', 70 | }, HTTP_AUTH_KEY="junk") 71 | self.assertEqual(response.status_code, 403) 72 | 73 | # This time with a valid key! 74 | response = self.client.post(url, { 75 | 'title': 'Foo Bar One', 76 | 'url': 'https://example.com/one', 77 | }, HTTP_AUTH_KEY=key.key) 78 | self.assertEqual(response.status_code, 201) 79 | 80 | response = self.client.post(url, { 81 | 'title': 'Foo Bar Two', 82 | 'url': 'https://example.com/two', 83 | 'popularity': 2 84 | }, HTTP_AUTH_KEY=key.key) 85 | self.assertEqual(response.status_code, 201) 86 | 87 | response = self.client.post(url, { 88 | 'title': 'Foo Bar Private', 89 | 'url': 'https://example.com/private', 90 | 'group': 'private', 91 | }, HTTP_AUTH_KEY=key.key) 92 | self.assertEqual(response.status_code, 201) 93 | 94 | self._refresh() 95 | 96 | # Now do a search 97 | response = self.client.get(url, { 98 | 'q': 'foo', 99 | 'd': 'example.com', 100 | }) 101 | self.assertEqual(response.status_code, 200) 102 | results = response.json()['results'] 103 | assert results 104 | self.assertEqual( 105 | results, 106 | [ 107 | # higher popularity 108 | ['https://example.com/two', 'Foo Bar Two'], 109 | # no specific group 110 | ['https://example.com/one', 'Foo Bar One'], 111 | ] 112 | ) 113 | 114 | # With groups 115 | response = self.client.get(url, { 116 | 'q': 'priv', 117 | 'd': 'example.com', 118 | 'g': 'private' 119 | }) 120 | self.assertEqual(response.status_code, 200) 121 | results = response.json()['results'] 122 | assert results 123 | self.assertEqual( 124 | results, 125 | [ 126 | ['https://example.com/private', 'Foo Bar Private'] 127 | ] 128 | ) 129 | 130 | # Overwrite one 131 | response = self.client.post(url, { 132 | 'title': 'Foo Bar One Hundred and One', 133 | 'url': 'https://example.com/one', 134 | 'popularity': 100, 135 | }, HTTP_AUTH_KEY=key.key) 136 | self.assertEqual(response.status_code, 201) 137 | self._refresh() 138 | response = self.client.get(url, { 139 | 'q': 'foo', 140 | 'd': 'example.com', 141 | }) 142 | self.assertEqual(response.status_code, 200) 143 | results = response.json()['results'] 144 | assert results 145 | self.assertEqual( 146 | results, 147 | [ 148 | # no specific group 149 | ['https://example.com/one', 'Foo Bar One Hundred and One'], 150 | # now lower popularity 151 | ['https://example.com/two', 'Foo Bar Two'], 152 | ] 153 | ) 154 | 155 | # Delete one of them 156 | response = self.client.delete( 157 | url + '?url=https://example.com/private', 158 | HTTP_AUTH_KEY=key.key 159 | ) 160 | self.assertEqual(response.status_code, 200) 161 | self._refresh() 162 | 163 | # Get some stats on these searches 164 | url = reverse('api:stats') 165 | response = self.client.get(url) 166 | self.assertEqual(response.status_code, 400) 167 | response = self.client.get(url, HTTP_AUTH_KEY=key.key) 168 | self.assertEqual(response.status_code, 200) 169 | results = response.json() 170 | self.assertEqual(results['documents'], 3 - 1) 171 | now = timezone.now() 172 | fetches = { 173 | str(now.year): { 174 | str(now.month): 4 # because we've done 4 searches 175 | } 176 | } 177 | self.assertEqual(Search.objects.filter(domain=domain).count(), 4) 178 | self.assertEqual(fetches, results['fetches']) 179 | 180 | # Flush out all 181 | url = reverse('api:flush') 182 | response = self.client.post(url, HTTP_AUTH_KEY=key.key) 183 | self.assertEqual(response.status_code, 200) 184 | self._refresh() 185 | # Get the stats again 186 | url = reverse('api:stats') 187 | response = self.client.get(url, HTTP_AUTH_KEY=key.key) 188 | self.assertEqual(response.status_code, 200) 189 | results = response.json() 190 | self.assertEqual(results['documents'], 0) 191 | 192 | def test_bulk_load(self): 193 | key = Key.objects.create( 194 | domain=Domain.objects.create(name='example.com'), 195 | key='mykey', 196 | user=User.objects.create(username='dude'), 197 | ) 198 | documents = [ 199 | { 200 | 'url': 'http://example.com/one', 201 | 'title': 'Zebra One', 202 | }, 203 | { 204 | 'url': 'http://example.com/two', 205 | 'title': 'Zebra Two', 206 | 'popularity': 100, 207 | }, 208 | { 209 | 'url': 'http://example.com/private', 210 | 'title': 'Zebra Private', 211 | 'group': 'private' 212 | }, 213 | { 214 | 'title': 'No URL!!', 215 | }, 216 | { 217 | 'url': 'No title!!', 218 | }, 219 | ] 220 | url = reverse('api:bulk') 221 | response = self.post_json(url, {'documents': documents}) 222 | self.assertEqual(response.status_code, 400) 223 | 224 | response = self.post_json( 225 | url, 226 | {'documents': documents}, 227 | HTTP_AUTH_KEY='junk' 228 | ) 229 | self.assertEqual(response.status_code, 403) 230 | response = self.post_json( 231 | url, 232 | {'no documents': 'key'}, 233 | HTTP_AUTH_KEY=key.key 234 | ) 235 | self.assertEqual(response.status_code, 400) 236 | response = self.post_json( 237 | url, 238 | {'documents': documents}, 239 | HTTP_AUTH_KEY=key.key 240 | ) 241 | self.assertEqual(response.status_code, 201) 242 | result = response.json() 243 | # the one without url and the one without title skipped 244 | self.assertEqual(result['count'], 3) 245 | 246 | self._refresh() 247 | 248 | url = reverse('api:home') 249 | response = self.client.get(url, { 250 | 'q': 'zebrra', 251 | 'd': 'example.com', 252 | }) 253 | self.assertEqual(response.status_code, 200) 254 | _json = response.json() 255 | terms = _json['terms'] 256 | self.assertTrue('zebrra' in terms) 257 | self.assertTrue('zebra' in terms) 258 | results = _json['results'] 259 | assert results, _json 260 | self.assertEqual( 261 | results, 262 | [ 263 | ['http://example.com/two', 'Zebra Two'], 264 | ['http://example.com/one', 'Zebra One'], 265 | ] 266 | ) 267 | -------------------------------------------------------------------------------- /autocompeter/api/views.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import json 3 | import functools 4 | import time 5 | 6 | from elasticsearch_dsl.connections import connections 7 | from elasticsearch.helpers import streaming_bulk 8 | from elasticsearch_dsl.query import Q 9 | from elasticsearch.exceptions import ConnectionTimeout 10 | 11 | from django import http 12 | from django.conf import settings 13 | from django.db.models import Count 14 | from django.views.decorators.csrf import csrf_exempt 15 | from django.db.models.functions import TruncMonth 16 | 17 | from autocompeter.main.models import Key, Domain, Search 18 | from autocompeter.main.search import TitleDoc 19 | 20 | 21 | def auth_key(*methods): 22 | # if isinstance(methods, str): 23 | # methods = [methods] 24 | 25 | def wrapper(func): 26 | 27 | @functools.wraps(func) 28 | def inner(request, *args): 29 | if request.method not in methods: 30 | return func(request, None, *args) 31 | try: 32 | auth_key = request.META['HTTP_AUTH_KEY'] 33 | assert auth_key 34 | except (AttributeError, AssertionError, KeyError): 35 | # XXX check what autocompeter Go does 36 | return http.JsonResponse({ 37 | 'error': "Missing header 'Auth-Key'", 38 | }, status=400) 39 | try: 40 | key = Key.objects.get(key=auth_key) 41 | except Key.DoesNotExist: 42 | # XXX check what autocompeter Go does 43 | return http.JsonResponse({ 44 | 'error': "Auth-Key not recognized", 45 | }, status=403) 46 | domain = Domain.objects.get(key=key) 47 | return func(request, domain, *args) 48 | 49 | return inner 50 | 51 | return wrapper 52 | 53 | 54 | def es_retry(callable, *args, **kwargs): 55 | sleep_time = kwargs.pop('_sleep_time', 1) 56 | attempts = kwargs.pop('_attempts', 10) 57 | verbose = kwargs.pop('_verbose', False) 58 | try: 59 | return callable(*args, **kwargs) 60 | except (ConnectionTimeout,) as exception: 61 | if attempts: 62 | attempts -= 1 63 | if verbose: 64 | print("ES Retrying ({} {}) {}".format( 65 | attempts, 66 | sleep_time, 67 | exception 68 | )) 69 | time.sleep(sleep_time) 70 | else: 71 | raise 72 | 73 | 74 | def make_id(*bits): 75 | return hashlib.md5(''.join(bits).encode('utf-8')).hexdigest() 76 | 77 | 78 | @auth_key('POST', 'DELETE') 79 | @csrf_exempt 80 | def home(request, domain): 81 | if request.method == 'POST': 82 | url = request.POST.get('url', '').strip() 83 | if not url: 84 | return http.JsonResponse({'error': "Missing 'url'"}, status=400) 85 | title = request.POST.get('title', '').strip() 86 | if not title: 87 | return http.JsonResponse({'error': "Missing 'title'"}, status=400) 88 | group = request.POST.get('group', '').strip() 89 | popularity = float(request.POST.get('popularity', 0.0)) 90 | 91 | doc = { 92 | 'domain': domain.name, 93 | 'url': url, 94 | 'title': title, 95 | 'group': group, 96 | 'popularity': popularity, 97 | } 98 | t0 = time.time() 99 | # print("INSERTING", doc) 100 | es_retry(TitleDoc(meta={'id': make_id(domain.name, url)}, **doc).save) 101 | t1 = time.time() 102 | return http.JsonResponse({ 103 | 'message': 'OK', 104 | 'took': t1 - t0, 105 | }, status=201) 106 | elif request.method == 'DELETE': 107 | url = request.GET.get('url', '').strip() 108 | if not url: 109 | return http.JsonResponse({'error': "Missing 'url'"}, status=400) 110 | t0 = time.time() 111 | doc = TitleDoc.get(id=make_id(domain.name, url)) 112 | doc.delete() 113 | t1 = time.time() 114 | return http.JsonResponse({ 115 | 'message': 'OK', 116 | 'took': t1 - t0, 117 | }) 118 | else: 119 | q = request.GET.get('q', '') 120 | if not q: 121 | return http.JsonResponse({'error': "Missing 'q'"}, status=400) 122 | d = request.GET.get('d', '').strip() 123 | if not d: 124 | return http.JsonResponse({'error': "Missing 'd'"}, status=400) 125 | try: 126 | domain = Domain.objects.get(name=d) 127 | except Domain.DoesNotExist: 128 | return http.JsonResponse({'error': "Unrecognized 'd'"}, status=400) 129 | groups = request.GET.get('g', '').strip() 130 | groups = [x.strip() for x in groups.split(',') if x.strip()] 131 | 132 | size = int(request.GET.get('n', 10)) 133 | 134 | terms = [q] 135 | 136 | search = TitleDoc.search() 137 | 138 | # Only bother if the search term is long enough 139 | if len(q) > 2: 140 | suggestion = search.suggest('suggestions', q, term={ 141 | 'field': 'title', 142 | }) 143 | suggestions = suggestion.execute_suggest() 144 | for suggestion in getattr(suggestions, 'suggestions', []): 145 | for option in suggestion.options: 146 | terms.append( 147 | q.replace(suggestion.text, option.text) 148 | ) 149 | results = [] 150 | 151 | search = search.filter('term', domain=domain.name) 152 | query = Q('match_phrase', title=terms[0]) 153 | for term in terms[1:]: 154 | query |= Q('match_phrase', title=term) 155 | 156 | if groups: 157 | # first, always include the empty group 158 | query &= Q('terms', group=[''] + groups) 159 | else: 160 | query &= Q('term', group='') 161 | 162 | search = search.query(query) 163 | search = search.sort('-popularity', '_score') 164 | search = search[:size] 165 | response = search.execute() 166 | for hit in response.hits: 167 | results.append([ 168 | hit.url, 169 | hit.title, 170 | 171 | ]) 172 | Search.objects.create( 173 | domain=domain, 174 | term=q, 175 | results=len(results), 176 | ) 177 | response = http.JsonResponse({ 178 | 'results': results, 179 | 'terms': terms, 180 | }) 181 | response['Access-Control-Allow-Origin'] = '*' 182 | return response 183 | 184 | 185 | @auth_key('POST') 186 | @csrf_exempt 187 | def bulk(request, domain): 188 | assert domain 189 | 190 | try: 191 | documents = json.loads(request.body.decode('utf-8'))['documents'] 192 | except KeyError: 193 | return http.JsonResponse({'error': "Missing 'documents'"}, status=400) 194 | 195 | def iterator(): 196 | for document in documents: 197 | url = document.get('url', '').strip() 198 | if not url: 199 | continue 200 | title = document.get('title', '').strip() 201 | if not title: 202 | continue 203 | yield TitleDoc( 204 | meta={'id': make_id(domain.name, url)}, 205 | **{ 206 | 'domain': domain.name, 207 | 'url': url, 208 | 'title': title, 209 | 'group': document.get('group', '').strip(), 210 | 'popularity': float(document.get('popularity', 0.0)), 211 | } 212 | ).to_dict(True) 213 | 214 | count = failures = 0 215 | 216 | t0 = time.time() 217 | for success, doc in streaming_bulk( 218 | connections.get_connection(), 219 | iterator(), 220 | index=settings.ES_INDEX, 221 | doc_type='title_doc', 222 | ): 223 | if not success: 224 | print("NOT SUCCESS!", doc) 225 | failures += 1 226 | count += 1 227 | t1 = time.time() 228 | 229 | return http.JsonResponse({ 230 | 'message': 'OK', 231 | 'count': count, 232 | 'failures': failures, 233 | 'took': t1 - t0, 234 | }, status=201) 235 | 236 | 237 | def ping(request): 238 | response = http.HttpResponse('pong') 239 | response['Access-Control-Allow-Origin'] = '*' 240 | return response 241 | 242 | 243 | @auth_key('GET') 244 | def stats(request, domain): 245 | assert domain 246 | fetches, documents = stats_by_domain(domain) 247 | return http.JsonResponse({ 248 | 'fetches': fetches, 249 | 'documents': documents, 250 | }) 251 | 252 | 253 | def stats_by_domain(domain): 254 | searches = ( 255 | Search.objects 256 | .annotate(month=TruncMonth('created')) 257 | .values('month') 258 | .annotate(count=Count('id')) 259 | .values('month', 'count') 260 | ) 261 | fetches = {} 262 | for s in searches: 263 | year = str(s['month'].year) 264 | month = str(s['month'].month) 265 | if year not in fetches: 266 | fetches[year] = {} 267 | fetches[year][month] = s['count'] 268 | 269 | search = TitleDoc.search() 270 | search = search.filter('term', domain=domain.name) 271 | documents = search.execute().hits.total 272 | 273 | return fetches, documents 274 | 275 | 276 | @auth_key('DELETE', 'POST') 277 | @csrf_exempt 278 | def flush(request, domain): 279 | # Should use the delete-by-query plugin 280 | # http://blog.appliedinformaticsinc.com/how-to-delete-elasticsearch-data-records-by-dsl-query/ # NOQA 281 | # Or the new API 282 | # https://www.elastic.co/guide/en/elasticsearch/reference/5.1/docs-delete-by-query.html # NOQA 283 | # Perhaps we can use 284 | # connections.get_connection().delete_by_query ?!?! 285 | assert domain 286 | t0 = time.time() 287 | search = TitleDoc.search() 288 | search = search.filter('term', domain=domain.name) 289 | ids = set() 290 | for hit in search.scan(): 291 | ids.add(hit._id) 292 | for _id in ids: 293 | TitleDoc.get(id=_id).delete() 294 | t1 = time.time() 295 | return http.JsonResponse({ 296 | 'messsage': 'OK', 297 | 'took': t1 - t0, 298 | }) 299 | -------------------------------------------------------------------------------- /src/autocompeter.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * 3 | * Copyright (c) 2015-2017, Peter Bengtsson 4 | * 5 | * Permission is hereby granted, free of charge, to any person obtaining a copy 6 | * of this software and associated documentation files (the "Software"), to deal 7 | * in the Software without restriction, including without limitation the rights 8 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | * copies of the Software, and to permit persons to whom the Software is 10 | * furnished to do so, subject to the following conditions: 11 | * 12 | * The above copyright notice and this permission notice shall be included in 13 | * all copies or substantial portions of the Software. 14 | * 15 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | * THE SOFTWARE. 22 | * 23 | */ 24 | 25 | /* 26 | * How to use it 27 | * ------------- 28 | * 29 | * Load this file and then call it like this: 30 | * 31 | * Autocomplete(document.getElementById('searchthing')); 32 | * 33 | * More to come... 34 | */ 35 | 36 | ;(function(window, document) { 37 | 'use strict'; 38 | 39 | /* utility function to create DOM elements */ 40 | function createDomElement(tag, options) { 41 | var e = document.createElement(tag); 42 | for (var key in options) { 43 | e[key] = options[key]; 44 | } 45 | return e; 46 | } 47 | 48 | /* utility function to attach event handlers to elements */ 49 | function attachHandler(target, type, handler) { 50 | if (target.addEventListener) { 51 | target.addEventListener(type, handler, false); 52 | } else { 53 | target.attachEvent(type, handler); 54 | } 55 | } 56 | 57 | function extend() { 58 | var key, object, objects, target, val, i, len, slice = [].slice; 59 | target = arguments[0]; 60 | objects = 2 <= arguments.length ? slice.call(arguments, 1) : []; 61 | for (i = 0, len = objects.length; i < len; i++) { 62 | object = objects[i]; 63 | for (key in object) { 64 | val = object[key]; 65 | target[key] = val; 66 | } 67 | } 68 | return target; 69 | } 70 | 71 | function setUp(q, options) { 72 | options = extend({ 73 | url: 'https://autocompeter.com/v1', 74 | domain: location.host, 75 | groups: null, 76 | ping: true, 77 | }, options || {}); 78 | 79 | options.url += options.url.indexOf('?') > -1 ? '&' : '?'; 80 | if (options.number) { 81 | options.url += 'n=' + options.number + '&'; 82 | } 83 | if (options.groups) { 84 | if (Array.isArray(options.groups)) { 85 | options.groups = options.groups.join(','); 86 | } 87 | options.url += 'g=' + encodeURIComponent(options.groups) + '&'; 88 | } 89 | options.url += 'd=' + options.domain + '&q='; 90 | 91 | var results_ps = []; 92 | var selected_pointer = -1; 93 | var actually_selected_pointer = false; 94 | q.spellcheck = false; 95 | q.autocomplete = 'off'; 96 | 97 | // wrap the input 98 | var wrapper = createDomElement('span', {className: '_ac-wrap'}); 99 | var hint = createDomElement('input', { 100 | tabindex: -1, 101 | spellcheck: false, 102 | autocomplete: 'off', 103 | readonly: 'readonly', 104 | type: q.type || 'text', 105 | className: q.className + ' _ac-hint' 106 | }); 107 | 108 | // The hint is a clone of the original but the original has some 109 | // requirements of its own. 110 | q.classList.add('_ac-foreground'); 111 | wrapper.appendChild(hint); 112 | var clone = q.cloneNode(true); 113 | wrapper.appendChild(clone); 114 | 115 | var r = createDomElement('div', {className: '_ac-results'}); 116 | attachHandler(r, 'mouseover', mouseoverResults); 117 | wrapper.appendChild(r); 118 | 119 | q.parentElement.insertBefore(wrapper, q); 120 | q.parentNode.removeChild(q); 121 | q = clone; 122 | 123 | function escapeRegExp(str) { 124 | return str.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&"); 125 | } 126 | function highlightText(text) { 127 | var search_terms = terms.map(escapeRegExp); 128 | var re = new RegExp('\\b((' + search_terms.join('|') + ')[\\S\']*)', 'gi'); 129 | return text.replace(re, '$1'); 130 | } 131 | 132 | function mouseoverResults(e) { 133 | if (e.target.tagName === 'P') { 134 | if (selected_pointer !== +e.target.dataset.i) { 135 | selected_pointer = +e.target.dataset.i; 136 | displayResults(); 137 | } 138 | } 139 | } 140 | 141 | function filterResults(results, terms) { 142 | // Return a new array where all results are matched 143 | // on all of the terms. 144 | var new_results = []; 145 | var search_terms = terms.map(escapeRegExp); 146 | var re = new RegExp('\\b(' + search_terms.join('|') + ')', 'gi'); 147 | for (var i=0, len=results.length; i < len; i++) { 148 | if (results[i][1].search(re) > -1) { 149 | new_results.push(results[i]); 150 | } 151 | } 152 | return new_results; 153 | } 154 | 155 | var results = null; 156 | var preAjaxDisplayTimer = null; 157 | var terms; 158 | 159 | function displayResults(preAjax) { 160 | preAjax = preAjax || false; 161 | if (!preAjax && preAjaxDisplayTimer) { 162 | // This function has been called because we have new results. 163 | // If we had started a whilst-waiting-for-ajax filtering based 164 | // on what little we have, then we can now kill that effort. 165 | window.clearTimeout(preAjaxDisplayTimer); 166 | } 167 | var i, len; 168 | if (results === null) return; 169 | if (preAjax) { 170 | // filter the results with the new terms manually 171 | results = filterResults(results, terms); 172 | } 173 | if (results.length) { 174 | r.style.display = 'block'; 175 | var ps = r.getElementsByTagName('p'); 176 | for (i=ps.length - 1; i >= 0; i--) { 177 | ps[i].remove(); 178 | } 179 | } else { 180 | r.style.display = 'none'; 181 | return; 182 | } 183 | results_ps = []; 184 | var p, a; 185 | var hint_candidate = null; 186 | var hint_candidates = []; 187 | if (!results.length) { 188 | // If there are no results for the current query, we make 189 | // sure the is no hint underneath that you can tab-complete 190 | // because since there are no results, there's no point 191 | // tab completing. 192 | hint.value = ''; 193 | } 194 | 195 | var search_terms = terms.map(escapeRegExp); 196 | var re = new RegExp('\\b(' + search_terms.join('|') + ')(\\w+)\\b', 'gi'); 197 | 198 | // Because `r` is a DOM element that has already been inserted into 199 | // the DOM we collect all the `

` tags into a Document Fragment 200 | // and add the whole thing later into the `r` element. 201 | var p_fragments = document.createDocumentFragment(); 202 | for (i=0, len=results.length; i < len; i++) { 203 | var found; 204 | var matched; 205 | 206 | while ((found = re.exec(results[i][1])) !== null) { 207 | matched = new RegExp('\\b' + escapeRegExp(found[0]) + '\\b', 'gi'); 208 | 209 | hint_candidate = found[found.length - 1]; 210 | if (hint_candidate !== undefined && !matched.test(q.value)) { 211 | if (selected_pointer === i || (selected_pointer === -1 && i === 0)) { 212 | hint_candidates.push(hint_candidate); 213 | } 214 | } 215 | } 216 | 217 | p = createDomElement('p'); 218 | if (i === selected_pointer && actually_selected_pointer) { 219 | p.classList.add('selected'); 220 | } 221 | p.dataset.i = i; // needed by the onmouseover event handler 222 | 223 | a = createDomElement('a', { 224 | innerHTML: highlightText(results[i][1]), 225 | href: results[i][0] 226 | }); 227 | p.appendChild(a); 228 | p_fragments.appendChild(p); 229 | results_ps.push(p); 230 | } 231 | r.appendChild(p_fragments); 232 | if (hint_candidates.length && q.value.charAt(q.value.length - 1) !== ' ') { 233 | hint_candidate = hint_candidates[Math.max(0, selected_pointer) % hint_candidates.length]; 234 | hint.value = q.value + hint_candidate; 235 | } else { 236 | // If there are no candidates there's no point putting the 237 | // hint value to be anything. 238 | // Also, this solves the problem that if the q.value is so long 239 | // that it's typing exceeds the input box, the text alignment 240 | // (assuming the caret is on the right) becomes right aligned. 241 | hint.value = ''; 242 | } 243 | } 244 | 245 | function findParentForm(element) { 246 | var parent = element.parentNode; 247 | if (parent === null) { 248 | console.warn("Form can not be found. Nothing to submit"); 249 | return; 250 | } 251 | if (parent.nodeName === 'FORM') { 252 | return parent; 253 | } 254 | return findParentForm(parent); 255 | } 256 | 257 | function handleKeyboardEvent(name) { 258 | var i, len; 259 | if (name === 'tab') { 260 | if (hint.value !== q.value) { 261 | q.value = hint.value + ' '; 262 | } 263 | if (q.value !== hint.value) { 264 | handler(); // this starts a new ajax request 265 | } 266 | actually_selected_pointer = true; 267 | } else if (name === 'down' || name === 'up') { 268 | if (name === 'down') { 269 | selected_pointer = Math.min(results_ps.length - 1, ++selected_pointer); 270 | } else if (name === 'up') { 271 | selected_pointer = Math.max(0, --selected_pointer); 272 | } 273 | for (i=0, len=results_ps.length; i < len; i++) { 274 | if (i === selected_pointer && actually_selected_pointer) { 275 | results_ps[i].classList.add('selected'); 276 | } else { 277 | results_ps[i].classList.remove('selected'); 278 | } 279 | } 280 | actually_selected_pointer = true; 281 | displayResults(); 282 | } else if (name === 'enter') { 283 | if (results_ps.length && actually_selected_pointer) { 284 | var p = results_ps[Math.max(0, selected_pointer)]; 285 | var a = p.getElementsByTagName('a')[0]; 286 | q.value = hint.value = a.textContent; 287 | r.style.display = 'none'; 288 | window.location = a.href; 289 | } else { 290 | // We need to submit the form but we can't simply `return true` 291 | // because the event we're returning to isn't a form submission. 292 | var form = findParentForm(q); 293 | if (form) { 294 | form.submit(); 295 | } 296 | return true; 297 | } 298 | } else if (name === 'esc') { 299 | r.style.display = 'none'; 300 | } 301 | return false; 302 | } 303 | 304 | function handleKeyEvent(e) { 305 | var relevant_keycodes = { 306 | 13: 'enter', 307 | 9: 'tab', 308 | 38: 'up', 309 | 40: 'down', 310 | 27: 'esc' 311 | }; 312 | if (!relevant_keycodes[e.keyCode]) return false; 313 | e.preventDefault(); 314 | return handleKeyboardEvent(relevant_keycodes[e.keyCode]); 315 | } 316 | 317 | function handleAjaxError() { 318 | r.style.display = 'none'; 319 | } 320 | 321 | var cache = {}; 322 | var req; 323 | var req_value; 324 | 325 | function handler() { 326 | if (!q.value.trim()) { 327 | hint.value = ''; 328 | r.style.display = 'none'; 329 | return; 330 | } else if (hint.value.length) { 331 | // perhaps the hint.value no longer is applicable, in that case 332 | // unset the hint.value 333 | if (hint.value.indexOf(q.value.trim()) === -1) { 334 | hint.value = q.value; 335 | } 336 | if (r.style.display === 'block') { 337 | // display the results again because the displays are shown 338 | // but the typed value is not visible 339 | terms = q.value.trim().split(/\s+/); 340 | preAjaxDisplayTimer = window.setTimeout(function() { 341 | // now display results PRE-ajax 342 | displayResults(true); 343 | }, 150); 344 | 345 | } 346 | } 347 | // New character, let's reset the selected_pointer 348 | selected_pointer = -1; 349 | // Also, reset that none of the results have been explicitly 350 | // selected yet. 351 | actually_selected_pointer = false; 352 | if (cache[q.value.trim()]) { 353 | var response = cache[q.value.trim()]; 354 | terms = response.terms; 355 | results = response.results; 356 | displayResults(); 357 | } else { 358 | if (req) { 359 | req.abort(); 360 | } 361 | if (window.XMLHttpRequest) { // Mozilla, Safari, ... 362 | req = new window.XMLHttpRequest(); 363 | } else if (window.ActiveXObject) { // IE 8 and older 364 | req = new window.ActiveXObject("Microsoft.XMLHTTP"); 365 | } else { 366 | return; 367 | } 368 | 369 | if (q.value.trim().length) { 370 | // Suppose you have typed in "Pytho", then your 371 | // previously typed in search is likely to be "Pyth". 372 | // I.e. the same minus the last character. 373 | // If that search is in the cache and had 0 results, then 374 | // there's no point also searching for "Pytho" 375 | var previous_value = q.value.trim().substring( 376 | 0, q.value.trim().length - 1 377 | ); 378 | if (cache[previous_value] && !cache[previous_value].results.length) { 379 | // don't bother sending this search then 380 | cache[q.value.trim()] = {results: []}; 381 | return; 382 | } 383 | } 384 | 385 | req.onreadystatechange = function() { 386 | if (req.readyState === 4) { 387 | if (req.status === 200) { 388 | if (req_value === q.value) { 389 | var response = JSON.parse(req.responseText); 390 | cache[q.value.trim()] = response; 391 | terms = response.terms; 392 | results = response.results; 393 | displayResults(); 394 | } 395 | } else { 396 | // if the req.status is 0, it's because the request was aborted 397 | if (req.status !== 0) { 398 | console.warn(req.status, req.responseText); 399 | handleAjaxError(); 400 | } 401 | 402 | } 403 | } 404 | }; 405 | req.open('GET', options.url + encodeURIComponent(q.value.trim()), true); 406 | req_value = q.value; 407 | req.send(); 408 | } 409 | } 410 | 411 | function handleBlur(event) { 412 | hint.value = q.value; 413 | // Necessary so it becomes possible to click the links before they 414 | // disappear too quickly. 415 | setTimeout(function() { 416 | r.style.display = 'none'; 417 | }, 200); 418 | event.preventDefault(); 419 | } 420 | 421 | // Used to know the search widget has been focused once and only once. 422 | var firstFocus = true; 423 | 424 | function handleFocus() { 425 | if (firstFocus) { 426 | // The input field is focussed for the first time in this 427 | // session. That gives us an optimization opportunity to 428 | // warm up with a quick and simply little ping. 429 | if (options.ping && window.XMLHttpRequest) { 430 | // For the really web performance conscientious implementors, 431 | // you can make it send a ping by AJAX first. This will pre-emptively 432 | // do a DNS look up and cert checking so that that's taken care of 433 | // when you later do the actual AJAX. 434 | var ping = new window.XMLHttpRequest(); 435 | ping.open('GET', options.url.split('?')[0] + '/ping'); 436 | ping.send(); 437 | firstFocus = false; 438 | } 439 | } 440 | if (q.value.length && results_ps.length) { 441 | r.style.display = 'block'; 442 | } 443 | } 444 | 445 | attachHandler(q, 'input', handler); 446 | attachHandler(q, 'keydown', handleKeyEvent); 447 | attachHandler(q, 'blur', handleBlur); 448 | attachHandler(q, 'focus', handleFocus); 449 | 450 | } // end of setUp 451 | 452 | window.Autocompeter = setUp; 453 | 454 | })(window, document); 455 | -------------------------------------------------------------------------------- /public/dist/autocompeter.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * 3 | * Copyright (c) 2015-2017, Peter Bengtsson 4 | * 5 | * Permission is hereby granted, free of charge, to any person obtaining a copy 6 | * of this software and associated documentation files (the "Software"), to deal 7 | * in the Software without restriction, including without limitation the rights 8 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | * copies of the Software, and to permit persons to whom the Software is 10 | * furnished to do so, subject to the following conditions: 11 | * 12 | * The above copyright notice and this permission notice shall be included in 13 | * all copies or substantial portions of the Software. 14 | * 15 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | * THE SOFTWARE. 22 | * 23 | */ 24 | 25 | /* 26 | * How to use it 27 | * ------------- 28 | * 29 | * Load this file and then call it like this: 30 | * 31 | * Autocomplete(document.getElementById('searchthing')); 32 | * 33 | * More to come... 34 | */ 35 | 36 | ;(function(window, document) { 37 | 'use strict'; 38 | 39 | /* utility function to create DOM elements */ 40 | function createDomElement(tag, options) { 41 | var e = document.createElement(tag); 42 | for (var key in options) { 43 | e[key] = options[key]; 44 | } 45 | return e; 46 | } 47 | 48 | /* utility function to attach event handlers to elements */ 49 | function attachHandler(target, type, handler) { 50 | if (target.addEventListener) { 51 | target.addEventListener(type, handler, false); 52 | } else { 53 | target.attachEvent(type, handler); 54 | } 55 | } 56 | 57 | function extend() { 58 | var key, object, objects, target, val, i, len, slice = [].slice; 59 | target = arguments[0]; 60 | objects = 2 <= arguments.length ? slice.call(arguments, 1) : []; 61 | for (i = 0, len = objects.length; i < len; i++) { 62 | object = objects[i]; 63 | for (key in object) { 64 | val = object[key]; 65 | target[key] = val; 66 | } 67 | } 68 | return target; 69 | } 70 | 71 | function setUp(q, options) { 72 | options = extend({ 73 | url: 'https://autocompeter.com/v1', 74 | domain: location.host, 75 | groups: null, 76 | ping: true, 77 | }, options || {}); 78 | 79 | options.url += options.url.indexOf('?') > -1 ? '&' : '?'; 80 | if (options.number) { 81 | options.url += 'n=' + options.number + '&'; 82 | } 83 | if (options.groups) { 84 | if (Array.isArray(options.groups)) { 85 | options.groups = options.groups.join(','); 86 | } 87 | options.url += 'g=' + encodeURIComponent(options.groups) + '&'; 88 | } 89 | options.url += 'd=' + options.domain + '&q='; 90 | 91 | var results_ps = []; 92 | var selected_pointer = -1; 93 | var actually_selected_pointer = false; 94 | q.spellcheck = false; 95 | q.autocomplete = 'off'; 96 | 97 | // wrap the input 98 | var wrapper = createDomElement('span', {className: '_ac-wrap'}); 99 | var hint = createDomElement('input', { 100 | tabindex: -1, 101 | spellcheck: false, 102 | autocomplete: 'off', 103 | readonly: 'readonly', 104 | type: q.type || 'text', 105 | className: q.className + ' _ac-hint' 106 | }); 107 | 108 | // The hint is a clone of the original but the original has some 109 | // requirements of its own. 110 | q.classList.add('_ac-foreground'); 111 | wrapper.appendChild(hint); 112 | var clone = q.cloneNode(true); 113 | wrapper.appendChild(clone); 114 | 115 | var r = createDomElement('div', {className: '_ac-results'}); 116 | attachHandler(r, 'mouseover', mouseoverResults); 117 | wrapper.appendChild(r); 118 | 119 | q.parentElement.insertBefore(wrapper, q); 120 | q.parentNode.removeChild(q); 121 | q = clone; 122 | 123 | function escapeRegExp(str) { 124 | return str.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&"); 125 | } 126 | function highlightText(text) { 127 | var search_terms = terms.map(escapeRegExp); 128 | var re = new RegExp('\\b((' + search_terms.join('|') + ')[\\S\']*)', 'gi'); 129 | return text.replace(re, '$1'); 130 | } 131 | 132 | function mouseoverResults(e) { 133 | if (e.target.tagName === 'P') { 134 | if (selected_pointer !== +e.target.dataset.i) { 135 | selected_pointer = +e.target.dataset.i; 136 | displayResults(); 137 | } 138 | } 139 | } 140 | 141 | function filterResults(results, terms) { 142 | // Return a new array where all results are matched 143 | // on all of the terms. 144 | var new_results = []; 145 | var search_terms = terms.map(escapeRegExp); 146 | var re = new RegExp('\\b(' + search_terms.join('|') + ')', 'gi'); 147 | for (var i=0, len=results.length; i < len; i++) { 148 | if (results[i][1].search(re) > -1) { 149 | new_results.push(results[i]); 150 | } 151 | } 152 | return new_results; 153 | } 154 | 155 | var results = null; 156 | var preAjaxDisplayTimer = null; 157 | var terms; 158 | 159 | function displayResults(preAjax) { 160 | preAjax = preAjax || false; 161 | if (!preAjax && preAjaxDisplayTimer) { 162 | // This function has been called because we have new results. 163 | // If we had started a whilst-waiting-for-ajax filtering based 164 | // on what little we have, then we can now kill that effort. 165 | window.clearTimeout(preAjaxDisplayTimer); 166 | } 167 | var i, len; 168 | if (results === null) return; 169 | if (preAjax) { 170 | // filter the results with the new terms manually 171 | results = filterResults(results, terms); 172 | } 173 | if (results.length) { 174 | r.style.display = 'block'; 175 | var ps = r.getElementsByTagName('p'); 176 | for (i=ps.length - 1; i >= 0; i--) { 177 | ps[i].remove(); 178 | } 179 | } else { 180 | r.style.display = 'none'; 181 | return; 182 | } 183 | results_ps = []; 184 | var p, a; 185 | var hint_candidate = null; 186 | var hint_candidates = []; 187 | if (!results.length) { 188 | // If there are no results for the current query, we make 189 | // sure the is no hint underneath that you can tab-complete 190 | // because since there are no results, there's no point 191 | // tab completing. 192 | hint.value = ''; 193 | } 194 | 195 | var search_terms = terms.map(escapeRegExp); 196 | var re = new RegExp('\\b(' + search_terms.join('|') + ')(\\w+)\\b', 'gi'); 197 | 198 | // Because `r` is a DOM element that has already been inserted into 199 | // the DOM we collect all the `

` tags into a Document Fragment 200 | // and add the whole thing later into the `r` element. 201 | var p_fragments = document.createDocumentFragment(); 202 | for (i=0, len=results.length; i < len; i++) { 203 | var found; 204 | var matched; 205 | 206 | while ((found = re.exec(results[i][1])) !== null) { 207 | matched = new RegExp('\\b' + escapeRegExp(found[0]) + '\\b', 'gi'); 208 | 209 | hint_candidate = found[found.length - 1]; 210 | if (hint_candidate !== undefined && !matched.test(q.value)) { 211 | if (selected_pointer === i || (selected_pointer === -1 && i === 0)) { 212 | hint_candidates.push(hint_candidate); 213 | } 214 | } 215 | } 216 | 217 | p = createDomElement('p'); 218 | if (i === selected_pointer && actually_selected_pointer) { 219 | p.classList.add('selected'); 220 | } 221 | p.dataset.i = i; // needed by the onmouseover event handler 222 | 223 | a = createDomElement('a', { 224 | innerHTML: highlightText(results[i][1]), 225 | href: results[i][0] 226 | }); 227 | p.appendChild(a); 228 | p_fragments.appendChild(p); 229 | results_ps.push(p); 230 | } 231 | r.appendChild(p_fragments); 232 | if (hint_candidates.length && q.value.charAt(q.value.length - 1) !== ' ') { 233 | hint_candidate = hint_candidates[Math.max(0, selected_pointer) % hint_candidates.length]; 234 | hint.value = q.value + hint_candidate; 235 | } else { 236 | // If there are no candidates there's no point putting the 237 | // hint value to be anything. 238 | // Also, this solves the problem that if the q.value is so long 239 | // that it's typing exceeds the input box, the text alignment 240 | // (assuming the caret is on the right) becomes right aligned. 241 | hint.value = ''; 242 | } 243 | } 244 | 245 | function findParentForm(element) { 246 | var parent = element.parentNode; 247 | if (parent === null) { 248 | console.warn("Form can not be found. Nothing to submit"); 249 | return; 250 | } 251 | if (parent.nodeName === 'FORM') { 252 | return parent; 253 | } 254 | return findParentForm(parent); 255 | } 256 | 257 | function handleKeyboardEvent(name) { 258 | var i, len; 259 | if (name === 'tab') { 260 | if (hint.value !== q.value) { 261 | q.value = hint.value + ' '; 262 | } 263 | if (q.value !== hint.value) { 264 | handler(); // this starts a new ajax request 265 | } 266 | actually_selected_pointer = true; 267 | } else if (name === 'down' || name === 'up') { 268 | if (name === 'down') { 269 | selected_pointer = Math.min(results_ps.length - 1, ++selected_pointer); 270 | } else if (name === 'up') { 271 | selected_pointer = Math.max(0, --selected_pointer); 272 | } 273 | for (i=0, len=results_ps.length; i < len; i++) { 274 | if (i === selected_pointer && actually_selected_pointer) { 275 | results_ps[i].classList.add('selected'); 276 | } else { 277 | results_ps[i].classList.remove('selected'); 278 | } 279 | } 280 | actually_selected_pointer = true; 281 | displayResults(); 282 | } else if (name === 'enter') { 283 | if (results_ps.length && actually_selected_pointer) { 284 | var p = results_ps[Math.max(0, selected_pointer)]; 285 | var a = p.getElementsByTagName('a')[0]; 286 | q.value = hint.value = a.textContent; 287 | r.style.display = 'none'; 288 | window.location = a.href; 289 | } else { 290 | // We need to submit the form but we can't simply `return true` 291 | // because the event we're returning to isn't a form submission. 292 | var form = findParentForm(q); 293 | if (form) { 294 | form.submit(); 295 | } 296 | return true; 297 | } 298 | } else if (name === 'esc') { 299 | r.style.display = 'none'; 300 | } 301 | return false; 302 | } 303 | 304 | function handleKeyEvent(e) { 305 | var relevant_keycodes = { 306 | 13: 'enter', 307 | 9: 'tab', 308 | 38: 'up', 309 | 40: 'down', 310 | 27: 'esc' 311 | }; 312 | if (!relevant_keycodes[e.keyCode]) return false; 313 | e.preventDefault(); 314 | return handleKeyboardEvent(relevant_keycodes[e.keyCode]); 315 | } 316 | 317 | function handleAjaxError() { 318 | r.style.display = 'none'; 319 | } 320 | 321 | var cache = {}; 322 | var req; 323 | var req_value; 324 | 325 | function handler() { 326 | if (!q.value.trim()) { 327 | hint.value = ''; 328 | r.style.display = 'none'; 329 | return; 330 | } else if (hint.value.length) { 331 | // perhaps the hint.value no longer is applicable, in that case 332 | // unset the hint.value 333 | if (hint.value.indexOf(q.value.trim()) === -1) { 334 | hint.value = q.value; 335 | } 336 | if (r.style.display === 'block') { 337 | // display the results again because the displays are shown 338 | // but the typed value is not visible 339 | terms = q.value.trim().split(/\s+/); 340 | preAjaxDisplayTimer = window.setTimeout(function() { 341 | // now display results PRE-ajax 342 | displayResults(true); 343 | }, 150); 344 | 345 | } 346 | } 347 | // New character, let's reset the selected_pointer 348 | selected_pointer = -1; 349 | // Also, reset that none of the results have been explicitly 350 | // selected yet. 351 | actually_selected_pointer = false; 352 | if (cache[q.value.trim()]) { 353 | var response = cache[q.value.trim()]; 354 | terms = response.terms; 355 | results = response.results; 356 | displayResults(); 357 | } else { 358 | if (req) { 359 | req.abort(); 360 | } 361 | if (window.XMLHttpRequest) { // Mozilla, Safari, ... 362 | req = new window.XMLHttpRequest(); 363 | } else if (window.ActiveXObject) { // IE 8 and older 364 | req = new window.ActiveXObject("Microsoft.XMLHTTP"); 365 | } else { 366 | return; 367 | } 368 | 369 | if (q.value.trim().length) { 370 | // Suppose you have typed in "Pytho", then your 371 | // previously typed in search is likely to be "Pyth". 372 | // I.e. the same minus the last character. 373 | // If that search is in the cache and had 0 results, then 374 | // there's no point also searching for "Pytho" 375 | var previous_value = q.value.trim().substring( 376 | 0, q.value.trim().length - 1 377 | ); 378 | if (cache[previous_value] && !cache[previous_value].results.length) { 379 | // don't bother sending this search then 380 | cache[q.value.trim()] = {results: []}; 381 | return; 382 | } 383 | } 384 | 385 | req.onreadystatechange = function() { 386 | if (req.readyState === 4) { 387 | if (req.status === 200) { 388 | if (req_value === q.value) { 389 | var response = JSON.parse(req.responseText); 390 | cache[q.value.trim()] = response; 391 | terms = response.terms; 392 | results = response.results; 393 | displayResults(); 394 | } 395 | } else { 396 | // if the req.status is 0, it's because the request was aborted 397 | if (req.status !== 0) { 398 | console.warn(req.status, req.responseText); 399 | handleAjaxError(); 400 | } 401 | 402 | } 403 | } 404 | }; 405 | req.open('GET', options.url + encodeURIComponent(q.value.trim()), true); 406 | req_value = q.value; 407 | req.send(); 408 | } 409 | } 410 | 411 | function handleBlur(event) { 412 | hint.value = q.value; 413 | // Necessary so it becomes possible to click the links before they 414 | // disappear too quickly. 415 | setTimeout(function() { 416 | r.style.display = 'none'; 417 | }, 200); 418 | event.preventDefault(); 419 | } 420 | 421 | // Used to know the search widget has been focused once and only once. 422 | var firstFocus = true; 423 | 424 | function handleFocus() { 425 | if (firstFocus) { 426 | // The input field is focussed for the first time in this 427 | // session. That gives us an optimization opportunity to 428 | // warm up with a quick and simply little ping. 429 | if (options.ping && window.XMLHttpRequest) { 430 | // For the really web performance conscientious implementors, 431 | // you can make it send a ping by AJAX first. This will pre-emptively 432 | // do a DNS look up and cert checking so that that's taken care of 433 | // when you later do the actual AJAX. 434 | var ping = new window.XMLHttpRequest(); 435 | ping.open('GET', options.url.split('?')[0] + '/ping'); 436 | ping.send(); 437 | firstFocus = false; 438 | } 439 | } 440 | if (q.value.length && results_ps.length) { 441 | r.style.display = 'block'; 442 | } 443 | } 444 | 445 | attachHandler(q, 'input', handler); 446 | attachHandler(q, 'keydown', handleKeyEvent); 447 | attachHandler(q, 'blur', handleBlur); 448 | attachHandler(q, 'focus', handleFocus); 449 | 450 | } // end of setUp 451 | 452 | window.Autocompeter = setUp; 453 | 454 | })(window, document); 455 | -------------------------------------------------------------------------------- /autocompeter/static/autocompeter.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * 3 | * Copyright (c) 2015-2017, Peter Bengtsson 4 | * 5 | * Permission is hereby granted, free of charge, to any person obtaining a copy 6 | * of this software and associated documentation files (the "Software"), to deal 7 | * in the Software without restriction, including without limitation the rights 8 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | * copies of the Software, and to permit persons to whom the Software is 10 | * furnished to do so, subject to the following conditions: 11 | * 12 | * The above copyright notice and this permission notice shall be included in 13 | * all copies or substantial portions of the Software. 14 | * 15 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | * THE SOFTWARE. 22 | * 23 | */ 24 | 25 | /* 26 | * How to use it 27 | * ------------- 28 | * 29 | * Load this file and then call it like this: 30 | * 31 | * Autocomplete(document.getElementById('searchthing')); 32 | * 33 | * More to come... 34 | */ 35 | 36 | ;(function(window, document) { 37 | 'use strict'; 38 | 39 | /* utility function to create DOM elements */ 40 | function createDomElement(tag, options) { 41 | var e = document.createElement(tag); 42 | for (var key in options) { 43 | e[key] = options[key]; 44 | } 45 | return e; 46 | } 47 | 48 | /* utility function to attach event handlers to elements */ 49 | function attachHandler(target, type, handler) { 50 | if (target.addEventListener) { 51 | target.addEventListener(type, handler, false); 52 | } else { 53 | target.attachEvent(type, handler); 54 | } 55 | } 56 | 57 | function extend() { 58 | var key, object, objects, target, val, i, len, slice = [].slice; 59 | target = arguments[0]; 60 | objects = 2 <= arguments.length ? slice.call(arguments, 1) : []; 61 | for (i = 0, len = objects.length; i < len; i++) { 62 | object = objects[i]; 63 | for (key in object) { 64 | val = object[key]; 65 | target[key] = val; 66 | } 67 | } 68 | return target; 69 | } 70 | 71 | function setUp(q, options) { 72 | options = extend({ 73 | url: 'https://autocompeter.com/v1', 74 | domain: location.host, 75 | groups: null, 76 | ping: true, 77 | }, options || {}); 78 | 79 | options.url += options.url.indexOf('?') > -1 ? '&' : '?'; 80 | if (options.number) { 81 | options.url += 'n=' + options.number + '&'; 82 | } 83 | if (options.groups) { 84 | if (Array.isArray(options.groups)) { 85 | options.groups = options.groups.join(','); 86 | } 87 | options.url += 'g=' + encodeURIComponent(options.groups) + '&'; 88 | } 89 | options.url += 'd=' + options.domain + '&q='; 90 | 91 | var results_ps = []; 92 | var selected_pointer = -1; 93 | var actually_selected_pointer = false; 94 | q.spellcheck = false; 95 | q.autocomplete = 'off'; 96 | 97 | // wrap the input 98 | var wrapper = createDomElement('span', {className: '_ac-wrap'}); 99 | var hint = createDomElement('input', { 100 | tabindex: -1, 101 | spellcheck: false, 102 | autocomplete: 'off', 103 | readonly: 'readonly', 104 | type: q.type || 'text', 105 | className: q.className + ' _ac-hint' 106 | }); 107 | 108 | // The hint is a clone of the original but the original has some 109 | // requirements of its own. 110 | q.classList.add('_ac-foreground'); 111 | wrapper.appendChild(hint); 112 | var clone = q.cloneNode(true); 113 | wrapper.appendChild(clone); 114 | 115 | var r = createDomElement('div', {className: '_ac-results'}); 116 | attachHandler(r, 'mouseover', mouseoverResults); 117 | wrapper.appendChild(r); 118 | 119 | q.parentElement.insertBefore(wrapper, q); 120 | q.parentNode.removeChild(q); 121 | q = clone; 122 | 123 | function escapeRegExp(str) { 124 | return str.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&"); 125 | } 126 | function highlightText(text) { 127 | var search_terms = terms.map(escapeRegExp); 128 | var re = new RegExp('\\b((' + search_terms.join('|') + ')[\\S\']*)', 'gi'); 129 | return text.replace(re, '$1'); 130 | } 131 | 132 | function mouseoverResults(e) { 133 | if (e.target.tagName === 'P') { 134 | if (selected_pointer !== +e.target.dataset.i) { 135 | selected_pointer = +e.target.dataset.i; 136 | displayResults(); 137 | } 138 | } 139 | } 140 | 141 | function filterResults(results, terms) { 142 | // Return a new array where all results are matched 143 | // on all of the terms. 144 | var new_results = []; 145 | var search_terms = terms.map(escapeRegExp); 146 | var re = new RegExp('\\b(' + search_terms.join('|') + ')', 'gi'); 147 | for (var i=0, len=results.length; i < len; i++) { 148 | if (results[i][1].search(re) > -1) { 149 | new_results.push(results[i]); 150 | } 151 | } 152 | return new_results; 153 | } 154 | 155 | var results = null; 156 | var preAjaxDisplayTimer = null; 157 | var terms; 158 | 159 | function displayResults(preAjax) { 160 | preAjax = preAjax || false; 161 | if (!preAjax && preAjaxDisplayTimer) { 162 | // This function has been called because we have new results. 163 | // If we had started a whilst-waiting-for-ajax filtering based 164 | // on what little we have, then we can now kill that effort. 165 | window.clearTimeout(preAjaxDisplayTimer); 166 | } 167 | var i, len; 168 | if (results === null) return; 169 | if (preAjax) { 170 | // filter the results with the new terms manually 171 | results = filterResults(results, terms); 172 | } 173 | if (results.length) { 174 | r.style.display = 'block'; 175 | var ps = r.getElementsByTagName('p'); 176 | for (i=ps.length - 1; i >= 0; i--) { 177 | ps[i].remove(); 178 | } 179 | } else { 180 | r.style.display = 'none'; 181 | return; 182 | } 183 | results_ps = []; 184 | var p, a; 185 | var hint_candidate = null; 186 | var hint_candidates = []; 187 | if (!results.length) { 188 | // If there are no results for the current query, we make 189 | // sure the is no hint underneath that you can tab-complete 190 | // because since there are no results, there's no point 191 | // tab completing. 192 | hint.value = ''; 193 | } 194 | 195 | var search_terms = terms.map(escapeRegExp); 196 | var re = new RegExp('\\b(' + search_terms.join('|') + ')(\\w+)\\b', 'gi'); 197 | 198 | // Because `r` is a DOM element that has already been inserted into 199 | // the DOM we collect all the `

` tags into a Document Fragment 200 | // and add the whole thing later into the `r` element. 201 | var p_fragments = document.createDocumentFragment(); 202 | for (i=0, len=results.length; i < len; i++) { 203 | var found; 204 | var matched; 205 | 206 | while ((found = re.exec(results[i][1])) !== null) { 207 | matched = new RegExp('\\b' + escapeRegExp(found[0]) + '\\b', 'gi'); 208 | 209 | hint_candidate = found[found.length - 1]; 210 | if (hint_candidate !== undefined && !matched.test(q.value)) { 211 | if (selected_pointer === i || (selected_pointer === -1 && i === 0)) { 212 | hint_candidates.push(hint_candidate); 213 | } 214 | } 215 | } 216 | 217 | p = createDomElement('p'); 218 | if (i === selected_pointer && actually_selected_pointer) { 219 | p.classList.add('selected'); 220 | } 221 | p.dataset.i = i; // needed by the onmouseover event handler 222 | 223 | a = createDomElement('a', { 224 | innerHTML: highlightText(results[i][1]), 225 | href: results[i][0] 226 | }); 227 | p.appendChild(a); 228 | p_fragments.appendChild(p); 229 | results_ps.push(p); 230 | } 231 | r.appendChild(p_fragments); 232 | if (hint_candidates.length && q.value.charAt(q.value.length - 1) !== ' ') { 233 | hint_candidate = hint_candidates[Math.max(0, selected_pointer) % hint_candidates.length]; 234 | hint.value = q.value + hint_candidate; 235 | } else { 236 | // If there are no candidates there's no point putting the 237 | // hint value to be anything. 238 | // Also, this solves the problem that if the q.value is so long 239 | // that it's typing exceeds the input box, the text alignment 240 | // (assuming the caret is on the right) becomes right aligned. 241 | hint.value = ''; 242 | } 243 | } 244 | 245 | function findParentForm(element) { 246 | var parent = element.parentNode; 247 | if (parent === null) { 248 | console.warn("Form can not be found. Nothing to submit"); 249 | return; 250 | } 251 | if (parent.nodeName === 'FORM') { 252 | return parent; 253 | } 254 | return findParentForm(parent); 255 | } 256 | 257 | function handleKeyboardEvent(name) { 258 | var i, len; 259 | if (name === 'tab') { 260 | if (hint.value !== q.value) { 261 | q.value = hint.value + ' '; 262 | } 263 | if (q.value !== hint.value) { 264 | handler(); // this starts a new ajax request 265 | } 266 | actually_selected_pointer = true; 267 | } else if (name === 'down' || name === 'up') { 268 | if (name === 'down') { 269 | selected_pointer = Math.min(results_ps.length - 1, ++selected_pointer); 270 | } else if (name === 'up') { 271 | selected_pointer = Math.max(0, --selected_pointer); 272 | } 273 | for (i=0, len=results_ps.length; i < len; i++) { 274 | if (i === selected_pointer && actually_selected_pointer) { 275 | results_ps[i].classList.add('selected'); 276 | } else { 277 | results_ps[i].classList.remove('selected'); 278 | } 279 | } 280 | actually_selected_pointer = true; 281 | displayResults(); 282 | } else if (name === 'enter') { 283 | if (results_ps.length && actually_selected_pointer) { 284 | var p = results_ps[Math.max(0, selected_pointer)]; 285 | var a = p.getElementsByTagName('a')[0]; 286 | q.value = hint.value = a.textContent; 287 | r.style.display = 'none'; 288 | window.location = a.href; 289 | } else { 290 | // We need to submit the form but we can't simply `return true` 291 | // because the event we're returning to isn't a form submission. 292 | var form = findParentForm(q); 293 | if (form) { 294 | form.submit(); 295 | } 296 | return true; 297 | } 298 | } else if (name === 'esc') { 299 | r.style.display = 'none'; 300 | } 301 | return false; 302 | } 303 | 304 | function handleKeyEvent(e) { 305 | var relevant_keycodes = { 306 | 13: 'enter', 307 | 9: 'tab', 308 | 38: 'up', 309 | 40: 'down', 310 | 27: 'esc' 311 | }; 312 | if (!relevant_keycodes[e.keyCode]) return false; 313 | e.preventDefault(); 314 | return handleKeyboardEvent(relevant_keycodes[e.keyCode]); 315 | } 316 | 317 | function handleAjaxError() { 318 | r.style.display = 'none'; 319 | } 320 | 321 | var cache = {}; 322 | var req; 323 | var req_value; 324 | 325 | function handler() { 326 | if (!q.value.trim()) { 327 | hint.value = ''; 328 | r.style.display = 'none'; 329 | return; 330 | } else if (hint.value.length) { 331 | // perhaps the hint.value no longer is applicable, in that case 332 | // unset the hint.value 333 | if (hint.value.indexOf(q.value.trim()) === -1) { 334 | hint.value = q.value; 335 | } 336 | if (r.style.display === 'block') { 337 | // display the results again because the displays are shown 338 | // but the typed value is not visible 339 | terms = q.value.trim().split(/\s+/); 340 | preAjaxDisplayTimer = window.setTimeout(function() { 341 | // now display results PRE-ajax 342 | displayResults(true); 343 | }, 150); 344 | 345 | } 346 | } 347 | // New character, let's reset the selected_pointer 348 | selected_pointer = -1; 349 | // Also, reset that none of the results have been explicitly 350 | // selected yet. 351 | actually_selected_pointer = false; 352 | if (cache[q.value.trim()]) { 353 | var response = cache[q.value.trim()]; 354 | terms = response.terms; 355 | results = response.results; 356 | displayResults(); 357 | } else { 358 | if (req) { 359 | req.abort(); 360 | } 361 | if (window.XMLHttpRequest) { // Mozilla, Safari, ... 362 | req = new window.XMLHttpRequest(); 363 | } else if (window.ActiveXObject) { // IE 8 and older 364 | req = new window.ActiveXObject("Microsoft.XMLHTTP"); 365 | } else { 366 | return; 367 | } 368 | 369 | if (q.value.trim().length) { 370 | // Suppose you have typed in "Pytho", then your 371 | // previously typed in search is likely to be "Pyth". 372 | // I.e. the same minus the last character. 373 | // If that search is in the cache and had 0 results, then 374 | // there's no point also searching for "Pytho" 375 | var previous_value = q.value.trim().substring( 376 | 0, q.value.trim().length - 1 377 | ); 378 | if (cache[previous_value] && !cache[previous_value].results.length) { 379 | // don't bother sending this search then 380 | cache[q.value.trim()] = {results: []}; 381 | return; 382 | } 383 | } 384 | 385 | req.onreadystatechange = function() { 386 | if (req.readyState === 4) { 387 | if (req.status === 200) { 388 | if (req_value === q.value) { 389 | var response = JSON.parse(req.responseText); 390 | cache[q.value.trim()] = response; 391 | terms = response.terms; 392 | results = response.results; 393 | displayResults(); 394 | } 395 | } else { 396 | // if the req.status is 0, it's because the request was aborted 397 | if (req.status !== 0) { 398 | console.warn(req.status, req.responseText); 399 | handleAjaxError(); 400 | } 401 | 402 | } 403 | } 404 | }; 405 | req.open('GET', options.url + encodeURIComponent(q.value.trim()), true); 406 | req_value = q.value; 407 | req.send(); 408 | } 409 | } 410 | 411 | function handleBlur(event) { 412 | hint.value = q.value; 413 | // Necessary so it becomes possible to click the links before they 414 | // disappear too quickly. 415 | setTimeout(function() { 416 | r.style.display = 'none'; 417 | }, 200); 418 | event.preventDefault(); 419 | } 420 | 421 | // Used to know the search widget has been focused once and only once. 422 | var firstFocus = true; 423 | 424 | function handleFocus() { 425 | if (firstFocus) { 426 | // The input field is focussed for the first time in this 427 | // session. That gives us an optimization opportunity to 428 | // warm up with a quick and simply little ping. 429 | if (options.ping && window.XMLHttpRequest) { 430 | // For the really web performance conscientious implementors, 431 | // you can make it send a ping by AJAX first. This will pre-emptively 432 | // do a DNS look up and cert checking so that that's taken care of 433 | // when you later do the actual AJAX. 434 | var ping = new window.XMLHttpRequest(); 435 | ping.open('GET', options.url.split('?')[0] + '/ping'); 436 | ping.send(); 437 | firstFocus = false; 438 | } 439 | } 440 | if (q.value.length && results_ps.length) { 441 | r.style.display = 'block'; 442 | } 443 | } 444 | 445 | attachHandler(q, 'input', handler); 446 | attachHandler(q, 'keydown', handleKeyEvent); 447 | attachHandler(q, 'blur', handleBlur); 448 | attachHandler(q, 'focus', handleFocus); 449 | 450 | } // end of setUp 451 | 452 | window.Autocompeter = setUp; 453 | 454 | })(window, document); 455 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License, version 2.0 2 | 3 | 1. Definitions 4 | 5 | 1.1. “Contributor” 6 | 7 | means each individual or legal entity that creates, contributes to the 8 | creation of, or owns Covered Software. 9 | 10 | 1.2. “Contributor Version” 11 | 12 | means the combination of the Contributions of others (if any) used by a 13 | Contributor and that particular Contributor’s Contribution. 14 | 15 | 1.3. “Contribution” 16 | 17 | means Covered Software of a particular Contributor. 18 | 19 | 1.4. “Covered Software” 20 | 21 | means Source Code Form to which the initial Contributor has attached the 22 | notice in Exhibit A, the Executable Form of such Source Code Form, and 23 | Modifications of such Source Code Form, in each case including portions 24 | thereof. 25 | 26 | 1.5. “Incompatible With Secondary Licenses” 27 | means 28 | 29 | a. that the initial Contributor has attached the notice described in 30 | Exhibit B to the Covered Software; or 31 | 32 | b. that the Covered Software was made available under the terms of version 33 | 1.1 or earlier of the License, but not also under the terms of a 34 | Secondary License. 35 | 36 | 1.6. “Executable Form” 37 | 38 | means any form of the work other than Source Code Form. 39 | 40 | 1.7. “Larger Work” 41 | 42 | means a work that combines Covered Software with other material, in a separate 43 | file or files, that is not Covered Software. 44 | 45 | 1.8. “License” 46 | 47 | means this document. 48 | 49 | 1.9. “Licensable” 50 | 51 | means having the right to grant, to the maximum extent possible, whether at the 52 | time of the initial grant or subsequently, any and all of the rights conveyed by 53 | this License. 54 | 55 | 1.10. “Modifications” 56 | 57 | means any of the following: 58 | 59 | a. any file in Source Code Form that results from an addition to, deletion 60 | from, or modification of the contents of Covered Software; or 61 | 62 | b. any new file in Source Code Form that contains any Covered Software. 63 | 64 | 1.11. “Patent Claims” of a Contributor 65 | 66 | means any patent claim(s), including without limitation, method, process, 67 | and apparatus claims, in any patent Licensable by such Contributor that 68 | would be infringed, but for the grant of the License, by the making, 69 | using, selling, offering for sale, having made, import, or transfer of 70 | either its Contributions or its Contributor Version. 71 | 72 | 1.12. “Secondary License” 73 | 74 | means either the GNU General Public License, Version 2.0, the GNU Lesser 75 | General Public License, Version 2.1, the GNU Affero General Public 76 | License, Version 3.0, or any later versions of those licenses. 77 | 78 | 1.13. “Source Code Form” 79 | 80 | means the form of the work preferred for making modifications. 81 | 82 | 1.14. “You” (or “Your”) 83 | 84 | means an individual or a legal entity exercising rights under this 85 | License. For legal entities, “You” includes any entity that controls, is 86 | controlled by, or is under common control with You. For purposes of this 87 | definition, “control” means (a) the power, direct or indirect, to cause 88 | the direction or management of such entity, whether by contract or 89 | otherwise, or (b) ownership of more than fifty percent (50%) of the 90 | outstanding shares or beneficial ownership of such entity. 91 | 92 | 93 | 2. License Grants and Conditions 94 | 95 | 2.1. Grants 96 | 97 | Each Contributor hereby grants You a world-wide, royalty-free, 98 | non-exclusive license: 99 | 100 | a. under intellectual property rights (other than patent or trademark) 101 | Licensable by such Contributor to use, reproduce, make available, 102 | modify, display, perform, distribute, and otherwise exploit its 103 | Contributions, either on an unmodified basis, with Modifications, or as 104 | part of a Larger Work; and 105 | 106 | b. under Patent Claims of such Contributor to make, use, sell, offer for 107 | sale, have made, import, and otherwise transfer either its Contributions 108 | or its Contributor Version. 109 | 110 | 2.2. Effective Date 111 | 112 | The licenses granted in Section 2.1 with respect to any Contribution become 113 | effective for each Contribution on the date the Contributor first distributes 114 | such Contribution. 115 | 116 | 2.3. Limitations on Grant Scope 117 | 118 | The licenses granted in this Section 2 are the only rights granted under this 119 | License. No additional rights or licenses will be implied from the distribution 120 | or licensing of Covered Software under this License. Notwithstanding Section 121 | 2.1(b) above, no patent license is granted by a Contributor: 122 | 123 | a. for any code that a Contributor has removed from Covered Software; or 124 | 125 | b. for infringements caused by: (i) Your and any other third party’s 126 | modifications of Covered Software, or (ii) the combination of its 127 | Contributions with other software (except as part of its Contributor 128 | Version); or 129 | 130 | c. under Patent Claims infringed by Covered Software in the absence of its 131 | Contributions. 132 | 133 | This License does not grant any rights in the trademarks, service marks, or 134 | logos of any Contributor (except as may be necessary to comply with the 135 | notice requirements in Section 3.4). 136 | 137 | 2.4. Subsequent Licenses 138 | 139 | No Contributor makes additional grants as a result of Your choice to 140 | distribute the Covered Software under a subsequent version of this License 141 | (see Section 10.2) or under the terms of a Secondary License (if permitted 142 | under the terms of Section 3.3). 143 | 144 | 2.5. Representation 145 | 146 | Each Contributor represents that the Contributor believes its Contributions 147 | are its original creation(s) or it has sufficient rights to grant the 148 | rights to its Contributions conveyed by this License. 149 | 150 | 2.6. Fair Use 151 | 152 | This License is not intended to limit any rights You have under applicable 153 | copyright doctrines of fair use, fair dealing, or other equivalents. 154 | 155 | 2.7. Conditions 156 | 157 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted in 158 | Section 2.1. 159 | 160 | 161 | 3. Responsibilities 162 | 163 | 3.1. Distribution of Source Form 164 | 165 | All distribution of Covered Software in Source Code Form, including any 166 | Modifications that You create or to which You contribute, must be under the 167 | terms of this License. You must inform recipients that the Source Code Form 168 | of the Covered Software is governed by the terms of this License, and how 169 | they can obtain a copy of this License. You may not attempt to alter or 170 | restrict the recipients’ rights in the Source Code Form. 171 | 172 | 3.2. Distribution of Executable Form 173 | 174 | If You distribute Covered Software in Executable Form then: 175 | 176 | a. such Covered Software must also be made available in Source Code Form, 177 | as described in Section 3.1, and You must inform recipients of the 178 | Executable Form how they can obtain a copy of such Source Code Form by 179 | reasonable means in a timely manner, at a charge no more than the cost 180 | of distribution to the recipient; and 181 | 182 | b. You may distribute such Executable Form under the terms of this License, 183 | or sublicense it under different terms, provided that the license for 184 | the Executable Form does not attempt to limit or alter the recipients’ 185 | rights in the Source Code Form under this License. 186 | 187 | 3.3. Distribution of a Larger Work 188 | 189 | You may create and distribute a Larger Work under terms of Your choice, 190 | provided that You also comply with the requirements of this License for the 191 | Covered Software. If the Larger Work is a combination of Covered Software 192 | with a work governed by one or more Secondary Licenses, and the Covered 193 | Software is not Incompatible With Secondary Licenses, this License permits 194 | You to additionally distribute such Covered Software under the terms of 195 | such Secondary License(s), so that the recipient of the Larger Work may, at 196 | their option, further distribute the Covered Software under the terms of 197 | either this License or such Secondary License(s). 198 | 199 | 3.4. Notices 200 | 201 | You may not remove or alter the substance of any license notices (including 202 | copyright notices, patent notices, disclaimers of warranty, or limitations 203 | of liability) contained within the Source Code Form of the Covered 204 | Software, except that You may alter any license notices to the extent 205 | required to remedy known factual inaccuracies. 206 | 207 | 3.5. Application of Additional Terms 208 | 209 | You may choose to offer, and to charge a fee for, warranty, support, 210 | indemnity or liability obligations to one or more recipients of Covered 211 | Software. However, You may do so only on Your own behalf, and not on behalf 212 | of any Contributor. You must make it absolutely clear that any such 213 | warranty, support, indemnity, or liability obligation is offered by You 214 | alone, and You hereby agree to indemnify every Contributor for any 215 | liability incurred by such Contributor as a result of warranty, support, 216 | indemnity or liability terms You offer. You may include additional 217 | disclaimers of warranty and limitations of liability specific to any 218 | jurisdiction. 219 | 220 | 4. Inability to Comply Due to Statute or Regulation 221 | 222 | If it is impossible for You to comply with any of the terms of this License 223 | with respect to some or all of the Covered Software due to statute, judicial 224 | order, or regulation then You must: (a) comply with the terms of this License 225 | to the maximum extent possible; and (b) describe the limitations and the code 226 | they affect. Such description must be placed in a text file included with all 227 | distributions of the Covered Software under this License. Except to the 228 | extent prohibited by statute or regulation, such description must be 229 | sufficiently detailed for a recipient of ordinary skill to be able to 230 | understand it. 231 | 232 | 5. Termination 233 | 234 | 5.1. The rights granted under this License will terminate automatically if You 235 | fail to comply with any of its terms. However, if You become compliant, 236 | then the rights granted under this License from a particular Contributor 237 | are reinstated (a) provisionally, unless and until such Contributor 238 | explicitly and finally terminates Your grants, and (b) on an ongoing basis, 239 | if such Contributor fails to notify You of the non-compliance by some 240 | reasonable means prior to 60 days after You have come back into compliance. 241 | Moreover, Your grants from a particular Contributor are reinstated on an 242 | ongoing basis if such Contributor notifies You of the non-compliance by 243 | some reasonable means, this is the first time You have received notice of 244 | non-compliance with this License from such Contributor, and You become 245 | compliant prior to 30 days after Your receipt of the notice. 246 | 247 | 5.2. If You initiate litigation against any entity by asserting a patent 248 | infringement claim (excluding declaratory judgment actions, counter-claims, 249 | and cross-claims) alleging that a Contributor Version directly or 250 | indirectly infringes any patent, then the rights granted to You by any and 251 | all Contributors for the Covered Software under Section 2.1 of this License 252 | shall terminate. 253 | 254 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all end user 255 | license agreements (excluding distributors and resellers) which have been 256 | validly granted by You or Your distributors under this License prior to 257 | termination shall survive termination. 258 | 259 | 6. Disclaimer of Warranty 260 | 261 | Covered Software is provided under this License on an “as is” basis, without 262 | warranty of any kind, either expressed, implied, or statutory, including, 263 | without limitation, warranties that the Covered Software is free of defects, 264 | merchantable, fit for a particular purpose or non-infringing. The entire 265 | risk as to the quality and performance of the Covered Software is with You. 266 | Should any Covered Software prove defective in any respect, You (not any 267 | Contributor) assume the cost of any necessary servicing, repair, or 268 | correction. This disclaimer of warranty constitutes an essential part of this 269 | License. No use of any Covered Software is authorized under this License 270 | except under this disclaimer. 271 | 272 | 7. Limitation of Liability 273 | 274 | Under no circumstances and under no legal theory, whether tort (including 275 | negligence), contract, or otherwise, shall any Contributor, or anyone who 276 | distributes Covered Software as permitted above, be liable to You for any 277 | direct, indirect, special, incidental, or consequential damages of any 278 | character including, without limitation, damages for lost profits, loss of 279 | goodwill, work stoppage, computer failure or malfunction, or any and all 280 | other commercial damages or losses, even if such party shall have been 281 | informed of the possibility of such damages. This limitation of liability 282 | shall not apply to liability for death or personal injury resulting from such 283 | party’s negligence to the extent applicable law prohibits such limitation. 284 | Some jurisdictions do not allow the exclusion or limitation of incidental or 285 | consequential damages, so this exclusion and limitation may not apply to You. 286 | 287 | 8. Litigation 288 | 289 | Any litigation relating to this License may be brought only in the courts of 290 | a jurisdiction where the defendant maintains its principal place of business 291 | and such litigation shall be governed by laws of that jurisdiction, without 292 | reference to its conflict-of-law provisions. Nothing in this Section shall 293 | prevent a party’s ability to bring cross-claims or counter-claims. 294 | 295 | 9. Miscellaneous 296 | 297 | This License represents the complete agreement concerning the subject matter 298 | hereof. If any provision of this License is held to be unenforceable, such 299 | provision shall be reformed only to the extent necessary to make it 300 | enforceable. Any law or regulation which provides that the language of a 301 | contract shall be construed against the drafter shall not be used to construe 302 | this License against a Contributor. 303 | 304 | 305 | 10. Versions of the License 306 | 307 | 10.1. New Versions 308 | 309 | Mozilla Foundation is the license steward. Except as provided in Section 310 | 10.3, no one other than the license steward has the right to modify or 311 | publish new versions of this License. Each version will be given a 312 | distinguishing version number. 313 | 314 | 10.2. Effect of New Versions 315 | 316 | You may distribute the Covered Software under the terms of the version of 317 | the License under which You originally received the Covered Software, or 318 | under the terms of any subsequent version published by the license 319 | steward. 320 | 321 | 10.3. Modified Versions 322 | 323 | If you create software not governed by this License, and you want to 324 | create a new license for such software, you may create and use a modified 325 | version of this License if you rename the license and remove any 326 | references to the name of the license steward (except to note that such 327 | modified license differs from this License). 328 | 329 | 10.4. Distributing Source Code Form that is Incompatible With Secondary Licenses 330 | If You choose to distribute Source Code Form that is Incompatible With 331 | Secondary Licenses under the terms of this version of the License, the 332 | notice described in Exhibit B of this License must be attached. 333 | 334 | Exhibit A - Source Code Form License Notice 335 | 336 | This Source Code Form is subject to the 337 | terms of the Mozilla Public License, v. 338 | 2.0. If a copy of the MPL was not 339 | distributed with this file, You can 340 | obtain one at 341 | http://mozilla.org/MPL/2.0/. 342 | 343 | If it is not possible or desirable to put the notice in a particular file, then 344 | You may include the notice in a location (such as a LICENSE file in a relevant 345 | directory) where a recipient would be likely to look for such a notice. 346 | 347 | You may add additional accurate notices of copyright ownership. 348 | 349 | Exhibit B - “Incompatible With Secondary Licenses” Notice 350 | 351 | This Source Code Form is “Incompatible 352 | With Secondary Licenses”, as defined by 353 | the Mozilla Public License, v. 2.0. 354 | 355 | -------------------------------------------------------------------------------- /autocompeter/main/templates/main/home.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {% load static %} 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | Autocompeter - A really fast AJAX autocomplete service and widget 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | {##} 23 | 24 | 25 | 26 | 30 | 31 | 32 | 33 | 34 | 35 | 44 | 45 | 46 | 47 | 48 | 49 | 50 |

91 | 92 | {% if messages %} 93 | 94 |
95 |
96 | {% for message in messages %} 97 | 103 |
104 | {% endfor %} 105 |
106 | {% endif %} 107 | 108 | 109 | 110 | 111 |
112 |
113 | 114 |
115 |
116 |
117 |

Autocompeter

118 |

A really fast AJAX autocomplete service and widget

119 |
120 |
121 | 122 |
123 | 124 | 125 | 132 | 133 |
134 |
135 |
136 | 137 |
138 | 139 | 140 |
141 | 142 | 143 | 144 | 145 | 146 |
147 | 148 |
149 |
150 |
151 | 152 |
153 |

Usage

154 |

155 | Download 156 | 157 | autocompeter.min.css 158 | and 159 | 160 | autocompeter.min.js 161 | and insert into your site. 162 |

163 |

164 | Submit your page information to 165 | like this: 166 |

167 |
curl -X POST -H "Auth-Key: yoursecurekey" \
168 |  -d url=/some/url \
169 |  -d title="Some Title" https://autocompeter.com/v1
170 |

171 | Or by bulk as one big JSON blob: 172 |

173 |
curl -X POST -H "Auth-Key: yoursecurekey" \
174 | -d '{"documents": [{"url":"/some/url", "title":"Some Title", "popularity":100}]}' \
175 | https://autocompeter.com/v1/bulk
176 |

177 | Check out 178 | the documentation on how to use the API. 179 |

180 |
181 |
182 |

183 |                   

184 |                   

185 |                   
186 | <script>
187 | Autocompeter(
188 |   document.querySelector('input[name="q"]')
189 | );
190 | </script>
191 |

192 | How the demo on this page was set up. 193 |

194 |
    195 |
  • See the HTML bits here on the right.
  • 196 |
  • The database was populate by sucking up all ~1,000+ 197 | blog post titles from www.peterbe.com. 199 |
  • A little bit of CSS overrides was added because 200 | this page's Boostrap CSS affected the CSS inside 201 | the autocomplete. 202 |
203 | 204 |
205 |
206 | 207 |
208 | 209 | 210 |
211 | 212 | 213 | 214 |
215 | 216 |
217 |
218 |
219 |
220 |

About

221 |

222 | Autocompeter is a web service where you put a piece 223 | of CSS and Javascript on your site and send your titles and their URLs 224 | to Autocompeter so you can have an auto complete search 225 | widget on your own site. 226 |

227 |

228 | Original blog post about the launch here. 229 |

230 |
231 |
232 |

Features

233 |
    234 |
  • Super fast
  • 235 |
  • Free
  • 236 |
  • No server tech for you to deploy or manage
  • 237 |
  • You determine the sort order by setting a "popularity" number on each title
  • 238 |
  • Can find "Pär is naïve" by typing in "par naive"
  • 239 |
  • You can submit use 240 | "groups" 241 | to differentiate content only certain users should see
  • 242 |
  • Bulk upload
  • 243 |
244 |
245 |
246 | 247 |
248 | 249 | 250 |
251 | 252 | 253 | 254 |
255 | 256 |
257 | 258 |
259 |
260 | 261 |
262 |

About the JavaScript and CSS

263 |
    264 |
  • Javascript code weighs only 4.2 Kb (2 Kb gzipped)
  • 265 |
  • Requires no jQuery or any other Javascript framework
  • 266 |
  • CSS code weighs only 781 b (395 b gzipped)
  • 267 |
  • The CSS code is derived from a 268 | SCSS file
  • 270 |
271 |
272 |
273 |
274 |

About the server

275 | 282 | 283 |
284 |
285 | 286 |
287 | 288 | 289 |
290 | 291 | 292 | 293 | 294 |
295 | 296 |
297 |
298 |
299 | 300 |
301 |

Documentation

302 |

303 | Detailed documentation available on 304 | 305 | https://autocompeter.readthedocs.io 306 | 307 |

308 |
309 |
310 |

Bugs?

311 |

312 | Just file an issue 313 | on GitHub. 314 |

315 |
316 |
317 | 318 |
319 | 320 | 321 |
322 | 323 | 324 | 325 | 326 |
327 | 328 |
329 |
330 |
331 | 332 |
333 |

Login/Signup

334 |

335 | {% if request.user.is_authenticated %} 336 | You are logged in as {{ request.user.username }}
337 |

338 | {% csrf_token %} 339 | 340 |
341 | {% else %} 342 | 346 | {% endif %} 347 |

348 |
349 |
350 | {% if request.user.is_authenticated %} 351 |

Your Auth-Keys

352 | 353 | 354 | 355 | 356 | 357 | 358 | 359 | 360 | 361 | {% for key in keys %} 362 | 363 | 364 | 365 | 372 | 373 | {% endfor %} 374 | 375 |
DomainKey
{{ key.domain.name }}{{ key.key }} 366 |
367 | {% csrf_token %} 368 | 369 | 370 |
371 |
376 | 377 |
378 | {% csrf_token %} 379 |
380 | 381 |
382 | 383 |
384 | 385 | {% if domains %} 386 |

387 | Your Domain Stats 388 |

389 | 390 | 391 | 392 | 393 | 394 | 395 | 396 | 397 | {% for domain in domains %} 398 | 399 | 404 | 440 | 441 | {% endfor %} 442 | 443 |
DomainSearches
400 | {{ domain.name }} 401 |
402 | {{ domain.no_documents }} indexed documents 403 |
405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 418 | 421 | 422 | 423 | 424 | {% for f in domain.fetch_months %} 425 | 426 | 429 | 432 | 435 | 436 | {% endfor %} 437 | 438 |
YearMonthSearches
416 | Total 417 | 419 | {{ domain.fetch_total }} 420 |
427 | {{ f.year }} 428 | 430 | {{ f.month }} 431 | 433 | {{ f.fetches }} 434 |
439 |
444 | {% endif %} 445 | 446 | {% else %} 447 |

Examples of Use

448 |

449 | Here are some different example implementations: 450 |

451 | 483 | {% endif %} 484 |
485 |
486 | 487 |
488 | 489 | 490 |
491 | 492 | 493 | 494 | 532 | 533 | 534 | 535 | 536 | 537 | 548 | {% if not DEBUG %} 549 | 566 | {% endif %} 567 | 568 | 569 | 593 | 594 | 595 | 596 | --------------------------------------------------------------------------------