// vim: et:ts=4:sw=4:sts=4:ft=javascript /*@ Ox.URL URL controller (options) -> URL controller options Options object findKeys <[o]> Find keys {id: "", type: ""} type can be "string" or "number" getItemId Tests if a string matches an item (string, callback) -> undefined string The string to be tested callback callback function id Matching item id, or empty getSpanId Tests if a string matches a span (string, callback) -> undefined string The string to be tested callback callback function id Matching span id, or empty pages <[s]> List of pages sortKeys <[o]> Sort keys {id: "", operator: ""} operator is the default operator ("+" or "-") types <[s]> List of types views List of views {type: {'list': [...], 'item': [...]}} @*/ /* example.com[/page] or example.com[/type][/item][/view][/span][/sort][/find] 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. 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), is (string) k!=v:w k [v,w] != is not between (number), is not (string) 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), view (of the default type), span id (of the default type's default view) or sort key (of the default type's default view), 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 Ff "map" is a default type list view and "Paris" is a place id, 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.) */ Ox.URL = function(options) { var self = {}, that = {}; self.options = Ox.extend({ findKeys: [], getItemId: null, getSpanId: null, pages: [], sortKeys: [], types: [], views: {} }, options); self.sortKeyIds = self.options.sortKeys.map(function(sortKey) { return sortKey.id; }); function constructCondition(condition) { var key = condition.key == '*' ? '' : condition.key, operator = condition.operator, value = ( Ox.isArray(condition.value) ? condition.value : [condition.value] ).map(encodeValue).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 constructDuration(duration) { return Ox.formatDuration(duration, 3).replace(/\.000$/, ''); } function constructFind(find) { return find.conditions.map(function(condition) { var ret; if (condition.conditions) { ret = '(' + constructFind(condition) + ')'; } else { ret = constructCondition(condition); } return ret; }).join(find.operator); } function constructSort(sort) { return sort.map(function(sort) { return ( sort.operator == Ox.getObjectById(self.options.sortKeys, sort.key).operator ? '' : sort.operator ) + sort.key; }).join(',') } function constructSpan(span) { return span.map(function(point) { return /^[0-9-\.:]+$/.test(str) ? constuctDuration(point) : point; }).join(','); } function constructURL(state) { var parts = []; if (self.options.types.indexOf(state.type) > 0) { parts.push(state.type); } if (state.item) { parts.push(item); } if (self.options.views[state.type][ state.item ? 'item' : 'list' ].indexOf(state.view) > 0) { parts.push(state.view); } if (state.span) { parts.push(constructSpan(state.span)); } if (state.sort.length) { parts.push(constructSort(state.sort)); } if (state.find) { parts.push(constructFind(state.find)); } return parts.join('/'); } function decodeValue(str) { return decodeURIComponent(str); } function encodeValue(str) { var chars = '/&|()=*:'; ret = ''; str.split('').forEach(function(char) { var index = chars.indexOf(char); ret += index > -1 ? '%' + char.charCodeAt(0).toString(16).toUpperCase() : char; }); return ret; } function parseCondition(str) { var condition, operators = ['!==', '==', '!=', '=', '!<', '<', '!>', '>'], split; 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; } }); if (!condition.operator) { condition = {key: '*', value: str, operator: '='}; } if (['=', '!='].indexOf(condition.operator) > -1) { if (condition.value[0] == '*') { condition.value = condition.value.substr(1); condition.operator = condition.operator.replace('=', '$') } else if (condition.value[condition.value.length - 1] == '*') { condition.value = condition.value.substr(0, condition.value.length - 1); condition.operator = condition.operator.replace('=', '^') } } if (condition.value.indexOf(':') > -1) { condition.value = condition.value.split(':') } return condition; } function parseDuration(str) { var parts = str.split(':').reverse(); while (parts.length > 3) { parts.pop(); } return parts.reduce(function(prev, curr, i) { return prev + (parseFloat(curr) || 0) * Math.pow(60, i); }, 0); } function parseFind(str) { 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.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(Ox.sub(condition, 1, -1))]); } else { ret = parseCondition(condition); } return ret; }); } return find; } function parseSort(str) { return str.split(',').map(function(str) { var hasOperator = /^[\+-]/.test(str); return { key: hasOperator ? str.substr(1) : str, operator: hasOperator ? str[0] : Ox.getObjectById(self.options.sortKeys, str).operator }; }); } function parseSpan(str, callback) { return str.split(',').map(parseDuration); } function parseURL(str, callback) { str = str || document.location.pathname + document.location.search + document.location.hash; var parts = str.substr(1).split('/'), state = {}; if (parts.length == 0) { state.page = ''; } else if (self.options.pages.indexOf(parts[0]) > -1) { state.page = parts[0]; } else { if (self.options.types.indexOf(parts[0]) > -1) { state.type = parts[0]; parts.shift(); } if (parts.length) { if (self.options.views.list.indexOf(parts[0]) > -1) { state.item = ''; state.view = parts[0]; parts.shift(); parseBeyondItem(false); } else { self.options.getItemId(parts[0], function(itemId) { if (itemId) { state.item = itemId; parts.shift(); } parseBeyondItem(!!itemId); }); } } else { callback(state); } } function parseBeyondItem(itemId) { if (parts.length && itemId && self.options.views.item.indexOf(parts[0]) > -1) { state.view = parts[0]; parts.shift(); } if (parts.length) { if (parts.split(',').every(function(str) { return /^[0-9-\.:]+$/.test(str); })) { state.span = parseSpan(parts[0]); parts.shift() parseBeyondSpan(); } else { self.options.getSpanId(parts[0], function(spanId) { if (spanId) { state.span = spanId; parts.shift(); } parseBeyondSpan(); }); } } else { callback(state); } } function parseBeyondSpan() { if (parts.length && parts[0].split(',').every(function(str) { return self.sortKeyIds.indexOf(str.replace(/^[\+-]/, '')) > -1; })) { state.sort = parseSort(parts[0]); parts.shift(); } if (parts.length) { state.find = parseFind(parts.join('/')); } callback(state); } } that._constructURL = function(state) { return constructURL(state); }; that.parse = function(str, callback) { parseURL(str, callback); } that.pop = function() { }; that.push = function(url) { }; that.replace = function(url) { }; that.update = function(state) { // pushes a new URL, constructed from state // state can have type, item, view, span, sort, find }; return that; };