-
Notifications
You must be signed in to change notification settings - Fork 643
Expand file tree
/
Copy pathTimelineConfig.js
More file actions
383 lines (336 loc) · 13.2 KB
/
TimelineConfig.js
File metadata and controls
383 lines (336 loc) · 13.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
import { sortByDate, SCALE_DATE_CLASSES } from "../date/DateUtil"
import { BigDate } from "../date/TLDate"
import { trim, ensureUniqueKey, slugify, unique_ID, trace, stripMarkup } from "../core/Util"
import TLError from "../core/TLError"
import DOMPurify from 'dompurify';
const SANITIZE_FIELDS = {
text: ['headline', 'text'],
media: ['caption', 'credit'] // media "URL" must be sanitized in Media classes to avoid messing up URLs
}
const STRIP_MARKUP_FIELDS = {
start_date: ['display_date'],
end_date: ['display_date'],
slide: ['display_date', 'group'],
date: ['display_date']
}
/**
* After sanitizing, make sure all <a> tags with 'href' attributes that
* don't have a target attribute are set to open in a new ('_blank')
* window. Also make sure that all <a> tags which are set to open in a '_blank'
* window set `rel="noopener"`
*/
DOMPurify.addHook('afterSanitizeAttributes', function(node) {
if (node.nodeName == 'A' && 'href' in node) {
if (!('target' in node.attributes)) {
node.setAttribute('target', '_blank');
}
let rel = node.attributes['rel']
if (!rel) {
node.setAttribute('rel', 'noopener');
} else {
if (rel.value.indexOf('noopener') == -1) {
node.setAttribute('rel', `noopener ${rel.value}`)
}
}
}
});
function _process_fields(slide, callback, fieldmap) {
Object.keys(fieldmap).forEach(k => {
var to_sanitize = (k == 'slide') ? slide : slide[k]
if (to_sanitize) {
fieldmap[k].forEach(i => {
if (typeof(to_sanitize[i]) != 'undefined') {
to_sanitize[i] = callback(to_sanitize[i])
}
})
}
})
}
/**
* Centralize use of HTML sanitizer so that we can enforce common
* rules. Maybe we would want to push this to Util and unit test
* but ultimately we're trusting the creators of the library.
* @param {string} txt
*/
function _tl_sanitize(txt) {
return DOMPurify.sanitize(txt, {
ADD_TAGS: ['iframe'],
ADD_ATTR: ['frameborder', 'target'],
})
}
export class TimelineConfig {
constructor(data) {
this.title = '';
this.scale = '';
this.events = [];
this.eras = [];
this.event_dict = {}; // despite name, all slides (events + title) indexed by slide.unique_id
this.messages = {
errors: [],
warnings: []
};
// Initialize the data
if (typeof data === 'object' && data.events) {
this.scale = data.scale;
this.events = [];
this._ensureValidScale(data.events);
if (data.title) {
// the 'title' is a kind of slide, one without a date
var title_id = this._assignID(data.title);
this._tidyFields(data.title);
this.title = data.title;
this.event_dict[title_id] = this.title;
}
for (var i = 0; i < data.events.length; i++) {
try {
this.addEvent(data.events[i], true);
} catch (e) {
this.logError(e);
}
}
if (data.eras) {
data.eras.forEach((era_data, indexOf) => {
try {
this.addEra(era_data)
} catch (e) {
this.logError("Era " + indexOf + ": " + e);
}
})
}
sortByDate(this.events);
sortByDate(this.eras);
}
}
logError(msg) {
trace(`logError: ${msg}`);
this.messages.errors.push(msg);
}
/*
* Return any accumulated error messages. If `sep` is passed, it should be a string which will be used to join all messages, resulting in a string return value. Otherwise,
* errors will be returned as an array.
*/
getErrors(sep) {
if (sep) {
return this.messages.errors.join(sep);
} else {
return this.messages.errors;
}
}
/*
* Perform any sanity checks we can before trying to use this to make a timeline. Returns nothing, but errors will be logged
* such that after this is called, one can test `this.isValid()` to see if everything is OK.
*/
validate() {
if (typeof(this.events) == "undefined" || typeof(this.events.length) == "undefined" || this.events.length == 0) {
this.logError("Timeline configuration has no events.")
}
// make sure all eras have start and end dates
for (var i = 0; i < this.eras.length; i++) {
if (typeof(this.eras[i].start_date) == 'undefined' || typeof(this.eras[i].end_date) == 'undefined') {
var era_identifier;
if (this.eras[i].headline) {
era_identifier = this.eras[i].headline
} else {
era_identifier = "era " + (i + 1);
}
this.logError("All eras must have start and end dates. [" + era_identifier + "]") // add internationalization (I18N) and context
}
};
}
/**
* @returns {boolean} whether or not this config has logged errors.
*/
isValid() {
return this.messages.errors.length == 0;
}
/* Add an event (including cleaning/validation) and return the unique id.
* All event data validation should happen in here.
* Throws: TLError for any validation problems.
*/
addEvent(data, defer_sort) {
var event_id = this._assignID(data);
if (typeof(data.start_date) == 'undefined') {
trace("Missing start date, skipping event")
return null
}
this._processDates(data);
this._tidyFields(data);
this.events.push(data);
this.event_dict[event_id] = data;
if (!defer_sort) {
sortByDate(this.events);
}
return event_id;
}
addEra(data) {
var event_id = this._assignID(data);
if (typeof(data.start_date) == 'undefined') {
throw new TLError("missing_start_date_err", event_id);
}
this._processDates(data);
this._tidyFields(data);
this.eras.push({
start_date: data.start_date,
end_date: data.end_date,
headline: data.text.headline
});
}
/**
* Given a slide, verify that its ID is unique, or assign it one which is.
* The assignment happens in this function, and the assigned ID is also
* the return value. Not thread-safe, because ids are not reserved
* when assigned here.
*/
_assignID(slide) {
var slide_id = slide.unique_id;
if (!trim(slide_id)) {
// give it an ID if it doesn't have one
slide_id = (slide.text) ? slugify(slide.text.headline) : null;
}
// make sure it's unique and add it.
slide.unique_id = ensureUniqueKey(this.event_dict, slide_id);
return slide.unique_id
}
/**
* Given an array of slide configs (the events), ensure that each one has a distinct unique_id. The id of the title
* is also passed in because in most ways it functions as an event slide, and the event IDs must also all be unique
* from the title ID.
*/
_makeUniqueIdentifiers(title_id, array) {
var used = [title_id];
// establish which IDs are assigned and if any appear twice, clear out successors.
for (var i = 0; i < array.length; i++) {
if (trim(array[i].unique_id)) {
array[i].unique_id = slugify(array[i].unique_id); // enforce valid
if (used.indexOf(array[i].unique_id) == -1) {
used.push(array[i].unique_id);
} else { // it was already used, wipe it out
array[i].unique_id = '';
}
}
};
if (used.length != (array.length + 1)) {
// at least some are yet to be assigned
for (var i = 0; i < array.length; i++) {
if (!array[i].unique_id) {
// use the headline for the unique ID if it's available
var slug = (array[i].text) ? slugify(array[i].text.headline) : null;
if (!slug) {
slug = unique_ID(6); // or generate a random ID
}
if (used.indexOf(slug) != -1) {
slug = slug + '-' + i; // use the index to get a unique ID.
}
used.push(slug);
array[i].unique_id = slug;
}
}
}
}
_ensureValidScale(events) {
if (!this.scale) {
this.scale = "human"; // default to human unless there's a slide which is explicitly 'cosmological' or one which has a cosmological year
for (var i = 0; i < events.length; i++) {
if (events[i].scale == 'cosmological') {
this.scale = 'cosmological';
break;
}
if (events[i].start_date && typeof(events[i].start_date.year) != "undefined") {
var d = new BigDate(events[i].start_date);
var year = d.data.date_obj.year;
if (year < -271820 || year > 275759) {
this.scale = "cosmological";
break;
}
}
}
trace(`Determining scale dynamically: ${this.scale}`);
}
var dateCls = SCALE_DATE_CLASSES[this.scale];
if (!dateCls) { this.logError("Don't know how to process dates on scale " + this.scale); }
}
/*
Given a thing which has a start_date and optionally an end_date, make sure that it is an instance
of the correct date class (for human or cosmological scale). For slides, remove redundant end dates
(people frequently configure an end date which is the same as the start date).
*/
_processDates(slide_or_era) {
var dateCls = SCALE_DATE_CLASSES[this.scale];
if (!(slide_or_era.start_date instanceof dateCls)) {
var start_date = slide_or_era.start_date;
slide_or_era.start_date = new dateCls(start_date);
// eliminate redundant end dates.
if (typeof(slide_or_era.end_date) != 'undefined' && !(slide_or_era.end_date instanceof dateCls)) {
var end_date = slide_or_era.end_date;
var equal = true;
for (let property in start_date) {
equal = equal && (start_date[property] == end_date[property]);
}
if (equal) {
trace("End date same as start date is redundant; dropping end date");
delete slide_or_era.end_date;
} else {
slide_or_era.end_date = new dateCls(end_date);
}
}
}
}
/**
* Return the earliest date that this config knows about, whether it's a slide or an era
*/
getEarliestDate() {
// counting that dates were sorted in initialization
var date = this.events[0].start_date;
if (this.eras && this.eras.length > 0) {
if (this.eras[0].start_date.isBefore(date)) {
return this.eras[0].start_date;
}
}
return date;
}
/**
* Return the latest date that this config knows about, whether it's a slide or an era, taking end_dates into account.
*/
getLatestDate() {
var dates = [];
for (var i = 0; i < this.events.length; i++) {
if (this.events[i].end_date) {
dates.push({ date: this.events[i].end_date });
} else {
dates.push({ date: this.events[i].start_date });
}
}
for (var i = 0; i < this.eras.length; i++) {
if (this.eras[i].end_date) {
dates.push({ date: this.eras[i].end_date });
} else {
dates.push({ date: this.eras[i].start_date });
}
}
sortByDate(dates, 'date');
return dates.slice(-1)[0].date;
}
/**
* Do some simple cleanup for all slides and eras, including sanitizing
* HTML input, or stripping markup for fields which are not intended to support
* it.
* @param { Slide | TimeEra } slide
*/
_tidyFields(slide) {
function fillIn(obj, key, default_value) {
if (!default_value) default_value = '';
if (!obj.hasOwnProperty(key)) { obj[key] = default_value }
}
if (slide.group) {
slide.group = trim(slide.group);
}
if (!slide.text) {
slide.text = {};
}
fillIn(slide.text, 'text');
fillIn(slide.text, 'headline');
_process_fields(slide, _tl_sanitize, SANITIZE_FIELDS)
// handle media.url separately
_process_fields(slide, stripMarkup, STRIP_MARKUP_FIELDS)
}
}