'use strict'; /*@ Ox.URL URL controller (options) -> URL controller options Options object findKeys <[o]> Find keys id Find key id type Value type (like "string" or "integer") getHash Takes a state object and normalizes its "hash" property (o) -> State object getItem Tests if a string matches an item (type, string, callback) -> undefined type type string The string to be tested callback callback function id Matching item id, or empty getSpan Tests if a string matches a span (type, item, view, string, callback) -> undefined item The item id, or empty view The view, or empty string The string to be tested callback Callback function id Matching span id, or empty view Matching view, or empty pages <[s]> List of pages sortKeys Sort keys for list and item views for all types typeA Sort keys for this type list Sort keys for list views for this type viewA <[o]> Sort keys for this view id Sort key id operator Default sort operator ("+" or "-") item Sort keys for item views for this type viewA <[o]> Sort keys for this view id Sort key id operator Default sort operator ("+" or "-") spanType Span types for list and item views for all types typeA Span types for this type list Span types for list views for this type viewA Span type for this view Can be "date", "duration" or "location" item Span types for item views for this type viewA Span type for this view Can be "date", "duration" or "location" types <[s]> List of types views List and item views for all types typeA Views for type "typeA" list <[s]> List views for this type item <[s]> Item views for this type > !!Ox.test.url.parse('/', function(o) { Ox.test(o, Ox.test.result['/']); }) true > !!Ox.test.url.parse('/faq#1', function(o) { Ox.test(o, Ox.test.result['/faq#1']); }) true > !!Ox.test.url.parse('/cities', function(o) { Ox.test(o, Ox.test.result['/cities']); }) true > !!Ox.test.url.parse('/map', function(o) { Ox.test(o, Ox.test.result['/map']); }) true > !!Ox.test.url.parse('/-45,-90,45,90', function(o) { Ox.test(o, Ox.test.result['/-45,-90,45,90']); }) true > !!Ox.test.url.parse('/@New%20York', function(o) { Ox.test(o, Ox.test.result['/@New%20York']); }) true > !!Ox.test.url.parse('/name', function(o) { Ox.test(o, Ox.test.result['/name']); }) true > !!Ox.test.url.parse('/-name,population', function(o) { Ox.test(o, Ox.test.result['/-name,population']); }) true > !!Ox.test.url.parse('/2342', function(o) { Ox.test(o, Ox.test.result['/2342']); }) true > !!Ox.test.url.parse('/2342/map', function(o) { Ox.test(o, Ox.test.result['/2342/map']); }) true > !!Ox.test.url.parse('/2342/name', function(o) { Ox.test(o, Ox.test.result['/2342/name']); }) true > !!Ox.test.url.parse('/foo', function(o) { Ox.test(o, Ox.test.result['/foo']); }) true > !!Ox.test.url.parse('/population=1000,2000', function(o) { Ox.test(o, Ox.test.result['/population=1000,2000']); }) true > !!Ox.test.url.parse('/population>0&(name=a*|name=*z)', function(o) { Ox.test(o, Ox.test.result['/population>0&(name=a*|name=*z)']); }) true > !!Ox.test.url.parse('/#a?k=v&l=w', function(o) { Ox.test(o, Ox.test.result['/#a?k=v&l=w']); }) true > !!Ox.test.url.parse('/#?a=[1,2]&b=true&n=1.2&o={"k":"v"}&s1="foo"&s2=bar', function(o) { Ox.test(o, Ox.test.result['/#?a=[1,2]&b=true&n=1.2&o={"k":"v"}&s1="foo"&s2=bar']); }) true @*/ /* example.com[/page][#hash] or example.com[/type][/item][/view][/span][/sort][/find][#hash] page Special page, like "about" or "contact" type Section a.k.a. item type, like "movies", "edits", "texts" etc. item Item id or title, like in '/movies/0060304', '/movies/inception' or 'texts/ABC'. Testing this is asynchonous. view List or item view, like "clips" or "map". Both list and item views are per type. span Position or selection in a view, either one or two coordinates or one id, like in "video/01:00", "video/-01:00", "video/01:00,-01:00", "video/@annotationABC", "video/@subtitles:23", "text/@chapter42", "map/0,0", "map/-45,-90,45,90", "map/@barcelona", "image/100,100" etc. Testing id is asynchronous. sort Sort, like "title" or "-director" or "country,year,-language,+runtime" find Query, like a=x or a=x&b=y or a=x&(b=y|c=z). A query object has the form {conditions: [], operator: ''} (logical operator), and a condition object has the form {key: '', value: '' or ['', ''], operator: ''} (comparison operator) or {conditions: [], operator: ''} (logical operator). Condition strings can be more than just "k=v", see below. hash Anchor and/or query, like 'a' or '?k=v' or 'a?k=v&l=w' or '?a=[1,2]&b=true&f=1.2&i=3&o={"k":"v"}&s="foo"'. Values are evaluated as JSON, and if that fails interpreted as a string. So strings (outside arrays or objects) work without quotes as well, unless their value is valid JSON, like 'true' or '1'. String Key Value Operator v * v = any text or string contains or any number is !v * v != no text or string contains and no number is k=v k v = contains (text or string), is (number) k!=v k v != does not contain (text or string), is not (number) k==v k v == is (string) k!==v k v !== is not (string) k=v* k v ^ starts with (string) k!=v* k v !^ does not start with (string) k=*v k v $ ends with (string) k!=*v k v !$ does not end with (string) kv k v > is more than (number) k!>v k v !> is not more than (number) k=v,w k [v,w] = is between (number), contains (string or text) k!=v,w k [v,w] != is not between (number), does not contain (string or text) All parts of the URL can be omitted, as long as the order is preserved. example.com/foo If "foo" is not a type, item (of the default type), list view (of the default type), span id (of any list view of the default type) or sort key (of any list view of the default type), then this means find *=foo example.com/title, or example.com/+title or example.com/-title If this neither matches a type or default type item, then this will be sort example.com/clip/+duration/title=foo If "clip" is a default type list view, this will show all clips of items that match title=foo, sorted by item duration in ascending order example.com/clip/+clip.duration/subtitles=foo If "clip" is a default type list view and "subtitles" is an annotation type, this will show all clips that match subtitles=foo, sorted by clip duration in ascending order. (In pan.do/ra's clip view, annotation=foo is always per clip. There is no way to show all clips of all items where any clip matches subtitles=foo, this doesn't seem to be needed.) example.com/map/@paris/duration/title!=london If "map" is a default type list view and "paris" is a place name, this will zoom the map to Paris, show all places of items that match title!=london, and when a place is selected sort matching clips by item duration in default order. example.com/calendar/1900,2000/clip:duration/event=hiroshima If "calendar" is a default type list view, this will zoom the calendar to the 20th century, show all events of all items that match event=hiroshima, and when an event is selected sort matching clips by clip duration in default order. (In pan.do/ra's map and calendar view, annotation=foo is always per item. There is no way to show all events of all clips that match event=hiroshima, this doesn't seem to be needed.) example.com/2001/2001 -> example.com/0062622/video/00:33:21 2001 matches an item title (word match), the second 2001 is a valid duration example.com/2002/2002 -> example.com/calendar/2002/2002 2002 is a valid duration, but no list view supports durations. Then it is read as a year, and we get calendar view with find *=2002 example.com/@paris/london -> example.com/map/@paris/london paris matches place ABC (case-insensitive), but (assuming) find *=london does not match place ABC, "paris" becomes the map query example.com/@paris/paris -> example.com/map/ABC/paris paris matches place ABC (case-insensitive), so we get map view, zoomed to ABC/Paris, which is selected, with find *=paris example.com/@renaissance/renaissance -> example.com/calendar/ABC/renaissance renaissaince matches an event name (case-insensitive), so we get calendar view, zoomed to the Renaissance, with find *=renaissance example.com/@foo/foo -> example.com/map/@foo/foo foo doesn't match a place or event name, but getSpan() sets the map query to foo and returns @foo, so we get map view, zoomed to Foo, with find *=foo example.com/clip:duration -> example.com/clip/clip:duration clip:duration is not a sort key of the default view (grid), so the view is set to the first list view that accepts this sort key */ Ox.URL = function(options) { var self = {}, that = {}; self.options = Ox.extend({ // fixme: find keys are also per type/list|item/view // since one can search for layer properties in some item views findKeys: [], getHash: null, getItem: null, getSpan: null, pages: [], spanType: {}, sortKeys: {}, types: [], views: {} }, options); if (Ox.every(self.options.findKeys, function(findKey) { return findKey.id != '*'; })) { self.options.findKeys.push({id: '*', type: 'string'}); } self.previousTitle = ''; self.previousURL = ''; window.addEventListener('popstate', function() { self.previousTitle = document.title; self.previousURL = document.location.pathname + document.location.search + document.location.hash; }); function constructCondition(condition) { var key = condition.key == '*' ? '' : condition.key, operator = condition.operator, value; value = ( Ox.isArray(condition.value) ? condition.value : [condition.value] ).map(function(value) { return encodeValue(constructValue(value, condition.key)); }).join(','); if (!key) { operator = operator.replace('=', ''); } else if (operator.indexOf('^') > -1) { operator = operator.replace('^', '='); value += '*'; } else if (operator.indexOf('$') > -1) { operator = operator.replace('$', '='); value = '*' + value; } return [key, operator, value].join(''); } function constructDate(date) { return Ox.formatDate(date, '%Y-%m-%d', true); } function constructDuration(duration) { return Ox.formatDuration(duration, 3).replace(/\.000$/, ''); } function constructFind(find) { return find.conditions.map(function(condition) { return condition.conditions ? '(' + constructFind(condition) + ')' : constructCondition(condition); }).join(find.operator); } function constructHash(hash) { var obj = {}; if (hash.query) { hash.query.forEach(function(condition) { obj[condition.key] = condition.value; }); } return (hash.anchor || '') + (hash.query ? '?' + Ox.serialize(obj) : ''); } function constructLocation(location) { return location.join(','); } function constructSort(sort, state) { var sortKeys = self.options.sortKeys[state.type][ !state.item ? 'list' : 'item' ][state.view]; return sortKeys ? sort.map(function(sort) { return ( Ox.getObjectById(sortKeys, sort.key).operator == sort.operator ? '' : sort.operator ) + sort.key; }).join(',') : ''; } function constructSpan(span, state) { var view = state.view || self.options.views[state.type][ !state.item ? 'list' : 'item' ][0], spanType = self.options.spanType[state.type][ !state.item ? 'list' : 'item' ][view]; return (Ox.isArray(span) ? span : [span]).map(function(point) { return Ox.isNumber(point) ? ( spanType == 'date' ? constructDate(point) : spanType == 'duration' ? constructDuration(point) : spanType == 'number' ? point : constructLocation(point) ) : point; }).join(','); } function constructURL(state) { var parts = []; if (state.page) { parts.push(state.page); } else { if (self.options.types.indexOf(state.type) > 0) { parts.push(state.type); } if (state.item) { parts.push(state.item); } if (state.type && self.options.views[state.type][ state.item ? 'item' : 'list' ].indexOf(state.view) > -1) { parts.push(state.view); } if (state.span && state.span.length) { parts.push(constructSpan(state.span, state)); } if (state.sort && state.sort.length) { parts.push(constructSort(state.sort, state)); } if (state.find) { parts.push(constructFind(state.find)); } } return '/' + Ox.filter(parts).join('/') + (state.hash ? '#' + constructHash(state.hash) : ''); } function constructValue(str, key) { var findKey = Ox.getObjectById(self.options.findKeys, key), type = Ox.isArray(findKey.type) ? findKey.type[0] : findKey.type, value = str, values = findKey.values; return type == 'enum' ? values[value] : value; } function decodeValue(value) { return decodeURIComponent(value); } function encodeValue(value) { // var chars = '/&|()=*:'; var chars = '&|()=*', ret = ''; value.toString().split('').forEach(function(char) { var index = chars.indexOf(char); ret += index > -1 ? '%' + char.charCodeAt(0).toString(16).toUpperCase() : char; }); return ret; } function isNumericalSpan(str) { return str.split(',').every(function(str) { return /^[0-9-\.:]+$/.test(str); }); } function getSpanType(str, types) { Ox.Log('Core', 'getSpanType', str, types) var canBeDate = types.indexOf('date') > -1, canBeDuration = types.indexOf('duration') > -1, canBeLocation = types.indexOf('location') > -1, canBeNumber = types.indexOf('number') > -1, length = str.split(',').length; return canBeDate && /\d-/.test(str) ? 'date' : canBeDuration && /:/.test(str) ? 'duration' : canBeLocation && length == 4 ? 'location' // leaves us with [-]D[.D][,[-]D[.D]] : canBeDuration ? 'duration' : canBeDate && !/\./.test(str) ? 'date' : canBeLocation && length == 2 ? 'location' : canBeNumber && /\d-/.test(str) ? 'number' : ':' } function parseCondition(str) { Ox.Log('Core', 'PARSE COND', str) var condition = {}, operators = ['!==', '==', '!=', '=', '!<', '<', '!>', '>'], split; str = str.replace(/%3C/g, '<').replace(/%3E/g, '>'); Ox.forEach(operators, function(operator) { if (str.indexOf(operator) > -1) { split = str.split(operator); condition = { key: split.shift(), value: split.join(operator), operator: operator }; return false; // break } }); if ( !condition.operator || Ox.getIndexById(self.options.findKeys, condition.key) == -1 ) { // missing operator or unknown key condition = {key: '*', value: str, operator: '='}; } if (['=', '!='].indexOf(condition.operator) > -1) { if (condition.value[0] == '*') { condition.value = condition.value.slice(1); condition.operator = condition.operator.replace('=', '$') } else if (condition.value[condition.value.length - 1] == '*') { condition.value = condition.value.slice(0, -1); condition.operator = condition.operator.replace('=', '^') } } if ( ['date', 'enum', 'float', 'integer', 'time', 'year'].indexOf( Ox.getObjectById(self.options.findKeys, condition.key).type ) > -1 && condition.value.indexOf(',') > -1 ) { condition.value = condition.value.split(',').map(function(value) { return parseValue(decodeValue(value), condition.key); }); } else { condition.value = parseValue(decodeValue(condition.value), condition.key); } Ox.Log('Core', 'PARSE COND', str, condition); return condition; } function parseDate(str) { return Ox.formatDate(Ox.parseDate(str, true), '%Y-%m-%d'); } function parseDuration(str) { return Ox.parseDuration(str); } function parseNumber(str) { return parseFloat(str); } function parseFind(str) { str = (str || '').replace(/%7C/g, '|'); var conditions, counter = 0, find = {conditions: [], operator: '&'}, subconditions = []; if (str.length) { // replace subconditions with placeholder, // so we can later split by main operator Ox.forEach(str, function(c, i) { if (c == ')') { counter--; } if (counter >= 1) { subconditions[subconditions.length - 1] += c; } if (c == '(') { (++counter == 1) && subconditions.push(''); } }); subconditions = subconditions.filter(function(subcondition) { // make sure empty brackets don't throw errors return !!subcondition; }); subconditions.forEach(function(subcondition, i) { str = str.replace(subcondition, i); }); find.operator = str.indexOf('|') > -1 ? '|' : '&' find.conditions = str.split(find.operator).map(function(condition, i) { var ret; if (condition[0] == '(') { // re-insert subcondition ret = parseFind(subconditions[parseInt(condition.slice(1, -1))]); } else { ret = parseCondition(condition); } return ret; }); } return find; } function parseHash(str) { var hash = {}, split = str.split('?'); if (split[0]) { hash.anchor = decodeValue(split[0]); } if (split[1]) { Ox.forEach(Ox.unserialize(split[1], true), function(value, key) { hash.query = (hash.query || []) hash.query.push({ key: key, value: value }); }); } return hash; } function parseLocation(str) { return str.split(',').map(function(str, i) { return Ox.limit(parseInt(str, 10), -90 * (i + 1), 90 * (i + 1)); }); } function parseSort(str, state) { return str.split(',').map(function(str) { var hasOperator = /^[\+-]/.test(str); return { key: hasOperator ? str.slice(1) : str, operator: hasOperator ? str[0] : Ox.getObjectById(self.options.sortKeys[state.type][ !state.item ? 'list' : 'item' ][state.view], str).operator }; }); } function parseSpan(str, type) { var split = str.split(','); if (split.length == 4) { split = [split[0] + ',' + split[1], split[2] + ',' + split[3]]; } return split.map( type == 'date' ? parseDate : type == 'duration' ? parseDuration : type == 'number' ? parseNumber : parseLocation ); } function parseURL(str, callback) { // fixme: removing trailing slash makes it impossible to search for '/' var split = str.split('#'), parts = split.shift().replace(/(^\/|\/$)/g, '').split('/'), state = split.length ? {hash: parseHash(split.join('#'))} : {}; if (parts[0] == '') { // empty URL getHash(); } else if (self.options.pages.indexOf(parts[0]) > -1) { // page state.page = parts[0]; getHash(); } else { if (self.options.types.indexOf(parts[0]) > -1) { // type state.type = parts[0]; parts.shift(); } else { // set to default type state.type = self.options.types[0]; } if (parts.length) { Ox.Log('Core', 'ST', state.type, self.options.views) if (self.options.views[state.type].list.indexOf(parts[0]) > -1) { // list view state.item = ''; state.view = parts[0]; parts.shift(); parseBeyondItem(); } else { // test for item id or name self.options.getItem(state.type, parts[0].replace(/%20/g, ' '), function(item) { state.item = item; if (item) { parts.shift(); } parseBeyondItem(); }); } } else { state.item = ''; // set to default view state.view = self.options.views[state.type].list[0]; getHash(); } } function parseBeyondItem() { Ox.Log('Core', 'pBI', state, parts.join('/')); var span, spanType, spanTypes; if ( parts.length && state.item && self.options.views[state.type].item.indexOf(parts[0]) > -1 ) { // item view state.view = parts[0]; parts.shift(); } if (parts.length) { if (isNumericalSpan(parts[0])) { // test for numerical span spanTypes = self.options.spanType[state.type][ !state.item ? 'list' : 'item' ]; // if no view is given then parse the span anyway, // but make sure the span type could match a view spanType = state.view ? spanTypes[state.view] : getSpanType(parts[0], Ox.unique(Ox.values(spanTypes))); Ox.Log('Core', 'SPAN TYPE', spanType) if (spanType) { span = parseSpan(parts[0], spanType); if (span) { if (state.view) { state.span = span; parts.shift(); } else { // if no view is given then switch to the first // view that supports a span of this type Ox.forEach(self.options.views[state.type][ !state.item ? 'list' : 'item' ], function(view) { if (spanTypes[view] == spanType) { state.view = view; state.span = span; parts.shift(); return false; // break } }); } } } } if (!state.span && /^[A-Z@]/.test(parts[0])) { // test for span id or name self.options.getSpan(state.type, state.item, state.view, decodeValue(parts[0]), function(span, view) { Ox.Log('Core', 'span/view', span, view) if (span) { if (!state.view) { // set list or item view state.view = view; } state.span = span; parts.shift(); } parseBeyondSpan(); }); } else { parseBeyondSpan(); } } else { if (!state.view) { // set to default item view state.view = self.options.views[state.type].item[0]; } getHash(); } } function parseBeyondSpan() { Ox.Log('Core', 'pBS', state, parts) var sortKeyIds, sortParts; if (parts.length) { sortParts = parts[0].split(','); sortKeyIds = Ox.map(self.options.sortKeys[state.type][ !state.item ? 'list' : 'item' ], function(sortKeys) { return sortKeys.map(function(sortKey) { return sortKey.id; }); }); // test if sort keys match the given view, // or any view if no view is given Ox.forEach( state.view ? [state.view] : self.options.views[state.type][!state.item ? 'list' : 'item'], function(view) { if (sortKeyIds[view] && sortParts.every(function(part) { return sortKeyIds[view].indexOf(part.replace(/^[\+-]/, '')) > -1; })) { if (!state.view) { // set list or item view state.view = view; } // sort state.sort = parseSort(parts[0], state); parts.shift(); return false; // break } } ); } if (!state.view) { // set to default list or item view state.view = self.options.views[state.type][ !state.item ? 'list' : 'item' ][0]; } if (parts.length) { // find state.find = parseFind(parts.join('/')); } getHash(); } function getHash() { if (self.options.getHash) { state = self.options.getHash(state); } callback(state); } } function parseValue(str, key) { var findKey = Ox.getObjectById(self.options.findKeys, key), type = Ox.isArray(findKey.type) ? findKey.type[0] : findKey.type, value = str, values = findKey.values; if (type == 'boolean') { value = ['', 'false'].indexOf(str) == -1; } else if (type == 'date') { value = Ox.formatDate(Ox.parseDate(str, true), '%F', true); } else if (type == 'enum') { value = Math.max(values.map(function(value) { return value.toLowerCase(); }).indexOf(str.toLowerCase()), 0); } else if (type == 'float') { value = parseFloat(str) || 0; } else if (type == 'integer') { value = Math.round(str) || 0; } else if (type == 'time') { value = Ox.formatDurarion(Ox.parseDuration(value)); } else if (type == 'year') { value = Math.round(str) || 1970; } return value.toString(); } function saveURL() { } /*@ parse parse (callback) -> parse state from document.location (url, callback) -> parse state from passed url @*/ that.parse = function() { var str = arguments.length == 2 ? arguments[0] : document.location.pathname + document.location.search + document.location.hash, callback = arguments[arguments.length - 1]; parseURL(str, callback); return that; }; /*@ pop Sets the URL to the previous URL @*/ that.pop = function() { if (self.previousURL) { history.pushState && history.pushState( {}, self.previousTitle, self.previousURL ); document.title = self.previousTitle; } return !!self.previousURL; }; /*@ push Pushes a new URL (state, title, url, callback) -> URL controller state State for the new URL If state is null, it will be derived from url title Title for the new URL url New URL If url is null, it will be derived from state callback callback function state New state @*/ that.push = function(state, title, url, callback) { if (!state) { parseURL(url, function(state) { pushState(state, title, url); }); } else { url = url || constructURL(state); pushState(state, title, url); } function pushState(state, title, url) { self.previousTitle = document.title; self.previousURL = document.location.pathname + document.location.search + document.location.hash; if (url != self.previousURL) { history.pushState && history.pushState( Ox.extend(state, {title: title}), '', url ); document.title = title; callback && callback(state); } } } /*@ replace Replaces the URL with a new URL (state, title, url, callback) -> URL controller state State for the new URL If state is null, it will be derived from url title Title for the new URL url New URL If url is null, it will be derived from state callback callback function state New state @*/ that.replace = function(state, title, url, callback) { if (!state) { parseURL(url, function(state) { replaceState(state, title, url); }); } else { url = url || constructURL(state); replaceState(state, title, url); } function replaceState(state, title, url) { history.replaceState && history.replaceState( Ox.extend(state, {title: title}), '', url ); document.title = title; callback && callback(state); } } return that; };