From 53335ce936e758b816cce584665d1d55914b4ef4 Mon Sep 17 00:00:00 2001 From: Mike Crute Date: Wed, 29 Jul 2015 18:38:22 -0700 Subject: Initial import --- requirements.txt | 4 + src/audiocloudweb.cfg | 7 + src/get_music_url.py | 23 + src/index_files.py | 58 + src/index_search.py | 10 + src/index_service.py | 27 + src/indexer.py | 113 + src/load_filesystem.py | 11 + src/load_itunes.py | 326 + src/parse_mp4.py | 67 + src/parse_smart_playlist.py | 636 ++ src/test.html | 16 + src/test.py | 21 + src/track_service.py | 132 + src/util.py | 29 + webapp/css/main.css | 55 + webapp/index.html | 36 + webapp/robots.txt | 3 + webapp/test.css | 303 + webapp/test.html | 145 + webapp/vendor/backbone-1.1.2.js | 1608 ++++ .../vendor/html5-boilerplate-4.3.0/.gitattributes | 1 + webapp/vendor/html5-boilerplate-4.3.0/.gitignore | 2 + webapp/vendor/html5-boilerplate-4.3.0/.htaccess | 551 ++ webapp/vendor/html5-boilerplate-4.3.0/css/main.css | 304 + .../html5-boilerplate-4.3.0/css/normalize.css | 527 ++ webapp/vendor/jquery-2.1.1.js | 9190 ++++++++++++++++++++ webapp/vendor/spoticon.svg | 691 ++ webapp/vendor/underscore-1.7.0.js | 1416 +++ 29 files changed, 16312 insertions(+) create mode 100644 requirements.txt create mode 100644 src/audiocloudweb.cfg create mode 100644 src/get_music_url.py create mode 100644 src/index_files.py create mode 100644 src/index_search.py create mode 100644 src/index_service.py create mode 100644 src/indexer.py create mode 100644 src/load_filesystem.py create mode 100644 src/load_itunes.py create mode 100644 src/parse_mp4.py create mode 100644 src/parse_smart_playlist.py create mode 100644 src/test.html create mode 100644 src/test.py create mode 100755 src/track_service.py create mode 100644 src/util.py create mode 100644 webapp/css/main.css create mode 100755 webapp/index.html create mode 100755 webapp/robots.txt create mode 100644 webapp/test.css create mode 100644 webapp/test.html create mode 100644 webapp/vendor/backbone-1.1.2.js create mode 100755 webapp/vendor/html5-boilerplate-4.3.0/.gitattributes create mode 100755 webapp/vendor/html5-boilerplate-4.3.0/.gitignore create mode 100755 webapp/vendor/html5-boilerplate-4.3.0/.htaccess create mode 100755 webapp/vendor/html5-boilerplate-4.3.0/css/main.css create mode 100755 webapp/vendor/html5-boilerplate-4.3.0/css/normalize.css create mode 100644 webapp/vendor/jquery-2.1.1.js create mode 100644 webapp/vendor/spoticon.svg create mode 100644 webapp/vendor/underscore-1.7.0.js diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4724c3a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +Whoosh==2.6.0 +boto==2.32.1 +bottle==0.12.7 +mutagen==1.24 diff --git a/src/audiocloudweb.cfg b/src/audiocloudweb.cfg new file mode 100644 index 0000000..8df17d0 --- /dev/null +++ b/src/audiocloudweb.cfg @@ -0,0 +1,7 @@ +[audiocloudweb] +access_key = +secret_key = + +[audiocloud] +access_key = +secret_key = diff --git a/src/get_music_url.py b/src/get_music_url.py new file mode 100644 index 0000000..d4284d4 --- /dev/null +++ b/src/get_music_url.py @@ -0,0 +1,23 @@ +import sqlite3 +import urllib +from util import SimpleConfigParser +from boto.s3.connection import S3Connection + +# remove key metadata +#key = key.copy(key.bucket.name, key.name, metadata={'Content-Type': 'audio/mp4'}, preserve_acl=True) + +db = sqlite3.connect('iTunesLibrary.db') +curs = db.cursor() +cfg = SimpleConfigParser('audiocloudweb.cfg', 'audiocloud') +s3 = S3Connection(cfg.get('access_key'), cfg.get('secret_key')) +bucket = s3.get_bucket('mecmusic') + + + +#curs.execute('select location from track where artist = 629 limit 1') +curs.execute('select location from track where track_id = 29510') +track = curs.fetchone()[0][6:]#.encode('utf-8') + +key = bucket.get_key(track, validate=True) +key.metadata = {} +print key.generate_url(60 * 10) diff --git a/src/index_files.py b/src/index_files.py new file mode 100644 index 0000000..ece51aa --- /dev/null +++ b/src/index_files.py @@ -0,0 +1,58 @@ +import os +import re +import json +import mutagen +from pprint import pprint + + +music = re.compile('.*\.(mp3|m4a)$') + + +tags = set() + + +def parse_frame(txt): + frame_type, remainder = txt.split(':', 1) + content = None + + if remainder.startswith('http'): + remainder = remainder.split(':', 2) + content = remainder[-1] + remainder = ':'.join(remainder[:2]) + elif ':' in remainder: + remainder, content = remainder.split(':', 1) + + return frame_type, remainder, content + + +for path, dirs, files in os.walk(os.path.expanduser('~/Desktop/Music')): + for file in files: + fullname = os.path.join(path, file) + + if not music.match(fullname): + continue + + file = mutagen.File(fullname) + + if not file or not file.tags: + print "ERROR: ", fullname + continue + + for tag, value in file.tags.items(): + #if hasattr(value, 'text'): + # value = value.text + + #if isinstance(value, list) and len(value) == 1: + # value = value[0] + + #if tag == 'covr': + # continue + + #if hasattr(value, 'mime') and value.mime.startswith('image'): + # continue + + #if tag.startswith(('PRIV', 'APIC', 'COMM', 'USLT', 'WCOM')): + # continue + + #if not isinstance(value, unicode): + print repr(tag), type(value), repr(value) diff --git a/src/index_search.py b/src/index_search.py new file mode 100644 index 0000000..805a9fb --- /dev/null +++ b/src/index_search.py @@ -0,0 +1,10 @@ +from whoosh import index +from whoosh.qparser import MultifieldParser + +idx = index.open_dir("indexdir") +parser = MultifieldParser(["track_name", "artist", "album"], schema=idx.schema) + +with idx.searcher() as searcher: + results = searcher.search(parser.parse("nine inch nails"), groupedby="genre") + import pdb; pdb.set_trace() + True diff --git a/src/index_service.py b/src/index_service.py new file mode 100644 index 0000000..5038fce --- /dev/null +++ b/src/index_service.py @@ -0,0 +1,27 @@ +from whoosh import index +from whoosh.qparser import MultifieldParser + +import bottle +from bottle import route, run, request + + +@route('/search') +def search(): + term = request.query.get("q") + + if not term: + return { 'error': 'No search term specified' } + + idx = index.open_dir("indexdir") + parser = MultifieldParser( + ["track_name", "artist", "album"], schema=idx.schema) + + with idx.searcher() as searcher: + results = searcher.search(parser.parse(term)) + return { 'results': [dict(result) for result in results] } + +bottle.debug(True) +app = bottle.default_app() + +#if __name__ == "__main__": +run(host='localhost', port=8080) diff --git a/src/indexer.py b/src/indexer.py new file mode 100644 index 0000000..283cdbc --- /dev/null +++ b/src/indexer.py @@ -0,0 +1,113 @@ +import sqlite3 + +from whoosh import fields, index + +TRACK_QUERY = """ +SELECT + t.track_id, t.composer, t.explicit, t.disc_number, t.name as track_name, + t.track_number, t.year as track_year, g.name as genre, a.artist, + ab.title as album, ab.artist as album_artist, ab.compilation, + ab.disc_count, ab.gapless, ab.release_date, ab.track_count, + ab.year as album_year, p.bpm, p.bit_rate, p.sample_rate, p.total_time, + k.kind +FROM + track t +INNER JOIN + genre g +ON t.genre = g.id +INNER JOIN + artist a +ON t.artist = a.id +INNER JOIN + album ab +ON t.album = ab.id +INNER JOIN + track_physical p +ON t.track_id = p.track_id +INNER JOIN + kind k +ON p.kind = k.id +""" + +def safe_unicode(value): + return value + return value.decode("utf-8") if value else None + +def safe_int(value): + return int(value) if value is not None else None + +def to_boolean(value): + return 1 if value else 0 + +def first_value(*items): + for item in items: + if item is not None: + return item + + return None + + +db = sqlite3.connect('iTunesLibrary.db') +db.row_factory = sqlite3.Row + +curs = db.cursor() +curs.execute(TRACK_QUERY) + +schema = fields.Schema( + track_id=fields.ID(stored=True), + composer=fields.TEXT(stored=True), + explicit=fields.BOOLEAN(stored=True), + disc_number=fields.NUMERIC(stored=True), + + track_name=fields.NGRAM(stored=True), + genre=fields.NGRAM(stored=True), + artist=fields.NGRAM(stored=True), + album=fields.NGRAM(stored=True), + + track_number=fields.NUMERIC(stored=True), + year=fields.NUMERIC(stored=True), + compilation=fields.BOOLEAN(stored=True), + disc_count=fields.NUMERIC(stored=True), + gapless=fields.BOOLEAN(stored=True), + release_date=fields.DATETIME(stored=True), + track_count=fields.NUMERIC(stored=True), + bpm=fields.NUMERIC(stored=True), + bit_rate=fields.NUMERIC(stored=True), + sample_rate=fields.NUMERIC(stored=True), + total_time=fields.NUMERIC(stored=True), + kind=fields.TEXT(stored=True) +) + +idx = index.create_in("indexdir", schema) +writer = idx.writer() + +for record in curs.fetchall(): + writer.add_document( + track_id=str(record['track_id']).decode("ascii"), + + composer=safe_unicode(record['composer']), + genre=safe_unicode(record['genre']), + album=safe_unicode(record['album']), + artist=safe_unicode(first_value(record['artist'], record['album_artist'])), + track_name=safe_unicode(record['track_name']), + kind=safe_unicode(record['kind']), + + #release_date=record['release_date'], + + explicit=to_boolean(record['explicit']), + compilation=to_boolean(record['compilation']), + gapless=to_boolean(record['gapless']), + + disc_number=safe_int(record['disc_number']), + track_number=safe_int(record['track_number']), + year=safe_int(first_value(record['track_year'], record['album_year'])), + disc_count=safe_int(record['disc_count']), + track_count=safe_int(record['track_count']), + bpm=safe_int(record['bpm']), + bit_rate=safe_int(record['bit_rate']), + sample_rate=safe_int(record['sample_rate']), + total_time=safe_int(record['total_time']), + ) + +writer.commit() +db.close() diff --git a/src/load_filesystem.py b/src/load_filesystem.py new file mode 100644 index 0000000..6181017 --- /dev/null +++ b/src/load_filesystem.py @@ -0,0 +1,11 @@ +import sqlite3 +import codecs + +db = sqlite3.connect('iTunes/iTunesLibrary.db') +curs = db.cursor() + +with codecs.open('tracks.txt', 'r', 'utf-8') as fp: + for track in fp: + curs.execute('insert into filesystem(location) values(?)', (track.strip(),)) + +db.commit() diff --git a/src/load_itunes.py b/src/load_itunes.py new file mode 100644 index 0000000..f4131b1 --- /dev/null +++ b/src/load_itunes.py @@ -0,0 +1,326 @@ +import sqlite3 +import plistlib + +library = plistlib.readPlist('iTunes-Music-Library.xml') + + +class BasicLibraryItem(object): + + def get_insert_query(self): + return 'INSERT INTO {} VALUES ({})'.format( + self.TABLE_NAME, self.get_bind_string(self.COLUMN_COUNT)) + + @staticmethod + def get_bind_string(count): + return ('?,' * count)[:-1] + + @staticmethod + def as_data_string(value): + if not value: + return None + else: + return value.asBase64() + + @staticmethod + def as_boolean(value): + return 1 if value else 0 + + @staticmethod + def format_name(name): + return name.lower().replace(' ', '_') + + @classmethod + def from_plist_entry(cls, entry): + self = cls() + + for key, value in entry.items(): + name = cls.format_name(key) + + if not hasattr(self, name): + raise Exception("No attribute named %r" % name) + + setattr(self, name, value) + + return self + + +class Playlist(BasicLibraryItem): + + COLUMN_COUNT = 21 + TABLE_NAME = 'playlist' + + def __init__(self): + self.playlist_id = None + self.playlist_persistent_id = None + self.parent_persistent_id = None + self.distinguished_kind = None + self.genius_track_id = None + self.name = None + + self.master = False + self.visible = False + self.all_items = False + self.folder = False + + self.music = False + self.movies = False + self.tv_shows = False + self.podcasts = False + self.itunesu = False + self.audiobooks = False + self.books = False + self.purchased_music = False + + self.smart_info = None + self.smart_criteria = None + + self.items = [] + + def as_dbrow_tuple(self): + return ( + self.playlist_id, #int + 1, # user_id + self.playlist_persistent_id, + self.parent_persistent_id, + self.distinguished_kind, #int + self.genius_track_id, #int + self.name, + + self.as_boolean(self.master), + self.as_boolean(self.visible), + self.as_boolean(self.all_items), + self.as_boolean(self.folder), + self.as_boolean(self.music), + self.as_boolean(self.movies), + self.as_boolean(self.tv_shows), + self.as_boolean(self.podcasts), + self.as_boolean(self.itunesu), + self.as_boolean(self.audiobooks), + self.as_boolean(self.books), + self.as_boolean(self.purchased_music), + + self.as_data_string(self.smart_info), + self.as_data_string(self.smart_criteria) + ) + + @classmethod + def from_plist_entry(cls, entry): + self = cls() + + for key, value in entry.items(): + if key == 'Playlist Items': + for item in value: + self.items.append(item['Track ID']) + + continue + + name = cls.format_name(key) + + if not hasattr(self, name): + raise Exception("No attribute named %r" % name) + + setattr(self, name, value) + + return self + + +class Track(BasicLibraryItem): + + COLUMN_COUNT = 53 + TABLE_NAME = 'track' + + def __init__(self): + self.album_rating = None + self.album_rating_computed = False + self.comments = None + self.volume_adjustment = None + self.unplayed = False + self.date_added = None + self.date_modified = None + self.disabled = False + self.play_count = None + self.play_date = None + self.play_date_utc = None + self.rating = None + self.skip_count = None + self.skip_date = None + self.track_id = None + self.persistent_id = None + self.library_folder_count = None + self.file_folder_count = None + self.location = None + self.track_type = None + self.file_type = None + self.artwork_count = None + self.hd = False + self.has_video = False + self.itunesu = False + self.tv_show = False + self.podcast = False + self.protected = False + self.purchased = False + self.movie = False + self.music_video = False + self.bpm = None + self.bit_rate = None + self.sample_rate = None + self.size = None + self.total_time = None + self.kind = None + self.video_height = None + self.video_width = None + self.sort_album = None + self.sort_album_artist = None + self.sort_artist = None + self.sort_composer = None + self.sort_name = None + self.sort_series = None + self.album = None + self.album_artist = None + self.artist = None + self.clean = False + self.compilation = False + self.composer = None + self.content_rating = None + self.disc_count = None + self.disc_number = None + self.episode = None + self.episode_order = None + self.explicit = False + self.genre = None + self.grouping = None + self.name = None + self.part_of_gapless_album = False + self.release_date = None + self.season = None + self.series = None + self.track_count = None + self.track_number = None + self.year = None + + def as_dbrow_tuple(self): + return ( + self.track_id, + self.persistent_id, + self.library_folder_count, + self.file_folder_count, + self.location, + self.track_type, + self.file_type, + self.artwork_count, + self.as_boolean(self.hd), + self.as_boolean(self.has_video), + self.as_boolean(self.itunesu), + self.as_boolean(self.tv_show), + self.as_boolean(self.podcast), + self.as_boolean(self.protected), + self.as_boolean(self.purchased), + self.as_boolean(self.movie), + self.as_boolean(self.music_video), + self.bpm, + self.bit_rate, + self.sample_rate, + self.size, + self.total_time, + self.kind, + self.video_height, + self.video_width, + self.sort_album, + self.sort_album_artist, + self.sort_artist, + self.sort_composer, + self.sort_name, + self.sort_series, + self.album, + self.album_artist, + self.artist, + self.as_boolean(self.clean), + self.as_boolean(self.compilation), + self.composer, + self.content_rating, + self.disc_count, + self.disc_number, + self.episode, + self.episode_order, + self.as_boolean(self.explicit), + self.genre, + self.grouping, + self.name, + self.as_boolean(self.part_of_gapless_album), + self.release_date, + self.season, + self.series, + self.track_count, + self.track_number, + self.year + ) + + def usermeta_as_dbrow_tuple(self): + return ( + 1, + self.track_id, + self.album_rating, + self.as_boolean(self.album_rating_computed), + self.comments, + self.volume_adjustment, + self.as_boolean(self.unplayed), + self.date_added, + self.date_modified, + self.as_boolean(self.disabled), + self.play_count, + self.play_date, + self.play_date_utc, + self.rating, + self.skip_count, + self.skip_date + ) + + USERMETA_COLUMN_COUNT = 16 + USERMETA_TABLE_NAME = 'user_track_metadata' + + def get_usermeta_insert_query(self): + return 'INSERT INTO {} VALUES ({})'.format( + self.USERMETA_TABLE_NAME, + self.get_bind_string(self.USERMETA_COLUMN_COUNT)) + + +assert library['Major Version'] == 1, "Invalid major version" +assert library['Minor Version'] == 1, "Invalid minor version" + +def insert_playlists(db, library): + playlists = [Playlist.from_plist_entry(entry) + for entry in library['Playlists']] + + curs = db.cursor() + track_insert_query = playlists[0].get_insert_query() + + for playlist in playlists: + curs.execute(track_insert_query, playlist.as_dbrow_tuple()) + + for item in playlist.items: + curs.execute('INSERT INTO playlist_track VALUES (?, ?)', + (playlist.playlist_id, item)) + + db.commit() + + +def insert_tracks(db, library): + music_folder = library['Music Folder'] + tracks = [Track.from_plist_entry(entry) + for entry in library['Tracks'].values()] + + curs = db.cursor() + track_insert_query = tracks[0].get_insert_query() + usermeta_insert_query = tracks[0].get_usermeta_insert_query() + + for track in tracks: + curs.execute(track_insert_query, track.as_dbrow_tuple()) + curs.execute(usermeta_insert_query, track.usermeta_as_dbrow_tuple()) + + db.commit() + + +db = sqlite3.connect('iTunesLibrary.db') +#insert_playlists(db, library) +insert_tracks(db, library) +db.close() diff --git a/src/parse_mp4.py b/src/parse_mp4.py new file mode 100644 index 0000000..3e92330 --- /dev/null +++ b/src/parse_mp4.py @@ -0,0 +1,67 @@ +from mutagen import mp4 +from mutagen.easyid3 import EasyID3 + + +class MP4Tags(mp4.MP4Tags): + + fields = { + 'cover': 'covr', + 'tempo': 'tmpo', + 'track_num': 'trkn', + 'disc_num': 'disk', + 'is_part_of_compilation': 'cpil', + 'is_part_of_gapless_album': 'pgap', + 'is_podcast': 'pcst', + 'track_title': '\xa9nam', + 'album': '\xa9alb', + 'artist': '\xa9ART', + 'album_artist': 'aART', + 'composer': '\xa9wrt', + 'year': '\xa9day', + 'comment': '\xa9cmt', + 'description': 'desc', + 'purchase_date': 'purd', + 'grouping': '\xa9grp', + 'genre': '\xa9gen', + 'lyrics': '\xa9lyr', + 'podcast_url': 'purl', + 'podcast_episode_id': 'egid', + 'podcast_category': 'catg', + 'podcast_keyword': 'keyw', + 'encoded_by': '\xa9too', + 'copyright': 'cprt', + 'sort_album': 'soal', + 'sort_album_artist': 'soaa', + 'sort_artist': 'soar', + 'sort_title': 'sonm', + 'sort_composer': 'soco', + 'sort_show': 'sosn', + 'tv_show_name': 'tvsh', + } + + @property + def track_number(self): + return self.track_num[0] + + @property + def total_tracks(self): + return self.track_num[1] + + @property + def disc_number(self): + return self.disc_num[0] + + @property + def total_discs(self): + return self.disc_num[1] + + def __getattr__(self, attr): + value = self[self.fields[attr]] + if len(value) == 1: + return value[0] + else: + return value + + +class MP4(mp4.MP4): + MP4Tags = MP4Tags diff --git a/src/parse_smart_playlist.py b/src/parse_smart_playlist.py new file mode 100644 index 0000000..0bfde9d --- /dev/null +++ b/src/parse_smart_playlist.py @@ -0,0 +1,636 @@ +# // http://banshee-itunes-import-plugin.googlecode.com/svn/trunk/ +# using System; +# using System.Collections.Generic; +# using System.Text; +# using Banshee.SmartPlaylist; +# +# namespace Banshee.Plugins.iTunesImporter +# { +# internal struct SmartPlaylist +# { +# public string Query, Ignore, OrderBy, Name, Output; +# public uint LimitNumber; +# public byte LimitMethod; +# } +# +# internal static partial class SmartPlaylistParser +# { +# private delegate bool KindEvalDel(Kind kind, string query); +# +# // INFO OFFSETS +# // +# // Offsets for bytes which... +# const int MATCHBOOLOFFSET = 1; // determin whether logical matching is to be performed - Absolute offset +# const int LIMITBOOLOFFSET = 2; // determin whether results are limited - Absolute offset +# const int LIMITMETHODOFFSET = 3; // determin by what criteria the results are limited - Absolute offset +# const int SELECTIONMETHODOFFSET = 7; // determin by what criteria limited playlists are populated - Absolute offset +# const int LIMITINTOFFSET = 11; // determin the limited - Absolute offset +# const int SELECTIONMETHODSIGNOFFSET = 13;// determin whether certain selection methods are "most" or "least" - Absolute offset +# +# // CRITERIA OFFSETS +# // +# // Offsets for bytes which... +# const int LOGICTYPEOFFSET = 15; // determin whether all or any criteria must match - Absolute offset +# const int FIELDOFFSET = 139; // determin what is being matched (Artist, Album, &c) - Absolute offset +# const int LOGICSIGNOFFSET = 1; // determin whether the matching rule is positive or negative (e.g., is vs. is not) - Relative offset from FIELDOFFSET +# const int LOGICRULEOFFSET = 4; // determin the kind of logic used (is, contains, begins, &c) - Relative offset from FIELDOFFSET +# const int STRINGOFFSET = 54; // begin string data - Relative offset from FIELDOFFSET +# const int INTAOFFSET = 60; // begin the first int - Relative offset from FIELDOFFSET +# const int INTBOFFSET = 24; // begin the second int - Relative offset from INTAOFFSET +# const int TIMEMULTIPLEOFFSET = 76;// begin the int with the multiple of time - Relative offset from FIELDOFFSET +# const int TIMEVALUEOFFSET = 68; // begin the inverse int with the value of time - Relative offset from FIELDOFFSET +# +# const int INTLENGTH = 64; // The length on a int criteria starting at the first int +# static DateTime STARTOFTIME = new DateTime(1904, 1, 1); // Dates are recorded as seconds since Jan 1, 1904 +# +# static bool or, again; +# static string conjunctionOutput, conjunctionQuery, output, query, ignore; +# static int offset, logicSignOffset,logicRulesOffset, stringOffset, intAOffset, intBOffset, +# timeMultipleOffset, timeValueOffset; +# static byte[] info, criteria; +# +# static KindEvalDel KindEval; +# +# public static SmartPlaylist Parse(byte[] i, byte[] c) +# { +# info = i; +# criteria = c; +# SmartPlaylist result = new SmartPlaylist(); +# offset = FIELDOFFSET; +# output = ""; +# query = ""; +# ignore = ""; +# +# if(info[MATCHBOOLOFFSET] == 1) { +# or = (criteria[LOGICTYPEOFFSET] == 1) ? true : false; +# if(or) { +# conjunctionQuery = " OR "; +# conjunctionOutput = " or\n"; +# } else { +# conjunctionQuery = " AND "; +# conjunctionOutput = " and\n"; +# } +# do { +# again = false; +# logicSignOffset = offset + LOGICSIGNOFFSET; +# logicRulesOffset = offset + LOGICRULEOFFSET; +# stringOffset = offset + STRINGOFFSET; +# intAOffset = offset + INTAOFFSET; +# intBOffset = intAOffset + INTBOFFSET; +# timeMultipleOffset = offset + TIMEMULTIPLEOFFSET; +# timeValueOffset = offset + TIMEVALUEOFFSET; +# +# if(Enum.IsDefined(typeof(StringFields), (int)criteria[offset])) { +# ProcessStringField(); +# } else if(Enum.IsDefined(typeof(IntFields), (int)criteria[offset])) { +# ProcessIntField(); +# } else if(Enum.IsDefined(typeof(DateFields), (int)criteria[offset])) { +# ProcessDateField(); +# } else { +# ignore += "Not processed"; +# } +# } +# while(again); +# } +# result.Output = output; +# result.Query = query; +# result.Ignore = ignore; +# if(info[LIMITBOOLOFFSET] == 1) { +# uint limit = BytesToUInt(info, LIMITINTOFFSET); +# result.LimitNumber = (info[LIMITMETHODOFFSET] == (byte)LimitMethods.GB) ? limit * 1024 : limit; +# if(output.Length > 0) { +# output += "\n"; +# } +# output += "Limited to " + limit.ToString() + " " + +# Enum.GetName(typeof(LimitMethods), (int)info[LIMITMETHODOFFSET]) + " selected by "; +# switch(info[LIMITMETHODOFFSET]) { +# case (byte)LimitMethods.Items: +# result.LimitMethod = 0; +# break; +# case (byte)LimitMethods.Minutes: +# result.LimitMethod = 1; +# break; +# case (byte)LimitMethods.Hours: +# result.LimitMethod = 2; +# break; +# case (byte)LimitMethods.MB: +# result.LimitMethod = 3; +# break; +# case (byte)LimitMethods.GB: +# goto case (byte)LimitMethods.MB; +# } +# switch(info[SELECTIONMETHODOFFSET]) { +# case (byte)SelectionMethods.Random: +# output += "random"; +# result.OrderBy = "RANDOM()"; +# break; +# case (byte)SelectionMethods.HighestRating: +# output += "highest rated"; +# result.OrderBy = "Rating DESC"; +# break; +# case (byte)SelectionMethods.LowestRating: +# output += "lowest rated"; +# result.OrderBy = "Rating ASC"; +# break; +# case (byte)SelectionMethods.RecentlyPlayed: +# output += (info[SELECTIONMETHODSIGNOFFSET] == 0) +# ? "most recently played" : "least recently played"; +# result.OrderBy = (info[SELECTIONMETHODSIGNOFFSET] == 0) +# ? "LastPlayedStamp DESC" : "LastPlayedStamp ASC"; +# break; +# case (byte)SelectionMethods.OftenPlayed: +# output += (info[SELECTIONMETHODSIGNOFFSET] == 0) +# ? "most often played" : "least often played"; +# result.OrderBy = (info[SELECTIONMETHODSIGNOFFSET] == 0) +# ? "NumberOfPlays DESC" : "NumberOfPlays ASC"; +# break; +# case (byte)SelectionMethods.RecentlyAdded: +# output += (info[SELECTIONMETHODSIGNOFFSET] == 0) +# ? "most recently added" : "least recently added"; +# result.OrderBy = (info[SELECTIONMETHODSIGNOFFSET] == 0) +# ? "DateAddedStamp DESC" : "DateAddedStamp ASC"; +# break; +# default: +# result.OrderBy = Enum.GetName(typeof(SelectionMethods), (int)info[SELECTIONMETHODOFFSET]); +# break; +# } +# } +# if(ignore.Length > 0) { +# output += "\n\nIGNORING:\n" + ignore; +# } +# +# if(query.Length > 0) { +# output += "\n\nQUERY:\n" + query; +# } +# return result; +# } +# +# private static void ProcessStringField() +# { +# bool end = false; +# string workingOutput = Enum.GetName(typeof(StringFields), criteria[offset]); +# string workingQuery = "(lower(" + Enum.GetName(typeof(StringFields), criteria[offset]) + ")"; +# switch(criteria[logicRulesOffset]) { +# case (byte)LogicRule.Contains: +# if((criteria[logicSignOffset] == (byte)LogicSign.StringPositive)) { +# workingOutput += " contains "; +# workingQuery += " LIKE '%"; +# } else { +# workingOutput += " does not contain "; +# workingQuery += " NOT LIKE '%"; +# } +# if(criteria[offset] == (byte)StringFields.Kind) { +# KindEval = delegate(Kind kind, string query) { +# return (kind.Name.IndexOf(query) != -1); +# }; +# } +# end = true; +# break; +# case (byte)LogicRule.Is: +# if((criteria[logicSignOffset] == (byte)LogicSign.StringPositive)) { +# workingOutput += " is "; +# workingQuery += " = '"; +# } else { +# workingOutput += " is not "; +# workingQuery += " != '"; +# } +# if(criteria[offset] == (byte)StringFields.Kind) { +# KindEval = delegate(Kind kind, string query) { +# return (kind.Name == query); +# }; +# } +# break; +# case (byte)LogicRule.Starts: +# workingOutput += " starts with "; +# workingQuery += " LIKE '"; +# if(criteria[offset] == (byte)StringFields.Kind) { +# KindEval = delegate (Kind kind, string query) { +# return (kind.Name.IndexOf(query) == 0); +# }; +# } +# end = true; +# break; +# case (byte)LogicRule.Ends: +# workingOutput += " ends with "; +# workingQuery += " LIKE '%"; +# if(criteria[offset] == (byte)StringFields.Kind) { +# KindEval = delegate (Kind kind, string query) { +# return (kind.Name.IndexOf(query) == (kind.Name.Length - query.Length)); +# }; +# } +# break; +# } +# workingOutput += "\""; +# byte[] character = new byte[1]; +# string content = ""; +# bool onByte = true; +# for(int i = (stringOffset); i < criteria.Length; i++) { +# // Off bytes are 0 +# if(onByte) { +# // If the byte is 0 and it's not the last byte, +# // we have another condition +# if(criteria[i] == 0 && i != (criteria.Length - 1)) { +# again = true; +# FinishStringField(content, workingOutput, workingQuery, end); +# offset = i + 2; +# return; +# } +# character[0] = criteria[i]; +# content += Encoding.UTF8.GetString(character); +# } +# onByte = !onByte; +# } +# FinishStringField(content, workingOutput, workingQuery, end); +# } +# +# private static void FinishStringField(string content, string workingOutput, string workingQuery, bool end) +# { +# workingOutput += content; +# workingOutput += "\" "; +# bool failed = false; +# if(criteria[offset] == (byte)StringFields.Kind) { +# workingQuery = ""; +# foreach(Kind kind in Kinds) { +# if(KindEval(kind, content)) { +# if(workingQuery.Length > 0) { +# if((query.Length == 0 && !again) || or) { +# workingQuery += " OR "; +# } else { +# failed = true; +# break; +# } +# } +# workingQuery += "(lower(Uri)"; +# workingQuery += ((criteria[logicSignOffset] == (byte)LogicSign.StringPositive)) +# ? " LIKE '%" + kind.Extension + "')" : " NOT LIKE '%" + kind.Extension + "%')"; +# } +# } +# } else { +# workingQuery += content.ToLower(); +# workingQuery += (end) ? "%')" : "')"; +# } +# if(Enum.IsDefined(typeof(IgnoreStringFields), +# (int)criteria[offset]) || failed) { +# if(ignore.Length > 0) { +# ignore += conjunctionOutput; +# } +# ignore += workingOutput; +# } else { +# if(output.Length > 0) { +# output += conjunctionOutput; +# } +# if(query.Length > 0) { +# query += conjunctionQuery; +# } +# output += workingOutput; +# query += workingQuery; +# } +# } +# +# private static void ProcessIntField() +# { +# string workingOutput = Enum.GetName(typeof(IntFields), criteria[offset]); +# string workingQuery = "(" + Enum.GetName(typeof(IntFields), criteria[offset]); +# +# switch(criteria[logicRulesOffset]) { +# case (byte)LogicRule.Is: +# if(criteria[logicSignOffset] == (byte)LogicSign.IntPositive) { +# workingOutput += " is "; +# workingQuery += " = "; +# } else { +# workingOutput += " is not "; +# workingQuery += " != "; +# } +# goto case 255; +# case (byte)LogicRule.Greater: +# workingOutput += " is greater than "; +# workingQuery += " > "; +# goto case 255; +# case (byte)LogicRule.Less: +# workingOutput += " is less than "; +# workingQuery += " > "; +# goto case 255; +# case 255: +# uint number = (criteria[offset] == (byte)IntFields.Rating) +# ? (BytesToUInt(criteria, intAOffset) / 20) : BytesToUInt(criteria, intAOffset); +# workingOutput += number.ToString(); +# workingQuery += number.ToString(); +# break; +# case (byte)LogicRule.Other: +# if(criteria[logicSignOffset + 2] == 1) { +# workingOutput += " is in the range of "; +# workingQuery += " BETWEEN "; +# uint num = (criteria[offset] == (byte)IntFields.Rating) +# ? (BytesToUInt(criteria, intAOffset) / 20) : BytesToUInt(criteria, intAOffset); +# workingOutput += num.ToString(); +# workingQuery += num.ToString(); +# workingOutput += " to "; +# workingQuery += " AND "; +# num = (criteria[offset] == (byte)IntFields.Rating) +# ? ((BytesToUInt(criteria, intBOffset) - 19) / 20) : BytesToUInt(criteria, intBOffset); +# workingOutput += num.ToString(); +# workingQuery += num.ToString(); +# } +# break; +# } +# workingQuery += ")"; +# if(Enum.IsDefined(typeof(IgnoreIntFields), +# (int)criteria[offset])) { +# if(ignore.Length > 0) { +# ignore += conjunctionOutput; +# } +# ignore += workingOutput; +# } else { +# if(output.Length > 0) { +# output += conjunctionOutput; +# } +# if(query.Length > 0) { +# query += conjunctionQuery; +# } +# output += workingOutput; +# query += workingQuery; +# } +# offset = intAOffset + INTLENGTH; +# if(criteria.Length > offset) { +# again = true; +# } +# } +# +# private static void ProcessDateField() +# { +# bool isIgnore = false; +# string workingOutput = Enum.GetName(typeof(DateFields), criteria[offset]); +# string workingQuery = "((strftime(\"%s\", current_timestamp) - DateAddedStamp + 3600)"; +# switch(criteria[logicRulesOffset]) { +# case (byte)LogicRule.Greater: +# workingOutput += " is after "; +# workingQuery += " > "; +# goto case 255; +# case (byte)LogicRule.Less: +# workingOutput += " is before "; +# workingQuery += " > "; +# goto case 255; +# case 255: +# isIgnore = true; +# DateTime time = BytesToDateTime(criteria, intAOffset); +# workingOutput += time.ToString(); +# workingQuery += ((int)DateTime.Now.Subtract(time).TotalSeconds).ToString(); +# break; +# case (byte)LogicRule.Other: +# if(criteria[logicSignOffset + 2] == 1) { +# isIgnore = true; +# DateTime t2 = BytesToDateTime(criteria, intAOffset); +# DateTime t1 = BytesToDateTime(criteria, intBOffset); +# if(criteria[logicSignOffset] == (byte)LogicSign.IntPositive) { +# workingOutput += " is in the range of "; +# workingQuery += " BETWEEN " + +# ((int)DateTime.Now.Subtract(t1).TotalSeconds).ToString() + +# " AND " + +# ((int)DateTime.Now.Subtract(t2).TotalSeconds).ToString(); +# } else { +# workingOutput += " is not in the range of "; +# } +# workingOutput += t1.ToString(); +# workingOutput += " to "; +# workingOutput += t2.ToString(); +# } else if(criteria[logicSignOffset + 2] == 2) { +# if(criteria[logicSignOffset] == (byte)LogicSign.IntPositive) { +# workingOutput += " is in the last "; +# workingQuery += " < "; +# } else { +# workingOutput += " is not in the last "; +# workingQuery += " > "; +# } +# uint t = InverseBytesToUInt(criteria, timeValueOffset); +# uint multiple = BytesToUInt(criteria, timeMultipleOffset); +# workingQuery += (t * multiple).ToString(); +# workingOutput += t.ToString() + " "; +# switch(multiple) { +# case 86400: +# workingOutput += "days"; +# break; +# case 604800: +# workingOutput += "weeks"; +# break; +# case 2628000: +# workingOutput += "months"; +# break; +# } +# } +# break; +# } +# workingQuery += ")"; +# if(isIgnore || Enum.IsDefined(typeof(IgnoreDateFields), (int)criteria[offset])) { +# if(ignore.Length > 0) { +# ignore += conjunctionOutput; +# } +# ignore += workingOutput; +# } else { +# if(output.Length > 0) { +# output += conjunctionOutput; +# } +# output += workingOutput; +# if(query.Length > 0) { +# query += conjunctionQuery; +# } +# query += workingQuery; +# } +# offset = intAOffset + INTLENGTH; +# if(criteria.Length > offset) { +# again = true; +# } +# } +# +# /// +# /// Converts 4 bytes to a uint +# /// +# /// A byte array +# /// Should be the byte of the uint with the 0th-power position +# /// +# private static uint BytesToUInt(byte[] byteArray, int offset) +# { +# uint output = 0; +# for (byte i = 0; i <= 4; i++) { +# output += (uint)(byteArray[offset - i] * Math.Pow(2, (8 * i))); +# } +# return output; +# } +# +# private static uint InverseBytesToUInt(byte[] byteArray, int offset) +# { +# uint output = 0; +# for (byte i = 0; i <= 4; i++) { +# output += (uint)((255 - (uint)(byteArray[offset - i])) * Math.Pow(2, (8 * i))); +# } +# return ++output; +# } +# +# private static DateTime BytesToDateTime (byte[] byteArray, int offset) +# { +# uint number = BytesToUInt(byteArray, offset); +# return STARTOFTIME.AddSeconds(number); +# } +# } +# } +# namespace Banshee.Plugins.iTunesImporter +# { +# internal struct Kind +# { +# public string Name, Extension; +# public Kind(string name, string extension) +# { +# Name = name; +# Extension = extension; +# } +# } +# +# internal partial class SmartPlaylistParser +# { +# private static Kind[] Kinds = { +# new Kind("Protected AAC audio file", ".m4p"), +# new Kind("MPEG audio file", ".mp3"), +# new Kind("AIFF audio file", ".aiff"), +# new Kind("WAV audio file", ".wav"), +# new Kind("QuickTime movie file", ".mov"), +# new Kind("MPEG-4 video file", ".mp4"), +# new Kind("AAC audio file", ".m4a") +# }; +# +# /// +# /// The methods by which the number of songs in a playlist are limited +# /// +# private enum LimitMethods +# { +# Minutes = 0x01, +# MB = 0x02, +# Items = 0x03, +# Hours = 0x04, +# GB = 0x05, +# } +# /// +# /// The methods by which songs are selected for inclusion in a limited playlist +# /// +# private enum SelectionMethods +# { +# Random = 0x02, +# Title = 0x05, +# AlbumTitle = 0x06, +# Artist = 0x07, +# Genre = 0x09, +# HighestRating = 0x1c, +# LowestRating = 0x01, +# RecentlyPlayed = 0x1a, +# OftenPlayed = 0x19, +# RecentlyAdded = 0x15 +# } +# /// +# /// The matching criteria which take string data +# /// +# private enum StringFields +# { +# AlbumTitle = 0x03, +# AlbumArtist = 0x47, +# Artist = 0x04, +# Category = 0x37, +# Comments = 0x0e, +# Composer = 0x12, +# Description = 0x36, +# Genre = 0x08, +# Grouping = 0x27, +# Kind = 0x09, +# Title = 0x02, +# Show = 0x3e +# } +# /// +# /// The matching criteria which take integer data +# /// +# private enum IntFields +# { +# BPM = 0x23, +# BitRate = 0x05, +# Compilation = 0x1f, +# DiskNumber = 0x18, +# NumberOfPlays = 0x16, +# Rating = 0x19, +# Playlist = 0x28, // FIXME Move this? +# Podcast = 0x39, +# SampleRate = 0x06, +# Season = 0x3f, +# Size = 0x0c, +# SkipCount = 0x44, +# Duration = 0x0d, +# TrackNumber = 0x0b, +# VideoKind = 0x3c, +# Year = 0x07 +# } +# /// +# /// The matching criteria which take date data +# /// +# private enum DateFields +# { +# DateAdded = 0x10, +# DateModified = 0x0a, +# LastPlayed = 0x17, +# LastSkipped = 0x45 +# } +# /// +# /// The matching criteria which we do no handle +# /// +# private enum IgnoreStringFields +# { +# AlbumArtist = 0x47, +# Category = 0x37, +# Comments = 0x0e, +# Composer = 0x12, +# Description = 0x36, +# Grouping = 0x27, +# Show = 0x3e +# } +# /// +# /// The matching criteria which we do no handle +# /// +# private enum IgnoreIntFields +# { +# BPM = 0x23, +# BitRate = 0x05, +# Compilation = 0x1f, +# DiskNumber = 0x18, +# Playlist = 0x28, +# Podcast = 0x39, +# SampleRate = 0x06, +# Season = 0x3f, +# Size = 0x0c, +# SkipCount = 0x44, +# TrackNumber = 0x0b, +# VideoKind = 0x3c +# } +# private enum IgnoreDateFields +# { +# DateModified = 0x0a, +# LastSkipped = 0x45 +# } +# /// +# /// The signs which apply to different kinds of logic (is vs. is not, contains vs. doesn't contain, etc.) +# /// +# private enum LogicSign +# { +# IntPositive = 0x00, +# StringPositive = 0x01, +# IntNegative = 0x02, +# StringNegative = 0x03 +# } +# /// +# /// The logical rules +# /// +# private enum LogicRule +# { +# Other = 0x00, +# Is = 0x01, +# Contains = 0x02, +# Starts = 0x04, +# Ends = 0x08, +# Greater = 0x10, +# Less = 0x40 +# } +# } +# } diff --git a/src/test.html b/src/test.html new file mode 100644 index 0000000..b1636dd --- /dev/null +++ b/src/test.html @@ -0,0 +1,16 @@ + + + + + + + + + + + + + diff --git a/src/test.py b/src/test.py new file mode 100644 index 0000000..3f5ccd8 --- /dev/null +++ b/src/test.py @@ -0,0 +1,21 @@ +import sqlite3 + +def unfuck_unicode(text): + return ''.join([chr(n) for n in [ord(i) for i in text]]).decode('utf-8') + +conn = sqlite3.connect('iTunesLibrary.db') +curs = conn.cursor() +upcurs = conn.cursor() + +curs.execute('select track_id, location from track where location is not null') + + +for id, datum in curs.fetchall(): + try: + datum.decode('utf-8') + except UnicodeEncodeError: + print id, type(datum), datum.encode('utf-8') + #upcurs.execute('update track set location = ? where track_id = ?', + # (datum.encode('utf-8'), id)) + +conn.commit() diff --git a/src/track_service.py b/src/track_service.py new file mode 100755 index 0000000..4a5a149 --- /dev/null +++ b/src/track_service.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python + +import sqlite3 +import bottle +import urllib + +db = sqlite3.connect('iTunesLibrary.db') +db.row_factory = sqlite3.Row + +ARTIST_COUNT_QUERY = """ +select a.*, count(t.track_id) as total_tracks +from track t +inner join artist a on t.artist = a.id +{where} +group by t.artist +""" + +GENRE_COUNT_QUERY = """ +select g.*, count(t.track_id) as total_tracks +from track t +inner join genre g on t.genre = g.id +{where} +group by t.genre +""" + +ALBUM_COUNT_QUERY = """ +select a.id, a.title, count(t.track_id) as total_tracks +from track t +inner join album a on t.album = a.id +{where} +group by t.album +""" + +TRACK_QUERY = """ +select +t.track_id, t.name, t.track_number, t.disc_number, +ar.artist, +ab.title, ab.track_count, ab.disc_count, +g.name +from track t +inner join artist ar on t.artist = ar.id +inner join album ab on t.album = ab.id +inner join genre g on t.genre = g.id +{where} +""" + +def _format_json(curs): + data = [] + + for item in curs.fetchall(): + data.append({ + 'id': item[0], + 'name': item[1].encode('utf-8'), + 'tracks': item[2], + }) + + return data + + +def _run_query(curs, query, where="", params=None): + query = query.format(where=where) + + if params: + curs.execute(query, params) + else: + curs.execute(query) + + +def _build_where(query): + where = [] + params = [] + + if 'genre' in query: + where.append('t.genre = ?') + params.append(query['genre']) + + if 'artist' in query: + where.append('t.artist = ?') + params.append(query['artist']) + + if 'album' in query: + where.append('t.album = ?') + params.append(query['album']) + + if where: + where = 'WHERE ' + ' AND '.join(where) + else: + where = "" + + return where, params + + +@bottle.route('/genre') +def genre_controller(): + curs = db.cursor() + curs.execute(GENRE_COUNT_QUERY.format(where="")) + return { 'data': _format_json(curs) } + + +@bottle.route('/browser') +def browser_controller(): + curs = db.cursor() + where, params = _build_where(bottle.request.query) + + _run_query(curs, ARTIST_COUNT_QUERY, where, params) + artists = _format_json(curs) + + _run_query(curs, GENRE_COUNT_QUERY, where, params) + genres = _format_json(curs) + + _run_query(curs, ALBUM_COUNT_QUERY, where, params) + albums = _format_json(curs) + + return { + 'data': { + 'artists': artists, + 'genres': genres, + 'albums': albums, + } + } + + +@bottle.route('/tracks') +def tracks_controller(): + curs = db.cursor() + where, params = _build_where(bottle.request.query) + _run_query(curs, TRACK_QUERY, where, params) + return { 'data': [dict(row) for row in curs.fetchall()] } + + +bottle.debug(True) +bottle.run(host='localhost', port=8100) diff --git a/src/util.py b/src/util.py new file mode 100644 index 0000000..ff1df56 --- /dev/null +++ b/src/util.py @@ -0,0 +1,29 @@ +from ConfigParser import SafeConfigParser + +class SimpleConfigParser(SafeConfigParser, object): + """SafeConfigParser that auto-loads files + + This class also supports a more terse form of get that uses the default + section which can be passed to the constructor. + """ + + def __init__(self, filename, default_section=None): + super(SimpleConfigParser, self).__init__() + self.default_section = default_section + + with open(filename) as fp: + self.readfp(fp) + + def get(self, section, option=None): + """Get that allows skipping section + + This can be called with one or two arguments. The two argument form is + the same as ConfigParser. The one argument form allows skipping the + section if the class has a default_section set. + """ + default_get = super(SimpleConfigParser, self).get + + if not option and self.default_section: + return default_get(self.default_section, section) + else: + return default_get(section, option) diff --git a/webapp/css/main.css b/webapp/css/main.css new file mode 100644 index 0000000..fecb981 --- /dev/null +++ b/webapp/css/main.css @@ -0,0 +1,55 @@ +@font-face { + font-family: "spoticon"; + src: url("../vendor/spoticon.svg") format("svg"); /* ,url("https://glue-static.s3-external-3.amazonaws.com/fonts/spoticon_1d2b9f586650bafedbd44381e1efa36b.woff") format("woff"),url("https://glue-static.s3-external-3.amazonaws.com/fonts/spoticon_1d2b9f586650bafedbd44381e1efa36b.ttf") format("truetype"); */ + font-weight: normal; + font-style:normal; +} + + + + +#main-navigation, #main-area, #player { + position: absolute; + top: 0; + bottom: 0; +} + +#main-navigation, #player { + width: 20%; + background: #CCC; + padding: 1em; +} + +#main-navigation { + left: 0; +} + +#main-area { + left: 0; + right: 0; + padding-right: 20%; + padding-left: 20%; +} + +#player { + right: 0; +} + + + + + +[class^="spoticon-"]:before, [class*="spoticon-"]:before { + font-family:"spoticon"; + font-style:normal; + font-weight:normal; + -webkit-font-smoothing:antialiased; + -moz-osx-font-smoothing:grayscale; + line-height:inherit; + vertical-align:bottom; + display:inline-block; + text-decoration:inherit; +} + +.spoticon-add-to-playlist:before { content: "\f160"; font-size: 32px; } + diff --git a/webapp/index.html b/webapp/index.html new file mode 100755 index 0000000..2e98565 --- /dev/null +++ b/webapp/index.html @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + +
+ Foobear +
+ +
+ Foobear +
+ + + + + + diff --git a/webapp/robots.txt b/webapp/robots.txt new file mode 100755 index 0000000..ee2cc21 --- /dev/null +++ b/webapp/robots.txt @@ -0,0 +1,3 @@ +# robotstxt.org/ + +User-agent: * diff --git a/webapp/test.css b/webapp/test.css new file mode 100644 index 0000000..7ac5fba --- /dev/null +++ b/webapp/test.css @@ -0,0 +1,303 @@ +@font-face { + font-family: "spoticon"; + src: url("vendor/spoticon.svg") format("svg"); + font-weight: normal; + font-style:normal; +} + +li:before { + font-family:"spoticon"; + font-style:normal; + font-weight:normal; + -webkit-font-smoothing:antialiased; + -moz-osx-font-smoothing:grayscale; + line-height:inherit; + vertical-align:bottom; + display:inline-block; + text-decoration:inherit; +} + +li { list-style: none; display: inline-block; height: 32px; width: 32px; border: 1px solid #CCC; margin: 10px;} + +.font-100:before { content: "\f100"; font-size: 32px; } +.font-101:before { content: "\f101"; font-size: 32px; } +.font-102:before { content: "\f102"; font-size: 32px; } +.font-103:before { content: "\f103"; font-size: 32px; } +.font-104:before { content: "\f104"; font-size: 32px; } +.font-105:before { content: "\f105"; font-size: 32px; } +.font-106:before { content: "\f106"; font-size: 32px; } +.font-107:before { content: "\f107"; font-size: 32px; } +.font-108:before { content: "\f108"; font-size: 32px; } +.font-109:before { content: "\f109"; font-size: 32px; } +.font-10a:before { content: "\f10a"; font-size: 32px; } +.font-10b:before { content: "\f10b"; font-size: 32px; } +.font-10c:before { content: "\f10c"; font-size: 32px; } +.font-10d:before { content: "\f10d"; font-size: 32px; } +.font-10e:before { content: "\f10e"; font-size: 32px; } +.font-10f:before { content: "\f10f"; font-size: 32px; } +.font-110:before { content: "\f110"; font-size: 32px; } +.font-111:before { content: "\f111"; font-size: 32px; } +.font-112:before { content: "\f112"; font-size: 32px; } +.font-113:before { content: "\f113"; font-size: 32px; } +.font-114:before { content: "\f114"; font-size: 32px; } +.font-115:before { content: "\f115"; font-size: 32px; } +.font-116:before { content: "\f116"; font-size: 32px; } +.font-117:before { content: "\f117"; font-size: 32px; } +.font-11a:before { content: "\f11a"; font-size: 32px; } +.font-11b:before { content: "\f11b"; font-size: 32px; } +.font-11c:before { content: "\f11c"; font-size: 32px; } +.font-11d:before { content: "\f11d"; font-size: 32px; } +.font-11e:before { content: "\f11e"; font-size: 32px; } +.font-11f:before { content: "\f11f"; font-size: 32px; } +.font-120:before { content: "\f120"; font-size: 32px; } +.font-121:before { content: "\f121"; font-size: 32px; } +.font-122:before { content: "\f122"; font-size: 32px; } +.font-123:before { content: "\f123"; font-size: 32px; } +.font-124:before { content: "\f124"; font-size: 32px; } +.font-125:before { content: "\f125"; font-size: 32px; } +.font-126:before { content: "\f126"; font-size: 32px; } +.font-127:before { content: "\f127"; font-size: 32px; } +.font-128:before { content: "\f128"; font-size: 32px; } +.font-129:before { content: "\f129"; font-size: 32px; } +.font-12a:before { content: "\f12a"; font-size: 32px; } +.font-12b:before { content: "\f12b"; font-size: 32px; } +.font-12c:before { content: "\f12c"; font-size: 32px; } +.font-12d:before { content: "\f12d"; font-size: 32px; } +.font-12e:before { content: "\f12e"; font-size: 32px; } +.font-12f:before { content: "\f12f"; font-size: 32px; } +.font-130:before { content: "\f130"; font-size: 32px; } +.font-131:before { content: "\f131"; font-size: 32px; } +.font-132:before { content: "\f132"; font-size: 32px; } +.font-133:before { content: "\f133"; font-size: 32px; } +.font-134:before { content: "\f134"; font-size: 32px; } +.font-135:before { content: "\f135"; font-size: 32px; } +.font-136:before { content: "\f136"; font-size: 32px; } +.font-137:before { content: "\f137"; font-size: 32px; } +.font-138:before { content: "\f138"; font-size: 32px; } +.font-139:before { content: "\f139"; font-size: 32px; } +.font-13a:before { content: "\f13a"; font-size: 32px; } +.font-13b:before { content: "\f13b"; font-size: 32px; } +.font-13c:before { content: "\f13c"; font-size: 32px; } +.font-13d:before { content: "\f13d"; font-size: 32px; } +.font-13e:before { content: "\f13e"; font-size: 32px; } +.font-13f:before { content: "\f13f"; font-size: 32px; } +.font-140:before { content: "\f140"; font-size: 32px; } +.font-141:before { content: "\f141"; font-size: 32px; } +.font-142:before { content: "\f142"; font-size: 32px; } +.font-143:before { content: "\f143"; font-size: 32px; } +.font-144:before { content: "\f144"; font-size: 32px; } +.font-145:before { content: "\f145"; font-size: 32px; } +.font-146:before { content: "\f146"; font-size: 32px; } +.font-147:before { content: "\f147"; font-size: 32px; } +.font-148:before { content: "\f148"; font-size: 32px; } +.font-149:before { content: "\f149"; font-size: 32px; } +.font-14a:before { content: "\f14a"; font-size: 32px; } +.font-14b:before { content: "\f14b"; font-size: 32px; } +.font-14c:before { content: "\f14c"; font-size: 32px; } +.font-14d:before { content: "\f14d"; font-size: 32px; } +.font-14e:before { content: "\f14e"; font-size: 32px; } +.font-14f:before { content: "\f14f"; font-size: 32px; } +.font-150:before { content: "\f150"; font-size: 32px; } +.font-151:before { content: "\f151"; font-size: 32px; } +.font-152:before { content: "\f152"; font-size: 32px; } +.font-153:before { content: "\f153"; font-size: 32px; } +.font-154:before { content: "\f154"; font-size: 32px; } +.font-155:before { content: "\f155"; font-size: 32px; } +.font-156:before { content: "\f156"; font-size: 32px; } +.font-157:before { content: "\f157"; font-size: 32px; } +.font-158:before { content: "\f158"; font-size: 32px; } +.font-159:before { content: "\f159"; font-size: 32px; } +.font-15c:before { content: "\f15c"; font-size: 32px; } +.font-15d:before { content: "\f15d"; font-size: 32px; } +.font-15e:before { content: "\f15e"; font-size: 32px; } +.font-15f:before { content: "\f15f"; font-size: 32px; } +.font-160:before { content: "\f160"; font-size: 32px; } +.font-161:before { content: "\f161"; font-size: 32px; } +.font-164:before { content: "\f164"; font-size: 32px; } +.font-165:before { content: "\f165"; font-size: 32px; } +.font-166:before { content: "\f166"; font-size: 32px; } +.font-167:before { content: "\f167"; font-size: 32px; } +.font-168:before { content: "\f168"; font-size: 32px; } +.font-169:before { content: "\f169"; font-size: 32px; } +.font-16a:before { content: "\f16a"; font-size: 32px; } +.font-16b:before { content: "\f16b"; font-size: 32px; } +.font-16c:before { content: "\f16c"; font-size: 32px; } +.font-16d:before { content: "\f16d"; font-size: 32px; } +.font-16e:before { content: "\f16e"; font-size: 32px; } +.font-16f:before { content: "\f16f"; font-size: 32px; } +.font-170:before { content: "\f170"; font-size: 32px; } +.font-171:before { content: "\f171"; font-size: 32px; } +.font-172:before { content: "\f172"; font-size: 32px; } +.font-173:before { content: "\f173"; font-size: 32px; } +.font-174:before { content: "\f174"; font-size: 32px; } +.font-175:before { content: "\f175"; font-size: 32px; } +.font-176:before { content: "\f176"; font-size: 32px; } +.font-177:before { content: "\f177"; font-size: 32px; } +.font-178:before { content: "\f178"; font-size: 32px; } +.font-179:before { content: "\f179"; font-size: 32px; } +.font-17a:before { content: "\f17a"; font-size: 32px; } +.font-17b:before { content: "\f17b"; font-size: 32px; } +.font-17c:before { content: "\f17c"; font-size: 32px; } +.font-17d:before { content: "\f17d"; font-size: 32px; } +.font-17e:before { content: "\f17e"; font-size: 32px; } +.font-17f:before { content: "\f17f"; font-size: 32px; } +.font-180:before { content: "\f180"; font-size: 32px; } +.font-181:before { content: "\f181"; font-size: 32px; } +.font-182:before { content: "\f182"; font-size: 32px; } +.font-183:before { content: "\f183"; font-size: 32px; } +.font-184:before { content: "\f184"; font-size: 32px; } +.font-185:before { content: "\f185"; font-size: 32px; } +.font-186:before { content: "\f186"; font-size: 32px; } +.font-187:before { content: "\f187"; font-size: 32px; } +.font-188:before { content: "\f188"; font-size: 32px; } +.font-189:before { content: "\f189"; font-size: 32px; } +.font-18a:before { content: "\f18a"; font-size: 32px; } +.font-18b:before { content: "\f18b"; font-size: 32px; } +.font-18c:before { content: "\f18c"; font-size: 32px; } +.font-18d:before { content: "\f18d"; font-size: 32px; } +.font-18e:before { content: "\f18e"; font-size: 32px; } +.font-18f:before { content: "\f18f"; font-size: 32px; } +.font-190:before { content: "\f190"; font-size: 32px; } +.font-191:before { content: "\f191"; font-size: 32px; } +.font-192:before { content: "\f192"; font-size: 32px; } +.font-193:before { content: "\f193"; font-size: 32px; } +.font-194:before { content: "\f194"; font-size: 32px; } +.font-195:before { content: "\f195"; font-size: 32px; } +.font-196:before { content: "\f196"; font-size: 32px; } +.font-197:before { content: "\f197"; font-size: 32px; } +.font-198:before { content: "\f198"; font-size: 32px; } +.font-199:before { content: "\f199"; font-size: 32px; } +.font-19a:before { content: "\f19a"; font-size: 32px; } +.font-19b:before { content: "\f19b"; font-size: 32px; } +.font-19c:before { content: "\f19c"; font-size: 32px; } +.font-19d:before { content: "\f19d"; font-size: 32px; } +.font-19e:before { content: "\f19e"; font-size: 32px; } +.font-19f:before { content: "\f19f"; font-size: 32px; } +.font-1a0:before { content: "\f1a0"; font-size: 32px; } +.font-1a1:before { content: "\f1a1"; font-size: 32px; } +.font-1a2:before { content: "\f1a2"; font-size: 32px; } +.font-1a3:before { content: "\f1a3"; font-size: 32px; } +.font-1a4:before { content: "\f1a4"; font-size: 32px; } +.font-1a5:before { content: "\f1a5"; font-size: 32px; } +.font-1a6:before { content: "\f1a6"; font-size: 32px; } +.font-1a7:before { content: "\f1a7"; font-size: 32px; } +.font-1a8:before { content: "\f1a8"; font-size: 32px; } +.font-1a9:before { content: "\f1a9"; font-size: 32px; } +.font-1aa:before { content: "\f1aa"; font-size: 32px; } +.font-1ab:before { content: "\f1ab"; font-size: 32px; } +.font-1ac:before { content: "\f1ac"; font-size: 32px; } +.font-1ad:before { content: "\f1ad"; font-size: 32px; } +.font-1ae:before { content: "\f1ae"; font-size: 32px; } +.font-1af:before { content: "\f1af"; font-size: 32px; } +.font-1b0:before { content: "\f1b0"; font-size: 32px; } +.font-1b1:before { content: "\f1b1"; font-size: 32px; } +.font-1b2:before { content: "\f1b2"; font-size: 32px; } +.font-1b3:before { content: "\f1b3"; font-size: 32px; } +.font-1b4:before { content: "\f1b4"; font-size: 32px; } +.font-1b5:before { content: "\f1b5"; font-size: 32px; } +.font-1b6:before { content: "\f1b6"; font-size: 32px; } +.font-1b7:before { content: "\f1b7"; font-size: 32px; } +.font-1b8:before { content: "\f1b8"; font-size: 32px; } +.font-1b9:before { content: "\f1b9"; font-size: 32px; } +.font-1ba:before { content: "\f1ba"; font-size: 32px; } +.font-1bb:before { content: "\f1bb"; font-size: 32px; } +.font-1bc:before { content: "\f1bc"; font-size: 32px; } +.font-1bd:before { content: "\f1bd"; font-size: 32px; } +.font-1be:before { content: "\f1be"; font-size: 32px; } +.font-1bf:before { content: "\f1bf"; font-size: 32px; } +.font-1c0:before { content: "\f1c0"; font-size: 32px; } +.font-1c1:before { content: "\f1c1"; font-size: 32px; } +.font-1c2:before { content: "\f1c2"; font-size: 32px; } +.font-1c3:before { content: "\f1c3"; font-size: 32px; } +.font-1c4:before { content: "\f1c4"; font-size: 32px; } +.font-1c5:before { content: "\f1c5"; font-size: 32px; } +.font-1c6:before { content: "\f1c6"; font-size: 32px; } +.font-1c7:before { content: "\f1c7"; font-size: 32px; } +.font-1c8:before { content: "\f1c8"; font-size: 32px; } +.font-1c9:before { content: "\f1c9"; font-size: 32px; } +.font-1ca:before { content: "\f1ca"; font-size: 32px; } +.font-1cb:before { content: "\f1cb"; font-size: 32px; } +.font-1cc:before { content: "\f1cc"; font-size: 32px; } +.font-1cd:before { content: "\f1cd"; font-size: 32px; } +.font-1ce:before { content: "\f1ce"; font-size: 32px; } +.font-1cf:before { content: "\f1cf"; font-size: 32px; } +.font-1d0:before { content: "\f1d0"; font-size: 32px; } +.font-1d1:before { content: "\f1d1"; font-size: 32px; } +.font-1d2:before { content: "\f1d2"; font-size: 32px; } +.font-1d3:before { content: "\f1d3"; font-size: 32px; } +.font-1d4:before { content: "\f1d4"; font-size: 32px; } +.font-1d5:before { content: "\f1d5"; font-size: 32px; } +.font-1d6:before { content: "\f1d6"; font-size: 32px; } +.font-1d7:before { content: "\f1d7"; font-size: 32px; } +.font-1d8:before { content: "\f1d8"; font-size: 32px; } +.font-1d9:before { content: "\f1d9"; font-size: 32px; } +.font-1da:before { content: "\f1da"; font-size: 32px; } +.font-1db:before { content: "\f1db"; font-size: 32px; } +.font-1dc:before { content: "\f1dc"; font-size: 32px; } +.font-1dd:before { content: "\f1dd"; font-size: 32px; } +.font-1de:before { content: "\f1de"; font-size: 32px; } +.font-1df:before { content: "\f1df"; font-size: 32px; } +.font-1e0:before { content: "\f1e0"; font-size: 32px; } +.font-1e1:before { content: "\f1e1"; font-size: 32px; } +.font-1e2:before { content: "\f1e2"; font-size: 32px; } +.font-1e3:before { content: "\f1e3"; font-size: 32px; } +.font-1e4:before { content: "\f1e4"; font-size: 32px; } +.font-1e5:before { content: "\f1e5"; font-size: 32px; } +.font-1e6:before { content: "\f1e6"; font-size: 32px; } +.font-1e7:before { content: "\f1e7"; font-size: 32px; } +.font-1e8:before { content: "\f1e8"; font-size: 32px; } +.font-1e9:before { content: "\f1e9"; font-size: 32px; } +.font-1ea:before { content: "\f1ea"; font-size: 32px; } +.font-1eb:before { content: "\f1eb"; font-size: 32px; } +.font-1ec:before { content: "\f1ec"; font-size: 32px; } +.font-1ed:before { content: "\f1ed"; font-size: 32px; } +.font-1ee:before { content: "\f1ee"; font-size: 32px; } +.font-1ef:before { content: "\f1ef"; font-size: 32px; } +.font-1f0:before { content: "\f1f0"; font-size: 32px; } +.font-1f1:before { content: "\f1f1"; font-size: 32px; } +.font-1f2:before { content: "\f1f2"; font-size: 32px; } +.font-1f3:before { content: "\f1f3"; font-size: 32px; } +.font-1f4:before { content: "\f1f4"; font-size: 32px; } +.font-1f5:before { content: "\f1f5"; font-size: 32px; } +.font-1f6:before { content: "\f1f6"; font-size: 32px; } +.font-1f7:before { content: "\f1f7"; font-size: 32px; } +.font-1f8:before { content: "\f1f8"; font-size: 32px; } +.font-1f9:before { content: "\f1f9"; font-size: 32px; } +.font-1fa:before { content: "\f1fa"; font-size: 32px; } +.font-1fb:before { content: "\f1fb"; font-size: 32px; } +.font-1fc:before { content: "\f1fc"; font-size: 32px; } +.font-1fd:before { content: "\f1fd"; font-size: 32px; } +.font-1fe:before { content: "\f1fe"; font-size: 32px; } +.font-1ff:before { content: "\f1ff"; font-size: 32px; } +.font-200:before { content: "\f200"; font-size: 32px; } +.font-201:before { content: "\f201"; font-size: 32px; } +.font-202:before { content: "\f202"; font-size: 32px; } +.font-203:before { content: "\f203"; font-size: 32px; } +.font-204:before { content: "\f204"; font-size: 32px; } +.font-205:before { content: "\f205"; font-size: 32px; } +.font-206:before { content: "\f206"; font-size: 32px; } +.font-207:before { content: "\f207"; font-size: 32px; } +.font-208:before { content: "\f208"; font-size: 32px; } +.font-209:before { content: "\f209"; font-size: 32px; } +.font-20a:before { content: "\f20a"; font-size: 32px; } +.font-20b:before { content: "\f20b"; font-size: 32px; } +.font-20c:before { content: "\f20c"; font-size: 32px; } +.font-20d:before { content: "\f20d"; font-size: 32px; } +.font-20e:before { content: "\f20e"; font-size: 32px; } +.font-20f:before { content: "\f20f"; font-size: 32px; } +.font-210:before { content: "\f210"; font-size: 32px; } +.font-211:before { content: "\f211"; font-size: 32px; } +.font-212:before { content: "\f212"; font-size: 32px; } +.font-213:before { content: "\f213"; font-size: 32px; } +.font-214:before { content: "\f214"; font-size: 32px; } +.font-215:before { content: "\f215"; font-size: 32px; } +.font-216:before { content: "\f216"; font-size: 32px; } +.font-217:before { content: "\f217"; font-size: 32px; } +.font-218:before { content: "\f218"; font-size: 32px; } +.font-219:before { content: "\f219"; font-size: 32px; } +.font-21a:before { content: "\f21a"; font-size: 32px; } +.font-21b:before { content: "\f21b"; font-size: 32px; } +.font-21c:before { content: "\f21c"; font-size: 32px; } +.font-21d:before { content: "\f21d"; font-size: 32px; } +.font-21e:before { content: "\f21e"; font-size: 32px; } +.font-21f:before { content: "\f21f"; font-size: 32px; } diff --git a/webapp/test.html b/webapp/test.html new file mode 100644 index 0000000..c82f51c --- /dev/null +++ b/webapp/test.html @@ -0,0 +1,145 @@ + + + + + + + + + + + + + + + diff --git a/webapp/vendor/backbone-1.1.2.js b/webapp/vendor/backbone-1.1.2.js new file mode 100644 index 0000000..24a550a --- /dev/null +++ b/webapp/vendor/backbone-1.1.2.js @@ -0,0 +1,1608 @@ +// Backbone.js 1.1.2 + +// (c) 2010-2014 Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors +// Backbone may be freely distributed under the MIT license. +// For all details and documentation: +// http://backbonejs.org + +(function(root, factory) { + + // Set up Backbone appropriately for the environment. Start with AMD. + if (typeof define === 'function' && define.amd) { + define(['underscore', 'jquery', 'exports'], function(_, $, exports) { + // Export global even in AMD case in case this script is loaded with + // others that may still expect a global Backbone. + root.Backbone = factory(root, exports, _, $); + }); + + // Next for Node.js or CommonJS. jQuery may not be needed as a module. + } else if (typeof exports !== 'undefined') { + var _ = require('underscore'); + factory(root, exports, _); + + // Finally, as a browser global. + } else { + root.Backbone = factory(root, {}, root._, (root.jQuery || root.Zepto || root.ender || root.$)); + } + +}(this, function(root, Backbone, _, $) { + + // Initial Setup + // ------------- + + // Save the previous value of the `Backbone` variable, so that it can be + // restored later on, if `noConflict` is used. + var previousBackbone = root.Backbone; + + // Create local references to array methods we'll want to use later. + var array = []; + var push = array.push; + var slice = array.slice; + var splice = array.splice; + + // Current version of the library. Keep in sync with `package.json`. + Backbone.VERSION = '1.1.2'; + + // For Backbone's purposes, jQuery, Zepto, Ender, or My Library (kidding) owns + // the `$` variable. + Backbone.$ = $; + + // Runs Backbone.js in *noConflict* mode, returning the `Backbone` variable + // to its previous owner. Returns a reference to this Backbone object. + Backbone.noConflict = function() { + root.Backbone = previousBackbone; + return this; + }; + + // Turn on `emulateHTTP` to support legacy HTTP servers. Setting this option + // will fake `"PATCH"`, `"PUT"` and `"DELETE"` requests via the `_method` parameter and + // set a `X-Http-Method-Override` header. + Backbone.emulateHTTP = false; + + // Turn on `emulateJSON` to support legacy servers that can't deal with direct + // `application/json` requests ... will encode the body as + // `application/x-www-form-urlencoded` instead and will send the model in a + // form param named `model`. + Backbone.emulateJSON = false; + + // Backbone.Events + // --------------- + + // A module that can be mixed in to *any object* in order to provide it with + // custom events. You may bind with `on` or remove with `off` callback + // functions to an event; `trigger`-ing an event fires all callbacks in + // succession. + // + // var object = {}; + // _.extend(object, Backbone.Events); + // object.on('expand', function(){ alert('expanded'); }); + // object.trigger('expand'); + // + var Events = Backbone.Events = { + + // Bind an event to a `callback` function. Passing `"all"` will bind + // the callback to all events fired. + on: function(name, callback, context) { + if (!eventsApi(this, 'on', name, [callback, context]) || !callback) return this; + this._events || (this._events = {}); + var events = this._events[name] || (this._events[name] = []); + events.push({callback: callback, context: context, ctx: context || this}); + return this; + }, + + // Bind an event to only be triggered a single time. After the first time + // the callback is invoked, it will be removed. + once: function(name, callback, context) { + if (!eventsApi(this, 'once', name, [callback, context]) || !callback) return this; + var self = this; + var once = _.once(function() { + self.off(name, once); + callback.apply(this, arguments); + }); + once._callback = callback; + return this.on(name, once, context); + }, + + // Remove one or many callbacks. If `context` is null, removes all + // callbacks with that function. If `callback` is null, removes all + // callbacks for the event. If `name` is null, removes all bound + // callbacks for all events. + off: function(name, callback, context) { + var retain, ev, events, names, i, l, j, k; + if (!this._events || !eventsApi(this, 'off', name, [callback, context])) return this; + if (!name && !callback && !context) { + this._events = void 0; + return this; + } + names = name ? [name] : _.keys(this._events); + for (i = 0, l = names.length; i < l; i++) { + name = names[i]; + if (events = this._events[name]) { + this._events[name] = retain = []; + if (callback || context) { + for (j = 0, k = events.length; j < k; j++) { + ev = events[j]; + if ((callback && callback !== ev.callback && callback !== ev.callback._callback) || + (context && context !== ev.context)) { + retain.push(ev); + } + } + } + if (!retain.length) delete this._events[name]; + } + } + + return this; + }, + + // Trigger one or many events, firing all bound callbacks. Callbacks are + // passed the same arguments as `trigger` is, apart from the event name + // (unless you're listening on `"all"`, which will cause your callback to + // receive the true name of the event as the first argument). + trigger: function(name) { + if (!this._events) return this; + var args = slice.call(arguments, 1); + if (!eventsApi(this, 'trigger', name, args)) return this; + var events = this._events[name]; + var allEvents = this._events.all; + if (events) triggerEvents(events, args); + if (allEvents) triggerEvents(allEvents, arguments); + return this; + }, + + // Tell this object to stop listening to either specific events ... or + // to every object it's currently listening to. + stopListening: function(obj, name, callback) { + var listeningTo = this._listeningTo; + if (!listeningTo) return this; + var remove = !name && !callback; + if (!callback && typeof name === 'object') callback = this; + if (obj) (listeningTo = {})[obj._listenId] = obj; + for (var id in listeningTo) { + obj = listeningTo[id]; + obj.off(name, callback, this); + if (remove || _.isEmpty(obj._events)) delete this._listeningTo[id]; + } + return this; + } + + }; + + // Regular expression used to split event strings. + var eventSplitter = /\s+/; + + // Implement fancy features of the Events API such as multiple event + // names `"change blur"` and jQuery-style event maps `{change: action}` + // in terms of the existing API. + var eventsApi = function(obj, action, name, rest) { + if (!name) return true; + + // Handle event maps. + if (typeof name === 'object') { + for (var key in name) { + obj[action].apply(obj, [key, name[key]].concat(rest)); + } + return false; + } + + // Handle space separated event names. + if (eventSplitter.test(name)) { + var names = name.split(eventSplitter); + for (var i = 0, l = names.length; i < l; i++) { + obj[action].apply(obj, [names[i]].concat(rest)); + } + return false; + } + + return true; + }; + + // A difficult-to-believe, but optimized internal dispatch function for + // triggering events. Tries to keep the usual cases speedy (most internal + // Backbone events have 3 arguments). + var triggerEvents = function(events, args) { + var ev, i = -1, l = events.length, a1 = args[0], a2 = args[1], a3 = args[2]; + switch (args.length) { + case 0: while (++i < l) (ev = events[i]).callback.call(ev.ctx); return; + case 1: while (++i < l) (ev = events[i]).callback.call(ev.ctx, a1); return; + case 2: while (++i < l) (ev = events[i]).callback.call(ev.ctx, a1, a2); return; + case 3: while (++i < l) (ev = events[i]).callback.call(ev.ctx, a1, a2, a3); return; + default: while (++i < l) (ev = events[i]).callback.apply(ev.ctx, args); return; + } + }; + + var listenMethods = {listenTo: 'on', listenToOnce: 'once'}; + + // Inversion-of-control versions of `on` and `once`. Tell *this* object to + // listen to an event in another object ... keeping track of what it's + // listening to. + _.each(listenMethods, function(implementation, method) { + Events[method] = function(obj, name, callback) { + var listeningTo = this._listeningTo || (this._listeningTo = {}); + var id = obj._listenId || (obj._listenId = _.uniqueId('l')); + listeningTo[id] = obj; + if (!callback && typeof name === 'object') callback = this; + obj[implementation](name, callback, this); + return this; + }; + }); + + // Aliases for backwards compatibility. + Events.bind = Events.on; + Events.unbind = Events.off; + + // Allow the `Backbone` object to serve as a global event bus, for folks who + // want global "pubsub" in a convenient place. + _.extend(Backbone, Events); + + // Backbone.Model + // -------------- + + // Backbone **Models** are the basic data object in the framework -- + // frequently representing a row in a table in a database on your server. + // A discrete chunk of data and a bunch of useful, related methods for + // performing computations and transformations on that data. + + // Create a new model with the specified attributes. A client id (`cid`) + // is automatically generated and assigned for you. + var Model = Backbone.Model = function(attributes, options) { + var attrs = attributes || {}; + options || (options = {}); + this.cid = _.uniqueId('c'); + this.attributes = {}; + if (options.collection) this.collection = options.collection; + if (options.parse) attrs = this.parse(attrs, options) || {}; + attrs = _.defaults({}, attrs, _.result(this, 'defaults')); + this.set(attrs, options); + this.changed = {}; + this.initialize.apply(this, arguments); + }; + + // Attach all inheritable methods to the Model prototype. + _.extend(Model.prototype, Events, { + + // A hash of attributes whose current and previous value differ. + changed: null, + + // The value returned during the last failed validation. + validationError: null, + + // The default name for the JSON `id` attribute is `"id"`. MongoDB and + // CouchDB users may want to set this to `"_id"`. + idAttribute: 'id', + + // Initialize is an empty function by default. Override it with your own + // initialization logic. + initialize: function(){}, + + // Return a copy of the model's `attributes` object. + toJSON: function(options) { + return _.clone(this.attributes); + }, + + // Proxy `Backbone.sync` by default -- but override this if you need + // custom syncing semantics for *this* particular model. + sync: function() { + return Backbone.sync.apply(this, arguments); + }, + + // Get the value of an attribute. + get: function(attr) { + return this.attributes[attr]; + }, + + // Get the HTML-escaped value of an attribute. + escape: function(attr) { + return _.escape(this.get(attr)); + }, + + // Returns `true` if the attribute contains a value that is not null + // or undefined. + has: function(attr) { + return this.get(attr) != null; + }, + + // Set a hash of model attributes on the object, firing `"change"`. This is + // the core primitive operation of a model, updating the data and notifying + // anyone who needs to know about the change in state. The heart of the beast. + set: function(key, val, options) { + var attr, attrs, unset, changes, silent, changing, prev, current; + if (key == null) return this; + + // Handle both `"key", value` and `{key: value}` -style arguments. + if (typeof key === 'object') { + attrs = key; + options = val; + } else { + (attrs = {})[key] = val; + } + + options || (options = {}); + + // Run validation. + if (!this._validate(attrs, options)) return false; + + // Extract attributes and options. + unset = options.unset; + silent = options.silent; + changes = []; + changing = this._changing; + this._changing = true; + + if (!changing) { + this._previousAttributes = _.clone(this.attributes); + this.changed = {}; + } + current = this.attributes, prev = this._previousAttributes; + + // Check for changes of `id`. + if (this.idAttribute in attrs) this.id = attrs[this.idAttribute]; + + // For each `set` attribute, update or delete the current value. + for (attr in attrs) { + val = attrs[attr]; + if (!_.isEqual(current[attr], val)) changes.push(attr); + if (!_.isEqual(prev[attr], val)) { + this.changed[attr] = val; + } else { + delete this.changed[attr]; + } + unset ? delete current[attr] : current[attr] = val; + } + + // Trigger all relevant attribute changes. + if (!silent) { + if (changes.length) this._pending = options; + for (var i = 0, l = changes.length; i < l; i++) { + this.trigger('change:' + changes[i], this, current[changes[i]], options); + } + } + + // You might be wondering why there's a `while` loop here. Changes can + // be recursively nested within `"change"` events. + if (changing) return this; + if (!silent) { + while (this._pending) { + options = this._pending; + this._pending = false; + this.trigger('change', this, options); + } + } + this._pending = false; + this._changing = false; + return this; + }, + + // Remove an attribute from the model, firing `"change"`. `unset` is a noop + // if the attribute doesn't exist. + unset: function(attr, options) { + return this.set(attr, void 0, _.extend({}, options, {unset: true})); + }, + + // Clear all attributes on the model, firing `"change"`. + clear: function(options) { + var attrs = {}; + for (var key in this.attributes) attrs[key] = void 0; + return this.set(attrs, _.extend({}, options, {unset: true})); + }, + + // Determine if the model has changed since the last `"change"` event. + // If you specify an attribute name, determine if that attribute has changed. + hasChanged: function(attr) { + if (attr == null) return !_.isEmpty(this.changed); + return _.has(this.changed, attr); + }, + + // Return an object containing all the attributes that have changed, or + // false if there are no changed attributes. Useful for determining what + // parts of a view need to be updated and/or what attributes need to be + // persisted to the server. Unset attributes will be set to undefined. + // You can also pass an attributes object to diff against the model, + // determining if there *would be* a change. + changedAttributes: function(diff) { + if (!diff) return this.hasChanged() ? _.clone(this.changed) : false; + var val, changed = false; + var old = this._changing ? this._previousAttributes : this.attributes; + for (var attr in diff) { + if (_.isEqual(old[attr], (val = diff[attr]))) continue; + (changed || (changed = {}))[attr] = val; + } + return changed; + }, + + // Get the previous value of an attribute, recorded at the time the last + // `"change"` event was fired. + previous: function(attr) { + if (attr == null || !this._previousAttributes) return null; + return this._previousAttributes[attr]; + }, + + // Get all of the attributes of the model at the time of the previous + // `"change"` event. + previousAttributes: function() { + return _.clone(this._previousAttributes); + }, + + // Fetch the model from the server. If the server's representation of the + // model differs from its current attributes, they will be overridden, + // triggering a `"change"` event. + fetch: function(options) { + options = options ? _.clone(options) : {}; + if (options.parse === void 0) options.parse = true; + var model = this; + var success = options.success; + options.success = function(resp) { + if (!model.set(model.parse(resp, options), options)) return false; + if (success) success(model, resp, options); + model.trigger('sync', model, resp, options); + }; + wrapError(this, options); + return this.sync('read', this, options); + }, + + // Set a hash of model attributes, and sync the model to the server. + // If the server returns an attributes hash that differs, the model's + // state will be `set` again. + save: function(key, val, options) { + var attrs, method, xhr, attributes = this.attributes; + + // Handle both `"key", value` and `{key: value}` -style arguments. + if (key == null || typeof key === 'object') { + attrs = key; + options = val; + } else { + (attrs = {})[key] = val; + } + + options = _.extend({validate: true}, options); + + // If we're not waiting and attributes exist, save acts as + // `set(attr).save(null, opts)` with validation. Otherwise, check if + // the model will be valid when the attributes, if any, are set. + if (attrs && !options.wait) { + if (!this.set(attrs, options)) return false; + } else { + if (!this._validate(attrs, options)) return false; + } + + // Set temporary attributes if `{wait: true}`. + if (attrs && options.wait) { + this.attributes = _.extend({}, attributes, attrs); + } + + // After a successful server-side save, the client is (optionally) + // updated with the server-side state. + if (options.parse === void 0) options.parse = true; + var model = this; + var success = options.success; + options.success = function(resp) { + // Ensure attributes are restored during synchronous saves. + model.attributes = attributes; + var serverAttrs = model.parse(resp, options); + if (options.wait) serverAttrs = _.extend(attrs || {}, serverAttrs); + if (_.isObject(serverAttrs) && !model.set(serverAttrs, options)) { + return false; + } + if (success) success(model, resp, options); + model.trigger('sync', model, resp, options); + }; + wrapError(this, options); + + method = this.isNew() ? 'create' : (options.patch ? 'patch' : 'update'); + if (method === 'patch') options.attrs = attrs; + xhr = this.sync(method, this, options); + + // Restore attributes. + if (attrs && options.wait) this.attributes = attributes; + + return xhr; + }, + + // Destroy this model on the server if it was already persisted. + // Optimistically removes the model from its collection, if it has one. + // If `wait: true` is passed, waits for the server to respond before removal. + destroy: function(options) { + options = options ? _.clone(options) : {}; + var model = this; + var success = options.success; + + var destroy = function() { + model.trigger('destroy', model, model.collection, options); + }; + + options.success = function(resp) { + if (options.wait || model.isNew()) destroy(); + if (success) success(model, resp, options); + if (!model.isNew()) model.trigger('sync', model, resp, options); + }; + + if (this.isNew()) { + options.success(); + return false; + } + wrapError(this, options); + + var xhr = this.sync('delete', this, options); + if (!options.wait) destroy(); + return xhr; + }, + + // Default URL for the model's representation on the server -- if you're + // using Backbone's restful methods, override this to change the endpoint + // that will be called. + url: function() { + var base = + _.result(this, 'urlRoot') || + _.result(this.collection, 'url') || + urlError(); + if (this.isNew()) return base; + return base.replace(/([^\/])$/, '$1/') + encodeURIComponent(this.id); + }, + + // **parse** converts a response into the hash of attributes to be `set` on + // the model. The default implementation is just to pass the response along. + parse: function(resp, options) { + return resp; + }, + + // Create a new model with identical attributes to this one. + clone: function() { + return new this.constructor(this.attributes); + }, + + // A model is new if it has never been saved to the server, and lacks an id. + isNew: function() { + return !this.has(this.idAttribute); + }, + + // Check if the model is currently in a valid state. + isValid: function(options) { + return this._validate({}, _.extend(options || {}, { validate: true })); + }, + + // Run validation against the next complete set of model attributes, + // returning `true` if all is well. Otherwise, fire an `"invalid"` event. + _validate: function(attrs, options) { + if (!options.validate || !this.validate) return true; + attrs = _.extend({}, this.attributes, attrs); + var error = this.validationError = this.validate(attrs, options) || null; + if (!error) return true; + this.trigger('invalid', this, error, _.extend(options, {validationError: error})); + return false; + } + + }); + + // Underscore methods that we want to implement on the Model. + var modelMethods = ['keys', 'values', 'pairs', 'invert', 'pick', 'omit']; + + // Mix in each Underscore method as a proxy to `Model#attributes`. + _.each(modelMethods, function(method) { + Model.prototype[method] = function() { + var args = slice.call(arguments); + args.unshift(this.attributes); + return _[method].apply(_, args); + }; + }); + + // Backbone.Collection + // ------------------- + + // If models tend to represent a single row of data, a Backbone Collection is + // more analagous to a table full of data ... or a small slice or page of that + // table, or a collection of rows that belong together for a particular reason + // -- all of the messages in this particular folder, all of the documents + // belonging to this particular author, and so on. Collections maintain + // indexes of their models, both in order, and for lookup by `id`. + + // Create a new **Collection**, perhaps to contain a specific type of `model`. + // If a `comparator` is specified, the Collection will maintain + // its models in sort order, as they're added and removed. + var Collection = Backbone.Collection = function(models, options) { + options || (options = {}); + if (options.model) this.model = options.model; + if (options.comparator !== void 0) this.comparator = options.comparator; + this._reset(); + this.initialize.apply(this, arguments); + if (models) this.reset(models, _.extend({silent: true}, options)); + }; + + // Default options for `Collection#set`. + var setOptions = {add: true, remove: true, merge: true}; + var addOptions = {add: true, remove: false}; + + // Define the Collection's inheritable methods. + _.extend(Collection.prototype, Events, { + + // The default model for a collection is just a **Backbone.Model**. + // This should be overridden in most cases. + model: Model, + + // Initialize is an empty function by default. Override it with your own + // initialization logic. + initialize: function(){}, + + // The JSON representation of a Collection is an array of the + // models' attributes. + toJSON: function(options) { + return this.map(function(model){ return model.toJSON(options); }); + }, + + // Proxy `Backbone.sync` by default. + sync: function() { + return Backbone.sync.apply(this, arguments); + }, + + // Add a model, or list of models to the set. + add: function(models, options) { + return this.set(models, _.extend({merge: false}, options, addOptions)); + }, + + // Remove a model, or a list of models from the set. + remove: function(models, options) { + var singular = !_.isArray(models); + models = singular ? [models] : _.clone(models); + options || (options = {}); + var i, l, index, model; + for (i = 0, l = models.length; i < l; i++) { + model = models[i] = this.get(models[i]); + if (!model) continue; + delete this._byId[model.id]; + delete this._byId[model.cid]; + index = this.indexOf(model); + this.models.splice(index, 1); + this.length--; + if (!options.silent) { + options.index = index; + model.trigger('remove', model, this, options); + } + this._removeReference(model, options); + } + return singular ? models[0] : models; + }, + + // Update a collection by `set`-ing a new list of models, adding new ones, + // removing models that are no longer present, and merging models that + // already exist in the collection, as necessary. Similar to **Model#set**, + // the core operation for updating the data contained by the collection. + set: function(models, options) { + options = _.defaults({}, options, setOptions); + if (options.parse) models = this.parse(models, options); + var singular = !_.isArray(models); + models = singular ? (models ? [models] : []) : _.clone(models); + var i, l, id, model, attrs, existing, sort; + var at = options.at; + var targetModel = this.model; + var sortable = this.comparator && (at == null) && options.sort !== false; + var sortAttr = _.isString(this.comparator) ? this.comparator : null; + var toAdd = [], toRemove = [], modelMap = {}; + var add = options.add, merge = options.merge, remove = options.remove; + var order = !sortable && add && remove ? [] : false; + + // Turn bare objects into model references, and prevent invalid models + // from being added. + for (i = 0, l = models.length; i < l; i++) { + attrs = models[i] || {}; + if (attrs instanceof Model) { + id = model = attrs; + } else { + id = attrs[targetModel.prototype.idAttribute || 'id']; + } + + // If a duplicate is found, prevent it from being added and + // optionally merge it into the existing model. + if (existing = this.get(id)) { + if (remove) modelMap[existing.cid] = true; + if (merge) { + attrs = attrs === model ? model.attributes : attrs; + if (options.parse) attrs = existing.parse(attrs, options); + existing.set(attrs, options); + if (sortable && !sort && existing.hasChanged(sortAttr)) sort = true; + } + models[i] = existing; + + // If this is a new, valid model, push it to the `toAdd` list. + } else if (add) { + model = models[i] = this._prepareModel(attrs, options); + if (!model) continue; + toAdd.push(model); + this._addReference(model, options); + } + + // Do not add multiple models with the same `id`. + model = existing || model; + if (order && (model.isNew() || !modelMap[model.id])) order.push(model); + modelMap[model.id] = true; + } + + // Remove nonexistent models if appropriate. + if (remove) { + for (i = 0, l = this.length; i < l; ++i) { + if (!modelMap[(model = this.models[i]).cid]) toRemove.push(model); + } + if (toRemove.length) this.remove(toRemove, options); + } + + // See if sorting is needed, update `length` and splice in new models. + if (toAdd.length || (order && order.length)) { + if (sortable) sort = true; + this.length += toAdd.length; + if (at != null) { + for (i = 0, l = toAdd.length; i < l; i++) { + this.models.splice(at + i, 0, toAdd[i]); + } + } else { + if (order) this.models.length = 0; + var orderedModels = order || toAdd; + for (i = 0, l = orderedModels.length; i < l; i++) { + this.models.push(orderedModels[i]); + } + } + } + + // Silently sort the collection if appropriate. + if (sort) this.sort({silent: true}); + + // Unless silenced, it's time to fire all appropriate add/sort events. + if (!options.silent) { + for (i = 0, l = toAdd.length; i < l; i++) { + (model = toAdd[i]).trigger('add', model, this, options); + } + if (sort || (order && order.length)) this.trigger('sort', this, options); + } + + // Return the added (or merged) model (or models). + return singular ? models[0] : models; + }, + + // When you have more items than you want to add or remove individually, + // you can reset the entire set with a new list of models, without firing + // any granular `add` or `remove` events. Fires `reset` when finished. + // Useful for bulk operations and optimizations. + reset: function(models, options) { + options || (options = {}); + for (var i = 0, l = this.models.length; i < l; i++) { + this._removeReference(this.models[i], options); + } + options.previousModels = this.models; + this._reset(); + models = this.add(models, _.extend({silent: true}, options)); + if (!options.silent) this.trigger('reset', this, options); + return models; + }, + + // Add a model to the end of the collection. + push: function(model, options) { + return this.add(model, _.extend({at: this.length}, options)); + }, + + // Remove a model from the end of the collection. + pop: function(options) { + var model = this.at(this.length - 1); + this.remove(model, options); + return model; + }, + + // Add a model to the beginning of the collection. + unshift: function(model, options) { + return this.add(model, _.extend({at: 0}, options)); + }, + + // Remove a model from the beginning of the collection. + shift: function(options) { + var model = this.at(0); + this.remove(model, options); + return model; + }, + + // Slice out a sub-array of models from the collection. + slice: function() { + return slice.apply(this.models, arguments); + }, + + // Get a model from the set by id. + get: function(obj) { + if (obj == null) return void 0; + return this._byId[obj] || this._byId[obj.id] || this._byId[obj.cid]; + }, + + // Get the model at the given index. + at: function(index) { + return this.models[index]; + }, + + // Return models with matching attributes. Useful for simple cases of + // `filter`. + where: function(attrs, first) { + if (_.isEmpty(attrs)) return first ? void 0 : []; + return this[first ? 'find' : 'filter'](function(model) { + for (var key in attrs) { + if (attrs[key] !== model.get(key)) return false; + } + return true; + }); + }, + + // Return the first model with matching attributes. Useful for simple cases + // of `find`. + findWhere: function(attrs) { + return this.where(attrs, true); + }, + + // Force the collection to re-sort itself. You don't need to call this under + // normal circumstances, as the set will maintain sort order as each item + // is added. + sort: function(options) { + if (!this.comparator) throw new Error('Cannot sort a set without a comparator'); + options || (options = {}); + + // Run sort based on type of `comparator`. + if (_.isString(this.comparator) || this.comparator.length === 1) { + this.models = this.sortBy(this.comparator, this); + } else { + this.models.sort(_.bind(this.comparator, this)); + } + + if (!options.silent) this.trigger('sort', this, options); + return this; + }, + + // Pluck an attribute from each model in the collection. + pluck: function(attr) { + return _.invoke(this.models, 'get', attr); + }, + + // Fetch the default set of models for this collection, resetting the + // collection when they arrive. If `reset: true` is passed, the response + // data will be passed through the `reset` method instead of `set`. + fetch: function(options) { + options = options ? _.clone(options) : {}; + if (options.parse === void 0) options.parse = true; + var success = options.success; + var collection = this; + options.success = function(resp) { + var method = options.reset ? 'reset' : 'set'; + collection[method](resp, options); + if (success) success(collection, resp, options); + collection.trigger('sync', collection, resp, options); + }; + wrapError(this, options); + return this.sync('read', this, options); + }, + + // Create a new instance of a model in this collection. Add the model to the + // collection immediately, unless `wait: true` is passed, in which case we + // wait for the server to agree. + create: function(model, options) { + options = options ? _.clone(options) : {}; + if (!(model = this._prepareModel(model, options))) return false; + if (!options.wait) this.add(model, options); + var collection = this; + var success = options.success; + options.success = function(model, resp) { + if (options.wait) collection.add(model, options); + if (success) success(model, resp, options); + }; + model.save(null, options); + return model; + }, + + // **parse** converts a response into a list of models to be added to the + // collection. The default implementation is just to pass it through. + parse: function(resp, options) { + return resp; + }, + + // Create a new collection with an identical list of models as this one. + clone: function() { + return new this.constructor(this.models); + }, + + // Private method to reset all internal state. Called when the collection + // is first initialized or reset. + _reset: function() { + this.length = 0; + this.models = []; + this._byId = {}; + }, + + // Prepare a hash of attributes (or other model) to be added to this + // collection. + _prepareModel: function(attrs, options) { + if (attrs instanceof Model) return attrs; + options = options ? _.clone(options) : {}; + options.collection = this; + var model = new this.model(attrs, options); + if (!model.validationError) return model; + this.trigger('invalid', this, model.validationError, options); + return false; + }, + + // Internal method to create a model's ties to a collection. + _addReference: function(model, options) { + this._byId[model.cid] = model; + if (model.id != null) this._byId[model.id] = model; + if (!model.collection) model.collection = this; + model.on('all', this._onModelEvent, this); + }, + + // Internal method to sever a model's ties to a collection. + _removeReference: function(model, options) { + if (this === model.collection) delete model.collection; + model.off('all', this._onModelEvent, this); + }, + + // Internal method called every time a model in the set fires an event. + // Sets need to update their indexes when models change ids. All other + // events simply proxy through. "add" and "remove" events that originate + // in other collections are ignored. + _onModelEvent: function(event, model, collection, options) { + if ((event === 'add' || event === 'remove') && collection !== this) return; + if (event === 'destroy') this.remove(model, options); + if (model && event === 'change:' + model.idAttribute) { + delete this._byId[model.previous(model.idAttribute)]; + if (model.id != null) this._byId[model.id] = model; + } + this.trigger.apply(this, arguments); + } + + }); + + // Underscore methods that we want to implement on the Collection. + // 90% of the core usefulness of Backbone Collections is actually implemented + // right here: + var methods = ['forEach', 'each', 'map', 'collect', 'reduce', 'foldl', + 'inject', 'reduceRight', 'foldr', 'find', 'detect', 'filter', 'select', + 'reject', 'every', 'all', 'some', 'any', 'include', 'contains', 'invoke', + 'max', 'min', 'toArray', 'size', 'first', 'head', 'take', 'initial', 'rest', + 'tail', 'drop', 'last', 'without', 'difference', 'indexOf', 'shuffle', + 'lastIndexOf', 'isEmpty', 'chain', 'sample']; + + // Mix in each Underscore method as a proxy to `Collection#models`. + _.each(methods, function(method) { + Collection.prototype[method] = function() { + var args = slice.call(arguments); + args.unshift(this.models); + return _[method].apply(_, args); + }; + }); + + // Underscore methods that take a property name as an argument. + var attributeMethods = ['groupBy', 'countBy', 'sortBy', 'indexBy']; + + // Use attributes instead of properties. + _.each(attributeMethods, function(method) { + Collection.prototype[method] = function(value, context) { + var iterator = _.isFunction(value) ? value : function(model) { + return model.get(value); + }; + return _[method](this.models, iterator, context); + }; + }); + + // Backbone.View + // ------------- + + // Backbone Views are almost more convention than they are actual code. A View + // is simply a JavaScript object that represents a logical chunk of UI in the + // DOM. This might be a single item, an entire list, a sidebar or panel, or + // even the surrounding frame which wraps your whole app. Defining a chunk of + // UI as a **View** allows you to define your DOM events declaratively, without + // having to worry about render order ... and makes it easy for the view to + // react to specific changes in the state of your models. + + // Creating a Backbone.View creates its initial element outside of the DOM, + // if an existing element is not provided... + var View = Backbone.View = function(options) { + this.cid = _.uniqueId('view'); + options || (options = {}); + _.extend(this, _.pick(options, viewOptions)); + this._ensureElement(); + this.initialize.apply(this, arguments); + this.delegateEvents(); + }; + + // Cached regex to split keys for `delegate`. + var delegateEventSplitter = /^(\S+)\s*(.*)$/; + + // List of view options to be merged as properties. + var viewOptions = ['model', 'collection', 'el', 'id', 'attributes', 'className', 'tagName', 'events']; + + // Set up all inheritable **Backbone.View** properties and methods. + _.extend(View.prototype, Events, { + + // The default `tagName` of a View's element is `"div"`. + tagName: 'div', + + // jQuery delegate for element lookup, scoped to DOM elements within the + // current view. This should be preferred to global lookups where possible. + $: function(selector) { + return this.$el.find(selector); + }, + + // Initialize is an empty function by default. Override it with your own + // initialization logic. + initialize: function(){}, + + // **render** is the core function that your view should override, in order + // to populate its element (`this.el`), with the appropriate HTML. The + // convention is for **render** to always return `this`. + render: function() { + return this; + }, + + // Remove this view by taking the element out of the DOM, and removing any + // applicable Backbone.Events listeners. + remove: function() { + this.$el.remove(); + this.stopListening(); + return this; + }, + + // Change the view's element (`this.el` property), including event + // re-delegation. + setElement: function(element, delegate) { + if (this.$el) this.undelegateEvents(); + this.$el = element instanceof Backbone.$ ? element : Backbone.$(element); + this.el = this.$el[0]; + if (delegate !== false) this.delegateEvents(); + return this; + }, + + // Set callbacks, where `this.events` is a hash of + // + // *{"event selector": "callback"}* + // + // { + // 'mousedown .title': 'edit', + // 'click .button': 'save', + // 'click .open': function(e) { ... } + // } + // + // pairs. Callbacks will be bound to the view, with `this` set properly. + // Uses event delegation for efficiency. + // Omitting the selector binds the event to `this.el`. + // This only works for delegate-able events: not `focus`, `blur`, and + // not `change`, `submit`, and `reset` in Internet Explorer. + delegateEvents: function(events) { + if (!(events || (events = _.result(this, 'events')))) return this; + this.undelegateEvents(); + for (var key in events) { + var method = events[key]; + if (!_.isFunction(method)) method = this[events[key]]; + if (!method) continue; + + var match = key.match(delegateEventSplitter); + var eventName = match[1], selector = match[2]; + method = _.bind(method, this); + eventName += '.delegateEvents' + this.cid; + if (selector === '') { + this.$el.on(eventName, method); + } else { + this.$el.on(eventName, selector, method); + } + } + return this; + }, + + // Clears all callbacks previously bound to the view with `delegateEvents`. + // You usually don't need to use this, but may wish to if you have multiple + // Backbone views attached to the same DOM element. + undelegateEvents: function() { + this.$el.off('.delegateEvents' + this.cid); + return this; + }, + + // Ensure that the View has a DOM element to render into. + // If `this.el` is a string, pass it through `$()`, take the first + // matching element, and re-assign it to `el`. Otherwise, create + // an element from the `id`, `className` and `tagName` properties. + _ensureElement: function() { + if (!this.el) { + var attrs = _.extend({}, _.result(this, 'attributes')); + if (this.id) attrs.id = _.result(this, 'id'); + if (this.className) attrs['class'] = _.result(this, 'className'); + var $el = Backbone.$('<' + _.result(this, 'tagName') + '>').attr(attrs); + this.setElement($el, false); + } else { + this.setElement(_.result(this, 'el'), false); + } + } + + }); + + // Backbone.sync + // ------------- + + // Override this function to change the manner in which Backbone persists + // models to the server. You will be passed the type of request, and the + // model in question. By default, makes a RESTful Ajax request + // to the model's `url()`. Some possible customizations could be: + // + // * Use `setTimeout` to batch rapid-fire updates into a single request. + // * Send up the models as XML instead of JSON. + // * Persist models via WebSockets instead of Ajax. + // + // Turn on `Backbone.emulateHTTP` in order to send `PUT` and `DELETE` requests + // as `POST`, with a `_method` parameter containing the true HTTP method, + // as well as all requests with the body as `application/x-www-form-urlencoded` + // instead of `application/json` with the model in a param named `model`. + // Useful when interfacing with server-side languages like **PHP** that make + // it difficult to read the body of `PUT` requests. + Backbone.sync = function(method, model, options) { + var type = methodMap[method]; + + // Default options, unless specified. + _.defaults(options || (options = {}), { + emulateHTTP: Backbone.emulateHTTP, + emulateJSON: Backbone.emulateJSON + }); + + // Default JSON-request options. + var params = {type: type, dataType: 'json'}; + + // Ensure that we have a URL. + if (!options.url) { + params.url = _.result(model, 'url') || urlError(); + } + + // Ensure that we have the appropriate request data. + if (options.data == null && model && (method === 'create' || method === 'update' || method === 'patch')) { + params.contentType = 'application/json'; + params.data = JSON.stringify(options.attrs || model.toJSON(options)); + } + + // For older servers, emulate JSON by encoding the request into an HTML-form. + if (options.emulateJSON) { + params.contentType = 'application/x-www-form-urlencoded'; + params.data = params.data ? {model: params.data} : {}; + } + + // For older servers, emulate HTTP by mimicking the HTTP method with `_method` + // And an `X-HTTP-Method-Override` header. + if (options.emulateHTTP && (type === 'PUT' || type === 'DELETE' || type === 'PATCH')) { + params.type = 'POST'; + if (options.emulateJSON) params.data._method = type; + var beforeSend = options.beforeSend; + options.beforeSend = function(xhr) { + xhr.setRequestHeader('X-HTTP-Method-Override', type); + if (beforeSend) return beforeSend.apply(this, arguments); + }; + } + + // Don't process data on a non-GET request. + if (params.type !== 'GET' && !options.emulateJSON) { + params.processData = false; + } + + // If we're sending a `PATCH` request, and we're in an old Internet Explorer + // that still has ActiveX enabled by default, override jQuery to use that + // for XHR instead. Remove this line when jQuery supports `PATCH` on IE8. + if (params.type === 'PATCH' && noXhrPatch) { + params.xhr = function() { + return new ActiveXObject("Microsoft.XMLHTTP"); + }; + } + + // Make the request, allowing the user to override any Ajax options. + var xhr = options.xhr = Backbone.ajax(_.extend(params, options)); + model.trigger('request', model, xhr, options); + return xhr; + }; + + var noXhrPatch = + typeof window !== 'undefined' && !!window.ActiveXObject && + !(window.XMLHttpRequest && (new XMLHttpRequest).dispatchEvent); + + // Map from CRUD to HTTP for our default `Backbone.sync` implementation. + var methodMap = { + 'create': 'POST', + 'update': 'PUT', + 'patch': 'PATCH', + 'delete': 'DELETE', + 'read': 'GET' + }; + + // Set the default implementation of `Backbone.ajax` to proxy through to `$`. + // Override this if you'd like to use a different library. + Backbone.ajax = function() { + return Backbone.$.ajax.apply(Backbone.$, arguments); + }; + + // Backbone.Router + // --------------- + + // Routers map faux-URLs to actions, and fire events when routes are + // matched. Creating a new one sets its `routes` hash, if not set statically. + var Router = Backbone.Router = function(options) { + options || (options = {}); + if (options.routes) this.routes = options.routes; + this._bindRoutes(); + this.initialize.apply(this, arguments); + }; + + // Cached regular expressions for matching named param parts and splatted + // parts of route strings. + var optionalParam = /\((.*?)\)/g; + var namedParam = /(\(\?)?:\w+/g; + var splatParam = /\*\w+/g; + var escapeRegExp = /[\-{}\[\]+?.,\\\^$|#\s]/g; + + // Set up all inheritable **Backbone.Router** properties and methods. + _.extend(Router.prototype, Events, { + + // Initialize is an empty function by default. Override it with your own + // initialization logic. + initialize: function(){}, + + // Manually bind a single named route to a callback. For example: + // + // this.route('search/:query/p:num', 'search', function(query, num) { + // ... + // }); + // + route: function(route, name, callback) { + if (!_.isRegExp(route)) route = this._routeToRegExp(route); + if (_.isFunction(name)) { + callback = name; + name = ''; + } + if (!callback) callback = this[name]; + var router = this; + Backbone.history.route(route, function(fragment) { + var args = router._extractParameters(route, fragment); + router.execute(callback, args); + router.trigger.apply(router, ['route:' + name].concat(args)); + router.trigger('route', name, args); + Backbone.history.trigger('route', router, name, args); + }); + return this; + }, + + // Execute a route handler with the provided parameters. This is an + // excellent place to do pre-route setup or post-route cleanup. + execute: function(callback, args) { + if (callback) callback.apply(this, args); + }, + + // Simple proxy to `Backbone.history` to save a fragment into the history. + navigate: function(fragment, options) { + Backbone.history.navigate(fragment, options); + return this; + }, + + // Bind all defined routes to `Backbone.history`. We have to reverse the + // order of the routes here to support behavior where the most general + // routes can be defined at the bottom of the route map. + _bindRoutes: function() { + if (!this.routes) return; + this.routes = _.result(this, 'routes'); + var route, routes = _.keys(this.routes); + while ((route = routes.pop()) != null) { + this.route(route, this.routes[route]); + } + }, + + // Convert a route string into a regular expression, suitable for matching + // against the current location hash. + _routeToRegExp: function(route) { + route = route.replace(escapeRegExp, '\\$&') + .replace(optionalParam, '(?:$1)?') + .replace(namedParam, function(match, optional) { + return optional ? match : '([^/?]+)'; + }) + .replace(splatParam, '([^?]*?)'); + return new RegExp('^' + route + '(?:\\?([\\s\\S]*))?$'); + }, + + // Given a route, and a URL fragment that it matches, return the array of + // extracted decoded parameters. Empty or unmatched parameters will be + // treated as `null` to normalize cross-browser behavior. + _extractParameters: function(route, fragment) { + var params = route.exec(fragment).slice(1); + return _.map(params, function(param, i) { + // Don't decode the search params. + if (i === params.length - 1) return param || null; + return param ? decodeURIComponent(param) : null; + }); + } + + }); + + // Backbone.History + // ---------------- + + // Handles cross-browser history management, based on either + // [pushState](http://diveintohtml5.info/history.html) and real URLs, or + // [onhashchange](https://developer.mozilla.org/en-US/docs/DOM/window.onhashchange) + // and URL fragments. If the browser supports neither (old IE, natch), + // falls back to polling. + var History = Backbone.History = function() { + this.handlers = []; + _.bindAll(this, 'checkUrl'); + + // Ensure that `History` can be used outside of the browser. + if (typeof window !== 'undefined') { + this.location = window.location; + this.history = window.history; + } + }; + + // Cached regex for stripping a leading hash/slash and trailing space. + var routeStripper = /^[#\/]|\s+$/g; + + // Cached regex for stripping leading and trailing slashes. + var rootStripper = /^\/+|\/+$/g; + + // Cached regex for detecting MSIE. + var isExplorer = /msie [\w.]+/; + + // Cached regex for removing a trailing slash. + var trailingSlash = /\/$/; + + // Cached regex for stripping urls of hash. + var pathStripper = /#.*$/; + + // Has the history handling already been started? + History.started = false; + + // Set up all inheritable **Backbone.History** properties and methods. + _.extend(History.prototype, Events, { + + // The default interval to poll for hash changes, if necessary, is + // twenty times a second. + interval: 50, + + // Are we at the app root? + atRoot: function() { + return this.location.pathname.replace(/[^\/]$/, '$&/') === this.root; + }, + + // Gets the true hash value. Cannot use location.hash directly due to bug + // in Firefox where location.hash will always be decoded. + getHash: function(window) { + var match = (window || this).location.href.match(/#(.*)$/); + return match ? match[1] : ''; + }, + + // Get the cross-browser normalized URL fragment, either from the URL, + // the hash, or the override. + getFragment: function(fragment, forcePushState) { + if (fragment == null) { + if (this._hasPushState || !this._wantsHashChange || forcePushState) { + fragment = decodeURI(this.location.pathname + this.location.search); + var root = this.root.replace(trailingSlash, ''); + if (!fragment.indexOf(root)) fragment = fragment.slice(root.length); + } else { + fragment = this.getHash(); + } + } + return fragment.replace(routeStripper, ''); + }, + + // Start the hash change handling, returning `true` if the current URL matches + // an existing route, and `false` otherwise. + start: function(options) { + if (History.started) throw new Error("Backbone.history has already been started"); + History.started = true; + + // Figure out the initial configuration. Do we need an iframe? + // Is pushState desired ... is it available? + this.options = _.extend({root: '/'}, this.options, options); + this.root = this.options.root; + this._wantsHashChange = this.options.hashChange !== false; + this._wantsPushState = !!this.options.pushState; + this._hasPushState = !!(this.options.pushState && this.history && this.history.pushState); + var fragment = this.getFragment(); + var docMode = document.documentMode; + var oldIE = (isExplorer.exec(navigator.userAgent.toLowerCase()) && (!docMode || docMode <= 7)); + + // Normalize root to always include a leading and trailing slash. + this.root = ('/' + this.root + '/').replace(rootStripper, '/'); + + if (oldIE && this._wantsHashChange) { + var frame = Backbone.$('