data.ts 29.2 KB
Newer Older
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/*******************************************************************************
 * Copyright 2003-2018 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
 * contact http://www.logilab.fr/ -- mailto:contact@logilab.fr
 *
 * This file is part of CubicWeb.
 *
 * CubicWeb is free software: you can redistribute it and/or modify it under the
 * terms of the GNU Lesser General Public License as published by the Free
 * Software Foundation, either version 2.1 of the License, or (at your option)
 * any later version.
 *
 * CubicWeb is distributed in the hope that it will be useful, but WITHOUT
 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
 * FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for more
 * details.
 *
 * You should have received a copy of the GNU Lesser General Public License along
 * with CubicWeb.  If not, see <http://www.gnu.org/licenses/>.
 ******************************************************************************/

Laurent Wouters's avatar
Laurent Wouters committed
21
import { application } from "@logilab/libview";
22

23
24
25
26
27
export function n<X>(i: X | null): X {
  if (i !== null) return i;
  throw new Error("Expected DOM node is null!");
}

Laurent Wouters's avatar
Laurent Wouters committed
28
29
30
31
/**
 * Metadata about a MIME type
 */
export class MimeInfo {
32
33
34
35
36
37
38
39
40
41
42
43
  constructor(
    mime: string,
    name: string,
    priority: number,
    fileExtension: string
  ) {
    this.mime = mime;
    this.name = name;
    this.priority = priority;
    this.fileExtension = fileExtension;
  }

Laurent Wouters's avatar
Laurent Wouters committed
44
45
46
47
48
49
50
51
52
53
54
55
  /**
   * The MIME type
   */
  mime: string;
  /**
   * The user-readable name
   */
  name: string;
  /**
   * The relative priority of using this MIME type (lower is better)
   */
  priority: number;
56
57
58
59
  /**
   * The associated file extension
   */
  fileExtension: string;
Laurent Wouters's avatar
Laurent Wouters committed
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
}

/**
 * A tag for a link
 */
export class Tag {
  name: string;
  value: string;
}

/**
 * A link for a related resource
 */
export class Link {
  constructor(url: string, tags: Tag[]) {
    this.url = url;
    this.tags = {};
77
    for (let i = 0; i !== tags.length; i++) {
Laurent Wouters's avatar
Laurent Wouters committed
78
79
80
81
82
83
84
85
86
87
88
      this.tags[tags[i].name] = tags[i].value;
    }
  }
  /**
   * The link's target
   */
  url: string;
  /**
   * The various tags for this link
   */
  tags: { [tag: string]: string };
89
}
Laurent Wouters's avatar
Laurent Wouters committed
90

91
92
93
94
95
96
97
98
/**
 * Determines whether this link is appropriate as a linked data source
 * @param link The link to inspect
 */
export function refersToData(link: Link): boolean {
  return (
    link.tags.hasOwnProperty("rel") &&
    link.tags.hasOwnProperty("type") &&
99
    link.tags.rel === "alternate" &&
100
101
102
    MIME.hasOwnProperty(link.tags.type)
  );
}
103

104
105
106
107
108
109
/**
 * Determines whether this link refers to the primary topic on a page
 * @param link The link to inspect
 */
export function refersToPrimaryTopic(link: Link): boolean {
  return (
110
    (link.tags.hasOwnProperty("rev") &&
111
112
113
      "describedBy".toLocaleLowerCase() ===
        link.tags.rev.toLocaleLowerCase()) ||
    (link.tags.hasOwnProperty("rel") && link.tags.rel === "bookmark")
114
  );
Laurent Wouters's avatar
Laurent Wouters committed
115
116
}

Laurent Wouters's avatar
Laurent Wouters committed
117
118
119
120
121
122
123
/**
 * Parses the tags for a link
 * @param content The link description
 */
export function parseLinkTags(content: string): Tag[] {
  let tags: Tag[] = [];
  let regexp = RegExp("([a-zA-Z_0-9]+)\\s*=\\s*('[^']*'|\"[^\"]*\")", "g");
124
  let match = regexp.exec(content);
125
  while (match !== null) {
Laurent Wouters's avatar
Laurent Wouters committed
126
127
128
129
    tags.push({
      name: match[1],
      value: match[2].substring(1, match[2].length - 1)
    });
130
    match = regexp.exec(content);
Laurent Wouters's avatar
Laurent Wouters committed
131
132
133
134
135
136
137
138
139
140
141
142
143
144
  }
  return tags;
}

/**
 * Parses the content of a link header
 * @param content The content to parse
 */
export function parseLinks(content: string): Link[] {
  let links: Link[] = [];
  let regexp = RegExp(
    "<([^>]*)>(?:\\s*;\\s*([a-zA-Z_0-9]+)\\s*=\\s*('[^']*'|\"[^\"]*\"))*",
    "g"
  );
145
  let match = regexp.exec(content);
146
  while (match !== null) {
Laurent Wouters's avatar
Laurent Wouters committed
147
148
149
    let tags = parseLinkTags(match[0]);
    let link = new Link(match[1], tags);
    links.push(link);
150
    match = regexp.exec(content);
Laurent Wouters's avatar
Laurent Wouters committed
151
152
153
154
155
156
157
158
  }
  return links;
}

/**
 * Detects the primary topic in the links
 * @param links The links in the header
 */
159
160
161
162
163
export function detectTopicOnlinks(links: Link[]): string {
  for (let i = 0; i !== links.length; i++) {
    if (refersToPrimaryTopic(links[i])) return links[i].url;
  }
  return "";
Laurent Wouters's avatar
Laurent Wouters committed
164
165
166
167
168
}

/**
 * Detects linked data in HTTP headers
 * @param links The links in the header
169
 * @param origin The origin for the links
Laurent Wouters's avatar
Laurent Wouters committed
170
 */
171
172
173
174
export function detectDataOnLinks(
  links: Link[],
  origin: Origin
): DocumentSourceLinked[] {
Laurent Wouters's avatar
Laurent Wouters committed
175
176
  return links
    .filter((link: Link) => refersToData(link))
177
    .map((link: Link) => new DocumentSourceLinked(link, origin));
Laurent Wouters's avatar
Laurent Wouters committed
178
179
180
181
182
183
184
185
186
187
188
}

/**
 * Finds the relevant links in an HTML document
 * @param document The document to inspect
 * @param url The document URL
 * @param primaryTopic The detected primary topic, if any
 */
export function findLinksInDocument(
  document: HTMLDocument,
  url: string,
189
  primaryTopic: string
Laurent Wouters's avatar
Laurent Wouters committed
190
191
192
): Link[] {
  let links: Link[] = [];
  let resource = primaryTopic;
193
  if (resource.length === 0) {
Laurent Wouters's avatar
Laurent Wouters committed
194
    resource = url;
195
  }
Laurent Wouters's avatar
Laurent Wouters committed
196

197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
  let elementsLink = n(document.head).getElementsByTagName("link");
  for (let i = 0; i !== elementsLink.length; i++) {
    let element = n(elementsLink.item(i));

    let rel = element.getAttribute("rel");
    let type = element.type;
    let href = element.href;
    if (rel === "meta") {
      links.push(
        new Link(href, [
          { name: "type", value: type },
          { name: "rel", value: "alternate" }
        ])
      );
    } else if (rel === "bookmark") {
      links.push(new Link(href, [{ name: "rel", value: "bookmark" }]));
Laurent Wouters's avatar
Laurent Wouters committed
213
214
215
216
    }
  }

  let elementsA = document.body.getElementsByTagName("a");
217
  for (let i = 0; i !== elementsA.length; i++) {
218
    let element = n(elementsA.item(i));
Laurent Wouters's avatar
Laurent Wouters committed
219
220
    if (!element.href.startsWith(resource)) continue;
    let mimes = Object.keys(MIME).map((key: string) => MIME[key]);
221
    for (let j = 0; j !== mimes.length; j++) {
222
223
224
      let info = mimes[j];
      if (info === undefined) continue;
      if (element.href.endsWith(info.fileExtension)) {
Laurent Wouters's avatar
Laurent Wouters committed
225
226
        links.push(
          new Link(element.href, [
227
            { name: "type", value: info.mime },
Laurent Wouters's avatar
Laurent Wouters committed
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
            { name: "rel", value: "alternate" }
          ])
        );
        break;
      }
    }
  }
  return links;
}

/**
 * A typed raw content
 */
export interface RawContent {
  /**
   * The MIME type
   */
  contentType: string;
  /**
   * The raw content
   */
  content: string;
}

/**
 * Content that can be observed for the detection of linked data
 */
export interface ObservableContent extends RawContent {
  /**
   * The URL of the content
   */
  url: string;
  /**
   * The value for the Link HTTP header
   */
  linkHeader: string;
}

/**
 * Gets some observable content at a target URI
 * @param target The target URI
 */
export function fetchObservableAt(target: string): Promise<ObservableContent> {
  return new Promise<ObservableContent>(
    (
      resolve: (result: ObservableContent) => void,
      reject: (reason: any) => void
    ) => {
      let xmlHttp = new XMLHttpRequest();
      xmlHttp.onreadystatechange = function() {
278
279
        if (xmlHttp.readyState === 4) {
          if (xmlHttp.status < 200 || xmlHttp.status >= 300) {
Laurent Wouters's avatar
Laurent Wouters committed
280
            return reject("HTTP error: " + xmlHttp.status);
281
          }
Laurent Wouters's avatar
Laurent Wouters committed
282
          let contentType = xmlHttp.getResponseHeader("Content-Type");
283
284
285
          if (contentType === null) {
            return reject("No Content-Type header");
          }
Laurent Wouters's avatar
Laurent Wouters committed
286
287
          let index = contentType.indexOf(";");
          if (index > 0) contentType = contentType.substring(0, index);
288
          let linkHeader = xmlHttp.getResponseHeader("Link");
289
          return resolve({
Laurent Wouters's avatar
Laurent Wouters committed
290
291
292
            contentType: contentType,
            content: xmlHttp.responseText,
            url: target,
293
            linkHeader: linkHeader !== null ? linkHeader : ""
Laurent Wouters's avatar
Laurent Wouters committed
294
295
296
          });
        }
      };
Laurent Wouters's avatar
Laurent Wouters committed
297
      xmlHttp.open("GET", target, true);
298
      xmlHttp.setRequestHeader("Accept", getAcceptRdf(true));
Laurent Wouters's avatar
Laurent Wouters committed
299
300
301
302
303
      xmlHttp.send();
    }
  );
}

Laurent Wouters's avatar
Laurent Wouters committed
304
305
306
/**
 * Map of known MIME types to badge names
 */
307
export const MIME: { [mime: string]: MimeInfo | undefined } = {
308
309
310
311
312
313
314
315
316
317
318
319
  "text/n3": new MimeInfo("text/n3", "N3", 5, ".n3"),
  "application/n-triples": new MimeInfo(
    "application/n-triples",
    "NT",
    6,
    ".nt"
  ),
  "application/n-quads": new MimeInfo("application/n-quads", "NQ", 7, ".nq"),
  "text/turtle": new MimeInfo("text/turtle", "TTL", 8, ".ttl"),
  "application/trig": new MimeInfo("application/trig", "TRIG", 9, ".trig"),
  "application/rdf+xml": new MimeInfo("application/rdf+xml", "RDF", 10, ".xml"),
  "application/ld+json": new MimeInfo("application/ld+json", "LD", 11, ".json")
Laurent Wouters's avatar
Laurent Wouters committed
320
321
};

322
323
324
325
326
327
328
329
330
331
332
333
334
335
/**
 * Gets the value of the Accept header for requesting RDF
 * @param includeHTML Whether to also require HTML (at a low priority)
 */
export function getAcceptRdf(includeHTML: boolean): string {
  return (
    Object.keys(MIME)
      .map(key => {
        return MIME[key];
      })
      .sort((x: MimeInfo, y: MimeInfo) => {
        return x.priority - y.priority;
      })
      .reduce((acc: string, mime: MimeInfo, index: number) => {
336
        if (acc.length === 0) return mime.mime;
337
338
339
340
341
        return acc + ", " + mime.mime + ";q=" + (1 - 0.1 * index).toString();
      }, "") + (includeHTML ? ", text/html;q=0.1" : "")
  );
}

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
/**
 * Describes the kind of origin for a document
 */
export enum OriginKind {
  /**
   * The document or source is linked from the HTTP headers
   */
  HttpLink,
  /**
   * The document or source is linked from HTML content
   */
  HtmlLink,
  /**
   * The document or source has been obtained through negotiation
   */
  Negotiated,
  /**
   * The document or source has been obtained through a redirection
   */
  Redirected,
  /**
   * The document or source is the direct response from the server
   */
  Direct,
  /**
   * An unknown origin
   */
  Unkown
}

372
373
374
375
376
377
378
/**
 * Describes the origin of a document or source
 */
export interface Origin {
  /**
   * The kind of origin
   */
379
  kind: OriginKind;
380
381
382
383
384
385
  /**
   * The associated URI
   */
  url: string;
}

Laurent Wouters's avatar
Laurent Wouters committed
386
/**
387
 * A source for a document
Laurent Wouters's avatar
Laurent Wouters committed
388
 */
389
export interface DocumentSource {
390
391
392
393
  /**
   * The origin of this source
   */
  origin: Origin;
Laurent Wouters's avatar
Laurent Wouters committed
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
  /**
   * A descriptor of the type of source
   */
  sourceType: string;
  /**
   * The human-readable source of this data
   */
  name: string;
  /**
   * The content's url
   */
  url: string;
  /**
   * MIME type for the content
   */
  contentType: string;
  /**
   * The relative priority of this source (lower is better)
   */
  priority: number;
414
415
416
417
  /**
   * The number of statements loaded from this source
   */
  loaded: number;
Laurent Wouters's avatar
Laurent Wouters committed
418
419
420
421
422
}

/**
 * No data source
 */
423
export class DocumentSourceNone implements DocumentSource {
Laurent Wouters's avatar
Laurent Wouters committed
424
  constructor() {
425
    this.origin = { kind: OriginKind.Unkown, url: "" };
426
    this.sourceType = "DocumentSourceNone";
427
    this.name = "No data";
Laurent Wouters's avatar
Laurent Wouters committed
428
429
430
    this.url = "";
    this.contentType = "";
    this.priority = Number.MAX_SAFE_INTEGER;
431
    this.loaded = 0;
Laurent Wouters's avatar
Laurent Wouters committed
432
  }
433
  origin: Origin;
Laurent Wouters's avatar
Laurent Wouters committed
434
435
436
437
438
  sourceType: string;
  name: string;
  url: string;
  contentType: string;
  priority: number;
439
  loaded: number;
Laurent Wouters's avatar
Laurent Wouters committed
440
441
}

Laurent Wouters's avatar
Laurent Wouters committed
442
443
444
/**
 * A data source with inline content
 */
445
export class DocumentSourceInline implements DocumentSource {
446
447
  constructor(url: string, mime: MimeInfo, content: string, origin: Origin) {
    this.origin = origin;
448
    this.sourceType = "DocumentSourceInline";
449
    this.name = "Inline content (" + url + ")";
Laurent Wouters's avatar
Laurent Wouters committed
450
451
452
    this.url = url;
    this.contentType = mime.mime;
    this.priority = mime.priority;
453
    this.loaded = 0;
Laurent Wouters's avatar
Laurent Wouters committed
454
455
    this.content = content;
  }
456
  origin: Origin;
Laurent Wouters's avatar
Laurent Wouters committed
457
458
459
460
461
  sourceType: string;
  name: string;
  url: string;
  contentType: string;
  priority: number;
462
  loaded: number;
Laurent Wouters's avatar
Laurent Wouters committed
463
464
465
466
467
468
  /**
   * The inline content
   */
  content: string;
}

469
470
471
/**
 * The data about a page
 */
472
export class DocumentSourcePage implements DocumentSource {
473
  constructor(url: string, contentType: string, origin: Origin) {
474
    let mime = MIME[contentType];
475
    this.origin = origin;
476
    this.sourceType = "DocumentSourcePage";
477
    this.name =
478
      "Page's content" + (mime !== undefined ? " (" + mime.name + ")" : "");
479
480
    this.url = url;
    this.contentType = contentType;
Laurent Wouters's avatar
Laurent Wouters committed
481
    this.priority = 1;
482
    this.loaded = 0;
483
  }
484
  origin: Origin;
Laurent Wouters's avatar
Laurent Wouters committed
485
486
  sourceType: string;
  name: string;
487
488
  url: string;
  contentType: string;
Laurent Wouters's avatar
Laurent Wouters committed
489
  priority: number;
490
  loaded: number;
491
492
493
494
495
}

/**
 * Get a promise to fetch the content of the current document
 */
496
function doFetchDocumentPage(page: DocumentSourcePage): Promise<RawContent> {
497
  return new Promise(function(resolve, reject) {
498
    for (let i = 0; i !== document.body.children.length; i++) {
499
      let child = document.body.children[i];
500
      if (child.tagName === "PRE") {
501
        let content = "";
502
        for (let j = 0; j !== child.childNodes.length; j++) {
503
504
505
506
507
508
509
510
511
          content += (child.childNodes[j] as any).wholeText;
        }
        return resolve({
          contentType: page.contentType,
          content: content
        });
      }
    }
    return resolve({
512
      contentType: page.contentType,
513
      content: document.body.innerHTML
514
515
516
517
    });
  });
}

518
519
520
/**
 * A link for a related resource
 */
521
export class DocumentSourceLinked implements DocumentSource {
522
  constructor(link: Link, origin: Origin) {
Laurent Wouters's avatar
Laurent Wouters committed
523
    let mime = MIME[link.tags.type];
524
    if (mime === undefined) {
525
526
      throw new Error("Unrecognized link type");
    }
Laurent Wouters's avatar
Laurent Wouters committed
527
    this.tags = link.tags;
528
    this.origin = origin;
529
    this.sourceType = "DocumentSourceLinked";
530
    this.name = "Linked content" + mime.name + " (" + link.url + ")";
Laurent Wouters's avatar
Laurent Wouters committed
531
532
533
    this.url = link.url;
    this.contentType = mime.mime;
    this.priority = mime.priority;
534
    this.loaded = 0;
535
536
537
538
539
  }
  /**
   * The various tags for this link
   */
  tags: { [tag: string]: string };
540
  origin: Origin;
Laurent Wouters's avatar
Laurent Wouters committed
541
542
543
544
545
  sourceType: string;
  name: string;
  url: string;
  contentType: string;
  priority: number;
546
  loaded: number;
547
548
549
550
551
}

/**
 * Get a promise to fetch content at a URI
 */
Laurent Wouters's avatar
Laurent Wouters committed
552
function doFetchDocumentLink(link: DocumentSourceLinked): Promise<RawContent> {
553
554
555
  return new Promise(function(resolve, reject) {
    let xmlHttp = new XMLHttpRequest();
    xmlHttp.onreadystatechange = function() {
556
      if (xmlHttp.readyState === 4) {
557
        if (xmlHttp.status < 200 || xmlHttp.status >= 300) {
558
          return reject("HTTP error: " + xmlHttp.status);
559
560
        }
        let ct = xmlHttp.getResponseHeader("Content-Type");
561
562
563
        if (ct === null) {
          return reject("No Content-Type header");
        }
564
565
566
567
568
569
570
        resolve({
          contentType: ct,
          content: xmlHttp.responseText
        });
      }
    };
    xmlHttp.open("GET", link.url, true);
Laurent Wouters's avatar
Laurent Wouters committed
571
    xmlHttp.setRequestHeader("Accept", link.contentType);
572
573
    xmlHttp.send();
  });
574
575
576
}

/**
Laurent Wouters's avatar
Laurent Wouters committed
577
 * Fetches this data
578
 */
579
export function fetchDocument(source: DocumentSource): Promise<RawContent> {
580
  if (source.sourceType === "DocumentSourceNone") {
Laurent Wouters's avatar
Laurent Wouters committed
581
    return new Promise((resolve, reject) => reject("No data"));
582
  } else if (source.sourceType === "DocumentSourceInline") {
583
    let inline = source as DocumentSourceInline;
Laurent Wouters's avatar
Laurent Wouters committed
584
585
586
    return new Promise((resolve, reject) =>
      resolve({ contentType: inline.contentType, content: inline.content })
    );
587
  } else if (source.sourceType === "DocumentSourcePage") {
588
    return doFetchDocumentPage(source as DocumentSourcePage);
589
  } else if (source.sourceType === "DocumentSourceLinked") {
Laurent Wouters's avatar
Laurent Wouters committed
590
    return doFetchDocumentLink(source as DocumentSourceLinked);
591
  }
Laurent Wouters's avatar
Laurent Wouters committed
592
  return new Promise((resolve, reject) => reject("Unknown data source"));
593
594
}

595
/**
Laurent Wouters's avatar
Laurent Wouters committed
596
597
598
 * Compares two data sources
 * @param x The first data source
 * @param y The second data source
599
 */
600
function compareSources(x: DocumentSource, y: DocumentSource): number {
Laurent Wouters's avatar
Laurent Wouters committed
601
  return x.priority - y.priority;
602
603
}

604
605
606
/**
 * Constant when no data are available
 */
607
export const NO_DATA = new DocumentSourceNone();
608

609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
/**
 * Describes the status of a document (on a tab)
 */
export enum DocumentStatus {
  /**
   * The extension shall remain inactive on this tab (default)
   */
  off,
  /**
   * The extension is activated on this tab
   */
  active,
  /**
   * The extension is activated on this tab, displaying in raw mode
   */
  activeRaw,
  /**
   * The extension has previously been acticated on this tab but shall remain asleep for the time being
   */
  asleep
}

631
/**
632
 * The observations made for a specific document
633
 */
634
export class DocumentObservations {
635
636
  constructor(origin: Origin) {
    this.origin = origin;
Laurent Wouters's avatar
Laurent Wouters committed
637
638
639
    this.url = "";
    this.sources = [];
    this.primaryTopic = "";
640
    this.preemptable = false;
641
    this.negotiated = null;
642
    this.totalLoaded = 0;
643
    this.status = DocumentStatus.off;
644
  }
645
646
647
648
  /**
   * The origin of this document
   */
  origin: Origin;
649
  /**
650
   * The document's url
651
   */
Laurent Wouters's avatar
Laurent Wouters committed
652
  url: string;
653
  /**
654
   * The possible sources of datasets
655
   */
656
  sources: DocumentSource[];
657
  /**
Laurent Wouters's avatar
Laurent Wouters committed
658
   * The detected primary topic if any
659
   */
Laurent Wouters's avatar
Laurent Wouters committed
660
  primaryTopic: string;
661
662
663
664
  /**
   * Flag whether the document can be pre-empted
   */
  preemptable: boolean;
665
666
667
  /**
   * Dataset that can be obtained at the document's location through HTTP content negotiation
   */
668
  negotiated: DocumentSourceLinked | null;
669
670
671
672
  /**
   * The total number of statements loaded from this document
   */
  totalLoaded: number;
673
674
675
676
  /**
   * The requested status for this document
   */
  status: DocumentStatus;
Laurent Wouters's avatar
Laurent Wouters committed
677
}
678

Laurent Wouters's avatar
Laurent Wouters committed
679
680
681
/**
 * Observes the content of an observable to get the observed data
 * @param observable An observable
682
 * @param origin The origin of the observable
Laurent Wouters's avatar
Laurent Wouters committed
683
684
 */
export function observeContent(
685
686
  observable: ObservableContent,
  origin: Origin
687
): DocumentObservations {
Laurent Wouters's avatar
Laurent Wouters committed
688
689
690
691
  if (MIME.hasOwnProperty(observable.contentType)) {
    // Linked-data content at this URI
    let links = parseLinks(observable.linkHeader);
    let primary = detectTopicOnlinks(links);
692
    let mimeInfo = MIME[observable.contentType];
Laurent Wouters's avatar
Laurent Wouters committed
693
    let source =
694
      observable.content.length > 0 && mimeInfo !== undefined
695
        ? new DocumentSourceInline(
Laurent Wouters's avatar
Laurent Wouters committed
696
            observable.url,
697
            mimeInfo,
698
            observable.content,
699
            origin
Laurent Wouters's avatar
Laurent Wouters committed
700
          )
701
        : new DocumentSourceLinked(
Laurent Wouters's avatar
Laurent Wouters committed
702
703
704
            new Link(observable.url, [
              { name: "rel", value: "alternate" },
              { name: "type", value: observable.contentType }
705
            ]),
706
            origin
Laurent Wouters's avatar
Laurent Wouters committed
707
          );
708
    let observations: DocumentObservations = {
709
      origin: origin,
710
      primaryTopic: primary,
Laurent Wouters's avatar
Laurent Wouters committed
711
      url: observable.url,
712
      sources: [source],
713
      preemptable: true,
714
      negotiated: null,
715
716
      totalLoaded: 0,
      status: DocumentStatus.off
Laurent Wouters's avatar
Laurent Wouters committed
717
718
719
720
721
722
723
    };
    return observations;
  } else {
    // no inline data, inspect the content
    let links = parseLinks(observable.linkHeader);
    let primary = detectTopicOnlinks(links);
    if (
724
      observable.contentType === "text/html" &&
Laurent Wouters's avatar
Laurent Wouters committed
725
726
727
      observable.content.length > 0
    ) {
      // parse the html to retrieve its header
Laurent Wouters's avatar
Laurent Wouters committed
728
729
730
731
      let parser = new DOMParser();
      let doc = parser.parseFromString(
        observable.content,
        observable.contentType
Laurent Wouters's avatar
Laurent Wouters committed
732
733
734
      );
      let otherLinks = findLinksInDocument(doc, observable.url, primary);
      links = links.concat(otherLinks);
735
      if (primary.length === 0) {
Laurent Wouters's avatar
Laurent Wouters committed
736
        primary = detectTopicOnlinks(links);
737
      }
Laurent Wouters's avatar
Laurent Wouters committed
738
    }
739
    let sources = detectDataOnLinks(links, {
740
      kind: OriginKind.HttpLink,
741
742
      url: observable.url
    });
743
    let negotiated = sources.find(
744
      (source: DocumentSourceLinked) => source.url === observable.url
745
    );
746
    let observations: DocumentObservations = {
747
      origin: origin,
748
      primaryTopic: primary,
Laurent Wouters's avatar
Laurent Wouters committed
749
      url: observable.url,
750
      sources: sources,
751
      preemptable: MIME.hasOwnProperty(observable.contentType),
752
      negotiated: negotiated !== undefined ? negotiated : null,
753
754
      totalLoaded: 0,
      status: DocumentStatus.off
Laurent Wouters's avatar
Laurent Wouters committed
755
756
757
758
759
    };
    return observations;
  }
}

Laurent Wouters's avatar
Laurent Wouters committed
760
761
762
763
/**
 * Gets whether data has been detected
 * @param observation The current observation
 */
764
export function hasDetectedData(observation: DocumentObservations): boolean {
Laurent Wouters's avatar
Laurent Wouters committed
765
  let main = observation.sources.find(
766
    (source: DocumentSource) => source !== NO_DATA
Laurent Wouters's avatar
Laurent Wouters committed
767
  );
768
  return main !== undefined && main !== NO_DATA;
769
770
}

771
/**
Laurent Wouters's avatar
Laurent Wouters committed
772
 * The user command for a specific resource for the linked data browser
773
 */
Laurent Wouters's avatar
Laurent Wouters committed
774
export class ResourceUserCommand {
Laurent Wouters's avatar
Laurent Wouters committed
775
  constructor() {
Laurent Wouters's avatar
Laurent Wouters committed
776
    this.isAutomatic = true;
777
    this.selectedTopic = null;
Laurent Wouters's avatar
Laurent Wouters committed
778
779
    this.selectedView = "";
    this.selectedLanguage = application.NO_LANGUAGE;
Laurent Wouters's avatar
Laurent Wouters committed
780
781
  }

782
  /**
Laurent Wouters's avatar
Laurent Wouters committed
783
   * Whether the command use the defaults
784
   */
Laurent Wouters's avatar
Laurent Wouters committed
785
  isAutomatic: boolean;
Laurent Wouters's avatar
Laurent Wouters committed
786
  /**
787
   * The user-selected topic, if any
Laurent Wouters's avatar
Laurent Wouters committed
788
   */
789
  selectedTopic: application.Resource | null;
790
  /**
Laurent Wouters's avatar
Laurent Wouters committed
791
   * The identifier of the user-selected view
792
   */
Laurent Wouters's avatar
Laurent Wouters committed
793
  selectedView: string;
794
  /**
Laurent Wouters's avatar
Laurent Wouters committed
795
   * The user-selected language
796
   */
Laurent Wouters's avatar
Laurent Wouters committed
797
798
  selectedLanguage: application.Language;
}
799

Laurent Wouters's avatar
Laurent Wouters committed
800
801
802
803
/**
 * The data about a resource for the linked data browser
 */
export class ResourceData {
804
  /**
805
   * The associated resource
806
   */
807
808
809
810
  resource: application.Resource;
  /**
   * The observations for the associated documents
   */
811
  observations: { [url: string]: DocumentObservations | undefined };
Laurent Wouters's avatar
Laurent Wouters committed
812
  /**
Laurent Wouters's avatar
Laurent Wouters committed
813
   * The associated user command
Laurent Wouters's avatar
Laurent Wouters committed
814
   */
Laurent Wouters's avatar
Laurent Wouters committed
815
  command: ResourceUserCommand;
816
817
818
  /**
   * The current rendering for this resource
   */
819
  rendering: application.RenderingResult | null;
820
  /**
821
   * The supplementary resources that have been merged into this one
822
   */
823
  supplements: application.Resource[];
Laurent Wouters's avatar
Laurent Wouters committed
824
825
}

826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
/**
 * The different kind of compliance warnings
 */
export enum ComplianceWarningKind {
  /**
   * A mismatch between the URI of a source of data and the URI of the current resource
   * The source of data is from a different URI than the URI of the current resource
   */
  SourceTopicMismatch,
  /**
   * The source has been obtained through a redirection from the URI of the current resource
   * The source of data should be obtained at the same URI through content type negotiation
   */
  SourceFromRedirection,
  /**
   * Multiple alternative writings of the same URI do not contain the same data
   */
  InconsistentAlternativeSources,
  /**
   * When bad negotiation occured for an URI
   * This is detected through the use of HTML links as the only source of data for an URI
   */
  BadNegotiation
}

/**
 * The data about a warning regarding the compliance of a the data known in a tab
 */
export interface ComplianceWarning {
  /**
   * The kind of compliance warning
   */
  kind: ComplianceWarningKind;
}

/**
 * A mismatch between the URI of a source of data and the URI of the current resource
 */
export class ComplianceWarningForSourceTopicMismatch
  implements ComplianceWarning {
  /**
   * The kind of compliance warning
   */
  kind: ComplianceWarningKind;
  /**
   * The source that triggered the warning
   */
  source: DocumentSource;

  /**
   * Initializes this warning
   * @param source The source that triggered the warning
   */
  constructor(source: DocumentSource) {
    this.kind = ComplianceWarningKind.SourceTopicMismatch;
    this.source = source;
  }
}

/**
 * The source has been obtained through a redirection from the URI of the current resource
 */
export class ComplianceWarningForSourceFromRedirection
  implements ComplianceWarning {
  /**
   * The kind of compliance warning
   */
  kind: ComplianceWarningKind;
  /**
   * The source that triggered the warning
   */
  source: DocumentSource;

  /**
   * Initializes this warning
   * @param source The source that triggered the warning
   */
  constructor(source: DocumentSource) {
    this.kind = ComplianceWarningKind.SourceFromRedirection;
    this.source = source;
  }
}

/**
 * Multiple alternative writings of the same URI do not contain the same data
 */
export class ComplianceWarningForInconsistentAlternativeSources
  implements ComplianceWarning {
  /**
   * The kind of compliance warning
   */
  kind: ComplianceWarningKind;
  /**
   * The sources that triggered the warning
   */
  sources: DocumentSource[];

  /**
   * Initializes this warning
   * @param source The sources that triggered the warning
   */
  constructor(sources: DocumentSource[]) {
    this.kind = ComplianceWarningKind.InconsistentAlternativeSources;
    this.sources = sources;
  }
}

/**
 * When bad negotiation occured for an URI
 */
export class ComplianceWarningForBadNegotiation implements ComplianceWarning {
  /**
   * The kind of compliance warning
   */
  kind: ComplianceWarningKind;
  /**
   * The document that triggered the warning
   */
  document: DocumentObservations;

  /**
   * Initializes this warning
   * @param source The document that triggered the warning
   */
  constructor(document: DocumentObservations) {
    this.kind = ComplianceWarningKind.BadNegotiation;
    this.document = document;
  }
}

Laurent Wouters's avatar
Laurent Wouters committed
956
/**
957
958
 * Gets the sources of data for the specified resource
 * @param data The current data about a resource
Laurent Wouters's avatar
Laurent Wouters committed
959
 */
960
961
962
963
964
export function getSourcesFor(
  data: ResourceData
): { [uri: string]: DocumentSource[] } {
  let result: { [url: string]: DocumentSource[] } = {};
  Object.keys(data.observations).forEach((url: string) => {
965
    let observations = data.observations[url];
966
967
968
969
970
    if (observations) {
      result[url] = observations.sources
        .filter((source: DocumentSource) => source !== NO_DATA)
        .sort(compareSources);
    }
971
  });
972
  return result;
973
974
}

Laurent Wouters's avatar
Laurent Wouters committed
975
976
/**
 * Selects the relevant primay topic
977
 * @param data The current data about a resource
Laurent Wouters's avatar
Laurent Wouters committed
978
 */
979
export function selectPrimaryTopic(data: ResourceData): application.Resource {
980
981
982
983
  if (data.command.isAutomatic || data.command.selectedTopic === null) {
    let url = Object.keys(data.observations).find((url: string) => {
      let observations = data.observations[url];
      if (observations) {
984
        return observations.primaryTopic.length > 0;
985
986
987
988
989
990
991
992
      } else {
        return false;
      }
    });
    if (url === undefined) return data.resource;
    let observations = data.observations[url];
    if (observations === undefined) return data.resource;
    return application.newResourceFromUri(observations.primaryTopic);
Laurent Wouters's avatar
Laurent Wouters committed
993
  } else {
994
    return data.command.selectedTopic;
Laurent Wouters's avatar
Laurent Wouters committed
995
  }
Laurent Wouters's avatar
Laurent Wouters committed
996
}
Laurent Wouters's avatar
Laurent Wouters committed
997

998
999
1000
1001
1002
1003
1004
1005
1006
/**
 * Checks the data for compliance issues
 * @param data The current data about a resource
 */
export function checkCompliance(data: ResourceData): ComplianceWarning[] {
  let warnings: ComplianceWarning[] = [];
  let loadedFromAlternatives: DocumentSource[] = [];
  Object.keys(data.observations).forEach((uri: string) => {
    let observation = data.observations[uri];
1007
1008
    if (observation === undefined) return;
    let allHtmlLink = true;
1009
    observation.sources.forEach((source: DocumentSource) => {
1010
      if (source === NO_DATA) return;
1011
1012
1013
      if (data.resource.uris.indexOf(source.url) < 0) {
        warnings.push(new ComplianceWarningForSourceTopicMismatch(source));
      }
1014
      if (source.origin.kind === OriginKind.Redirected) {
1015
1016
        warnings.push(new ComplianceWarningForSourceFromRedirection(source));
      }
1017
      allHtmlLink = allHtmlLink && source.origin.kind === OriginKind.HtmlLink;
1018
      if (
1019
1020
1021
        ((source.origin.kind === OriginKind.Direct ||
          source.origin.kind === OriginKind.Negotiated ||
          source.origin.kind === OriginKind.Redirected) &&
1022
          data.resource.uris.indexOf(source.origin.url) >= 0) ||
1023
        data.resource.uris.indexOf(source.url) >= 0
1024
1025
1026
1027
1028
1029
1030
1031
1032
      ) {
        loadedFromAlternatives.push(source);
      }
    });
    if (allHtmlLink) {
      warnings.push(new ComplianceWarningForBadNegotiation(observation));
    }
  });
  let consistent = loadedFromAlternatives
1033
1034
1035
1036
    .map((source: DocumentSource, index: number) =>
      index === 0
        ? true
        : source.loaded === loadedFromAlternatives[index - 1].loaded
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
    )
    .reduce((acc: boolean, value: boolean) => acc && value, true);
  if (!consistent) {
    warnings.push(
      new ComplianceWarningForInconsistentAlternativeSources(
        loadedFromAlternatives
      )
    );
  }
  return warnings;
}

1049
1050
1051
/**
 * Tries to negotiate data content at the specified URL
 */
1052
1053
1054
export function tryNegotiateData(
  target: string
): Promise<DocumentSourceLinked> {
1055
1056
1057
1058
1059
1060
1061
1062
  let accept = Object.keys(MIME)
    .map(key => {
      return MIME[key];
    })
    .sort((x: MimeInfo, y: MimeInfo) => {
      return x.priority - y.priority;
    })
    .reduce((acc: string, mime: MimeInfo, index: number) => {
1063
      if (acc.length === 0) return mime.mime;
1064
1065
1066
1067
1068
1069
      return acc + ", " + mime.mime + ";q=" + (1 - 0.1 * index).toString();
    }, "");

  return new Promise(function(resolve, reject) {
    let xmlHttp = new XMLHttpRequest();
    xmlHttp.onreadystatechange = function() {
1070
1071
      if (xmlHttp.readyState === 4) {
        if (xmlHttp.status < 200 || xmlHttp.status >= 300) {
1072
          return reject("HTTP error: " + xmlHttp.status);
1073
        }
1074
        let contentType = xmlHttp.getResponseHeader("Content-Type");
1075
1076
1077
        if (contentType === null) {
          return reject("No Content-Type header");
        }
1078
1079
1080
1081
1082
1083
1084
        let index = contentType.indexOf(";");
        if (index > 0) contentType = contentType.substring(0, index);
        if (!MIME.hasOwnProperty(contentType)) return reject("Nothing found");
        let link = new Link(target, [
          { name: "type", value: contentType },
          { name: "rel", value: "alternate" }
        ]);
1085
1086
        resolve(
          new DocumentSourceLinked(link, {
1087
            kind: OriginKind.Negotiated,
1088
1089
1090
            url: target
          })
        );
1091
1092
1093
1094
1095
1096
1097
      }
    };
    xmlHttp.open("HEAD", target, true);
    xmlHttp.setRequestHeader("Accept", accept);
    xmlHttp.send();
  });
}
1098
1099

/**
Laurent Wouters's avatar
Laurent Wouters committed
1100
 * A registry for observed data
1101
 */
Laurent Wouters's avatar
Laurent Wouters committed
1102
export interface ObservedResourceRegistry {
1103
  [index: number]: DocumentObservations | undefined;
1104
1105
1106
}

/**
Laurent Wouters's avatar
Laurent Wouters committed
1107
 * Gets the observed data for a tab
1108
1109
1110
 * @param registry The registry of tab data
 * @param id The identifier of a tab
 */
Laurent Wouters's avatar
Laurent Wouters committed
1111
1112
1113
export function resolveObservationsForTab(
  registry: ObservedResourceRegistry,
  id: number
1114
): DocumentObservations {
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
  let result = registry[id];
  if (result === undefined) {
    result = new DocumentObservations({
      kind: OriginKind.Unkown,
      url: ""
    });
    registry[id] = result;
    return result;
  } else {
    return result;
1125
1126
  }
}