666 lines
No EOL
25 KiB
JavaScript
666 lines
No EOL
25 KiB
JavaScript
// vim: et:ts=4:sw=4:sts=4:ft=javascript
|
|
|
|
/*@
|
|
Ox.URL <f> URL controller
|
|
(options) -> <o> URL controller
|
|
options <o> Options object
|
|
findKeys <[o]> Find keys
|
|
id <s> Find key id
|
|
type <s> Value type ("string" or "number")
|
|
getItem <f> Tests if a string matches an item
|
|
(string, callback) -> <u> undefined
|
|
string <s> The string to be tested
|
|
callback <f> callback function
|
|
id <s> Matching item id, or empty
|
|
getSpan <f> Tests if a string matches a span
|
|
(item, view, string, callback) -> <u> undefined
|
|
item <s> The item id, or empty
|
|
view <s> The view, or empty
|
|
string <s> The string to be tested
|
|
callback <f> Callback function
|
|
id <s> Matching span id, or empty
|
|
view <s> Matching view, or empty
|
|
pages <[s]> List of pages
|
|
sortKeys <o> Sort keys for list and item views for all types
|
|
typeA <o> Sort keys for this type
|
|
list <o> Sort keys for list views for this type
|
|
viewA <[o]> Sort keys for this view
|
|
id <s> Sort key id
|
|
operator <s> Default sort operator ("+" or "-")
|
|
item <o> Sort keys for item views for this type
|
|
viewA <[o]> Sort keys for this view
|
|
id <s> Sort key id
|
|
operator <s> Default sort operator ("+" or "-")
|
|
spanType <o> Span types for list and item views for all types
|
|
typeA <o> Span types for this type
|
|
list <o> Span types for list views for this type
|
|
viewA <s> Span type for this view
|
|
Can be "date", "duration" or "location"
|
|
item <o> Span types for item views for this type
|
|
viewA <s> Span type for this view
|
|
Can be "date", "duration" or "location"
|
|
types <[s]> List of types
|
|
views <o> List and item views for all types
|
|
typeA <o> Views for type "typeA"
|
|
list <[s]> List views for this type
|
|
item <[s]> Item views for this type
|
|
@*/
|
|
|
|
/*
|
|
|
|
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)
|
|
k<v k v < is less than (number)
|
|
k!<v k v !< is not less than (number)
|
|
k>v 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), 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/paris -> example.com/map/ABC/paris
|
|
paris matches a place name (case-insensitive), so we get map view, zoomed to
|
|
Paris, 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: [],
|
|
getItem: null,
|
|
getSpan: null,
|
|
pages: [],
|
|
spanType: {},
|
|
sortKeys: {},
|
|
types: [],
|
|
views: {}
|
|
}, options);
|
|
|
|
Ox.print('Ox.URL options', self.options)
|
|
|
|
self.previousURL = '';
|
|
|
|
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 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) {
|
|
var ret;
|
|
if (condition.conditions) {
|
|
ret = '(' + constructFind(condition) + ')';
|
|
} else {
|
|
ret = constructCondition(condition);
|
|
}
|
|
return ret;
|
|
}).join(find.operator);
|
|
}
|
|
|
|
function constructLocation(location) {
|
|
return location.join(',');
|
|
}
|
|
|
|
function constructSort(sort, state) {
|
|
var sortKeys = self.options.sortKeys[state.type][
|
|
!state.item ? 'list' : 'item'
|
|
][state.view];
|
|
return sort.map(function(sort) {
|
|
return (
|
|
Ox.getObjectById(sortKeys, sort.key).operator == sort.operator
|
|
? '' : sort.operator
|
|
) + sort.key;
|
|
}).join(',');
|
|
}
|
|
|
|
function constructSpan(span, state) {
|
|
var spanType = self.options.spanType[state.type][
|
|
!state.item ? 'list' : 'item'
|
|
][state.view];
|
|
return (Ox.isArray(span) ? span : [span]).map(function(point) {
|
|
return Ox.isNumber(point) ? (
|
|
spanType == 'date' ? constructDate(point)
|
|
: spanType == 'duration' ? constructDuration(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 '/' + parts.join('/');
|
|
}
|
|
|
|
function decodeValue(str) {
|
|
return decodeURIComponent(str);
|
|
}
|
|
|
|
function encodeValue(str) {
|
|
// var chars = '/&|()=*:';
|
|
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 isNumericalSpan(str) {
|
|
return str.split(',').every(function(str) {
|
|
return /^[0-9-\.:]+$/.test(str);
|
|
});
|
|
}
|
|
|
|
function getSpanType(str, types) {
|
|
Ox.print('getSpanType', str, types)
|
|
var canBeDate = types.indexOf('date') > -1,
|
|
canBeDuration = types.indexOf('duration') > -1,
|
|
canBeLocation = types.indexOf('location') > -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' : ':'
|
|
}
|
|
|
|
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
|
|
|| Ox.getPositionById(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.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(':').map(decodeValue).join(':');
|
|
} else {
|
|
condition.value = decodeValue(condition.value);
|
|
}
|
|
return condition;
|
|
}
|
|
|
|
function parseDate(str) {
|
|
return Ox.formatDate(Ox.parseDate(str, true), '%Y-%m-%d');
|
|
}
|
|
|
|
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 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.substr(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
|
|
: parseLocation
|
|
);
|
|
}
|
|
|
|
function parseURL(str, callback) {
|
|
Ox.print('pU', str)
|
|
var parts = str.substr(1).split('/'),
|
|
state = {};
|
|
if (parts[0] == '') {
|
|
// empty URL
|
|
callback(state);
|
|
} else if (self.options.pages.indexOf(parts[0]) > -1) {
|
|
// page
|
|
state.page = parts[0];
|
|
callback(state);
|
|
} 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.print('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(parts[0].replace(/%20/g, ' '), function(item) {
|
|
state.item = item;
|
|
if (item) {
|
|
parts.shift();
|
|
}
|
|
parseBeyondItem();
|
|
});
|
|
}
|
|
} else {
|
|
callback(state);
|
|
}
|
|
}
|
|
function parseBeyondItem() {
|
|
Ox.print('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.print('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;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (!state.span && /^[A-Z@]/.test(parts[0])) {
|
|
// test for span id or name
|
|
self.options.getSpan(state.item, state.view, parts[0], function(span, view) {
|
|
if (span) {
|
|
if (!state.view) {
|
|
// set list or item view
|
|
state.view = view;
|
|
}
|
|
state.span = span;
|
|
parts.shift();
|
|
}
|
|
parseBeyondSpan();
|
|
});
|
|
} else {
|
|
parseBeyondSpan();
|
|
}
|
|
} else {
|
|
callback(state);
|
|
}
|
|
}
|
|
function parseBeyondSpan() {
|
|
Ox.print('pBS', state)
|
|
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;
|
|
}
|
|
}
|
|
);
|
|
}
|
|
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('/'));
|
|
}
|
|
callback(state);
|
|
}
|
|
}
|
|
|
|
function saveURL() {
|
|
self.previousURL = document.location.pathname
|
|
+ document.location.search
|
|
+ document.location.hash;
|
|
}
|
|
|
|
that._construct = function(state) {
|
|
return constructURL(state);
|
|
};
|
|
|
|
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, function(state) {
|
|
that.replace(constructURL(state));
|
|
callback(state);
|
|
});
|
|
*/
|
|
parseURL(str, callback);
|
|
return that;
|
|
}
|
|
|
|
/*@
|
|
pop <f> Sets the URL to the previous URL
|
|
@*/
|
|
that.pop = function() {
|
|
history.pushState(self.previousURL);
|
|
return that;
|
|
};
|
|
|
|
/*@
|
|
push <f> Pushes a new URL
|
|
(state, title, url, callback) -> <o> URL controller
|
|
state <o> State for the new URL
|
|
title <s> Title for the new URL
|
|
url <s|o> New URL, or state object to construct it from
|
|
This state object can be different from the first one
|
|
(for example: save full state, create URL from reduced state)
|
|
callback <f> callback function
|
|
state <o> New state
|
|
@*/
|
|
that.push = function(state, title, url, callback) {
|
|
if (Ox.isString(url)) {
|
|
if (state) {
|
|
pushState(state, title, url);
|
|
} else {
|
|
parseURL(url, function(state) {
|
|
pushState(state, title, url);
|
|
});
|
|
}
|
|
} else {
|
|
url = constructURL(url);
|
|
pushState(state, title, url);
|
|
}
|
|
function pushState(state, title, url) {
|
|
history.pushState(state, title, url);
|
|
callback && callback(state);
|
|
}
|
|
}
|
|
|
|
/*@
|
|
replace <f> Replaces the URL with a new URL
|
|
(state, title, url, callback) -> <o> URL controller
|
|
state <o> State for the new URL
|
|
title <s> Title for the new URL
|
|
url <s|o> New URL, or state object to construct it from
|
|
callback <f> callback function
|
|
state <o> New state
|
|
@*/
|
|
that.replace = function(state, title, url, callback) {
|
|
if (Ox.isString(url)) {
|
|
if (state) {
|
|
replaceState(state, title, url);
|
|
} else {
|
|
parseURL(url, function(state) {
|
|
replaceState(state, title, url);
|
|
});
|
|
}
|
|
} else {
|
|
url = constructURL(url);
|
|
replaceState(state, title, url);
|
|
}
|
|
function replaceState(state, title, url) {
|
|
history.replaceState(state, title, url);
|
|
callback && callback(state);
|
|
}
|
|
}
|
|
|
|
return that;
|
|
|
|
}; |