view content_scripts/feed-preview.js @ 5:341a0f4b7ce0

Handle feed entry content normalization with a setter
author Guido Berhoerster <guido+feed-preview@berhoerster.name>
date Sun, 04 Nov 2018 10:03:05 +0100
parents 086ee559acbb
children
line wrap: on
line source

/*
 * Copyright (C) 2018 Guido Berhoerster <guido+feed-preview@berhoerster.name>
 *
 * This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/.
 */

'use strict';

const ALLOWED_PROTOCOLS = new Set(['http:', 'https:', 'ftp:']);

function encodeXML(str) {
    return str.replace(/[<>&'"]/g, c => {
        switch (c) {
            case '<': return '&lt;';
            case '>': return '&gt;';
            case '&': return '&amp;';
            case '\'': return '&apos;';
            case '"': return '&quot;';
        }
    });
}

function parseDate(s) {
    let date = new Date(s);

    return isNaN(date) ? new Date(0) : date;
}

function parseURL(text, baseURL = window.location.href) {
    let url;

    try {
        url = new URL(text, baseURL);
    } catch (e) {
        return null;
    }
    if (!ALLOWED_PROTOCOLS.has(url.protocol)) {
        return null;
    }

    return url;
}

function feedNSResolver(prefix) {
    switch (prefix) {
        case 'atom':
            return 'http://www.w3.org/2005/Atom'
        case 'rss':
            return 'http://my.netscape.com/rdf/simple/0.9/'
    }
    return null;
}

function feedQueryXPath(feedDocument, scopeElement, xpathQuery) {
    return feedDocument.evaluate(xpathQuery, scopeElement, feedNSResolver,
            XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
}

function feedQueryXPathAll(feedDocument, scopeElement, xpathQuery) {
    let result = feedDocument.evaluate(xpathQuery, scopeElement, feedNSResolver,
            XPathResult.ORDERED_NODE_ITERATOR_TYPE, null);
    let nodes = [];
    for (let node = result.iterateNext(); node !== null;
            node = result.iterateNext()) {
        nodes.push(node);
    }

    return nodes;
}

class FeedLogo {
    constructor(url, title = '') {
        this.url = url;
        this.title = title;
    }
}

class RSS1Logo extends FeedLogo {
    constructor(feedDocument, imageElement) {
        let urlElement = feedQueryXPath(feedDocument, imageElement,
                './rss:url');
        if (urlElement === null) {
            throw new TypeError('missing <url> element in <logo> element');
        }
        let url = parseURL(urlElement.textContent.trim());
        if (url === null) {
            throw new TypeError('invalid URL in <logo> element');
        }
        super(url);

        let titleElement = feedQueryXPath(feedDocument, imageElement,
                './rss:title');
        if (titleElement !== null) {
            this.title = titleElement.textContent.trim();
        }
    }
}

class RSS2Logo extends FeedLogo {
    constructor(feedDocument, imageElement) {
        let urlElement = feedQueryXPath(feedDocument, imageElement, './url');
        if (urlElement === null) {
            throw new TypeError('missing <url> element in <logo> element');
        }
        let url = parseURL(urlElement.textContent.trim());
        if (url === null) {
            throw new TypeError('invalid URL in <logo> element');
        }
        super(url);

        let titleElement = feedQueryXPath(feedDocument, imageElement,
                './title');
        if (titleElement !== null) {
            this.title = titleElement.textContent.trim();
        }
    }
}

class AtomLogo extends FeedLogo {
    constructor(logoElement) {
        let url = parseURL(logoElement.textContent.trim());
        if (url === null) {
            throw new TypeError('invalid URL in <logo> element');
        }
        super(url);
    }
}

class FeedEntryFile {
    constructor(url, type = browser.i18n.getMessage('defaultFileType'),
            size = 0) {
        this.url = url;
        let filename = url.pathname.split('/').pop();
        this.filename = filename !== '' ? filename :
                browser.i18n.getMessage('defaultFileName');
        this.type = type;
        this.size = size;
    }
}

class RSS2EntryFile extends FeedEntryFile {
    constructor(enclosureElement) {
        let url = parseURL(enclosureElement.getAttribute('url'));
        if (url === null) {
            throw new TypeError('invalid URL in <enclosure> element');
        }
        super(url);

        let type = enclosureElement.getAttribute('type');
        if (type !== null) {
            this.type = type;
        }

        let size = parseInt(enclosureElement.getAttribute('length'), 10);
        if (!isNaN(size)) {
            this.size = size;
        }
    }
}

class FeedEntry {
    constructor(title = browser.i18n.getMessage('defaultFeedEntryTitle'),
            url = null, date = new Date(0), content = '', files = []) {
        this.title = title;
        this.url = url;
        this.date = date;
        this._content;
        this.content = content;
        this.files = files;
    }

    set content(content) {
        this._content = this.normalizeContent(content);
    }

    get content() {
        return this._content;
    }

    normalizeContent(text) {
        let parsedDocument = new DOMParser().parseFromString(text, 'text/html');

        let linkElement = parsedDocument.createElement('link');
        linkElement.rel = 'stylesheet';
        linkElement.href ='style/entry-content.css';
        parsedDocument.head.appendChild(linkElement);

        return new XMLSerializer().serializeToString(parsedDocument);
    }
}

class RSS1Entry extends FeedEntry {
    constructor(feedDocument, itemElement) {
        super();

        let titleElement = feedQueryXPath(feedDocument, itemElement,
                './rss:title');
        if (titleElement !== null) {
            this.title = titleElement.textContent;
        }

        let linkElement = feedQueryXPath(feedDocument, itemElement,
                './rss:link');
        if (linkElement !== null) {
            this.url = parseURL(linkElement.textContent);
        }
    }
}

class RSS2Entry extends FeedEntry {
    constructor(feedDocument, itemElement) {
        super();

        let titleElement = feedQueryXPath(feedDocument, itemElement, './title');
        if (titleElement !== null) {
            this.title = titleElement.textContent;
        }

        let linkElement = feedQueryXPath(feedDocument, itemElement, './link');
        if (linkElement !== null) {
            this.url = parseURL(linkElement.textContent);
        }

        let pubDateElement = feedQueryXPath(feedDocument, itemElement,
                './pubDate');
        if (pubDateElement !== null) {
            this.date = parseDate(pubDateElement.textContent);
        }

        let descriptionElement = feedQueryXPath(feedDocument, itemElement,
                './description');
        if (descriptionElement !== null) {
            this.content = descriptionElement.textContent.trim();
        }

        for (let enclosureElement of
                feedQueryXPathAll(feedDocument, itemElement, './enclosure')) {
            try {
                let entryFile = new RSS2EntryFile(enclosureElement);
                this.files.push(entryFile);
            } catch (e) {}
        }
    }
}

class AtomEntry extends FeedEntry {
    constructor(feedDocument, entryElement) {
        super();

        let titleElement = feedQueryXPath(feedDocument, entryElement,
                './atom:title');
        if (titleElement !== null) {
            this.title = titleElement.textContent.trim();
        }

        let linkElement = feedQueryXPath(feedDocument, entryElement,
                './atom:link[@href][@rel="alternate"]');
        if (linkElement !== null) {
            this.url = parseURL(linkElement.getAttribute('href'));
        }

        let updatedElement = feedQueryXPath(feedDocument, entryElement,
                './atom:updated');
        if (updatedElement !== null) {
            this.date = parseDate(updatedElement.textContent);
        }

        let contentElement = feedQueryXPath(feedDocument, entryElement,
                './atom:content');
        if (contentElement === null) {
            contentElement = feedQueryXPath(feedDocument, entryElement,
                    './atom:summary');
        }
        if (contentElement !== null) {
            let contentType = contentElement.getAttribute('type');
            if (contentType === null) {
                contentType = 'text';
            }
            contentType = contentType.toLowerCase();
            if (contentType === 'xhtml') {
                this.content = contentElement.innerHTML;
            } else if (contentType === 'html') {
                this.content = contentElement.textContent;
            } else {
                let encodedContent =
                        encodeXML(contentElement.textContent.trim());
                this.content = `<pre>${encodedContent}</pre>`;
            }
        }
    }
}

class Feed {
    constructor(title = browser.i18n.getMessage('defaultFeedTitle'),
            subtitle = '', logo = null, entries = []) {
        this.title = title;
        this.subtitle = subtitle;
        this.logo = logo;
        this.entries = entries;
    }

    async createPreviewDocument() {
        let url = browser.extension.getURL('web_resources/feed-preview.xhtml');
        let response;
        let text;
        try {
            response = await fetch(url);
            text = await response.text();
        } catch (e) {
            console.log(`Error: failed to read preview template: ${e.message}`);
            return;
        }
        let previewDocument = (new DOMParser()).parseFromString(text,
                'application/xhtml+xml');

        previewDocument.querySelector('base').href =
                browser.extension.getURL('web_resources/');

        previewDocument.querySelector('title').textContent = this.title;
        previewDocument.querySelector('#feed-title').textContent = this.title;
        previewDocument.querySelector('#feed-subtitle').textContent =
                this.subtitle;
        if (this.logo !== null) {
            let feedLogoTemplate =
                    previewDocument.querySelector('#feed-logo-template');
            let logoNode = previewDocument.importNode(feedLogoTemplate.content,
                    true);
            let imgElement = logoNode.querySelector('#feed-logo');
            imgElement.setAttribute('src', this.logo.url);
            imgElement.setAttribute('alt', this.logo.title);
            previewDocument.querySelector('#feed-header').prepend(logoNode);
        }

        let entryTemplateElement =
                previewDocument.querySelector('#entry-template');
        let entryTitleTemplateElement =
                previewDocument.querySelector('#entry-title-template');
        let entryTitleLinkedTemplateElement =
                previewDocument.querySelector('#entry-title-linked-template');
        let entryFileListTemplateElement =
                previewDocument.querySelector('#entry-files-list-template');
        let entryFileTemplateElement =
                previewDocument.querySelector('#entry-file-template');
        if (this.entries.length === 0) {
            let hintTemplateElement =
                    previewDocument.querySelector('#no-entries-hint-template');
            let hintNode =
                    previewDocument.importNode(hintTemplateElement.content,
                    true);
            hintNode.querySelector("#no-entries-hint").textContent =
                browser.i18n.getMessage('noEntriesHint');

            previewDocument.body.append(hintNode);
        }
        for (let entry of this.entries) {
            let entryNode =
                    previewDocument.importNode(entryTemplateElement.content,
                    true);
            let titleElement;
            let titleNode;

            if (entry.url !== null) {
                titleNode = previewDocument
                        .importNode(entryTitleLinkedTemplateElement.content,
                        true);
                titleElement = titleNode.querySelector('.entry-link');
                titleElement.href = entry.url;
                titleElement.title = entry.title;
            } else {
                titleNode = previewDocument
                        .importNode(entryTitleTemplateElement.content, true);
                titleElement = titleNode.querySelector('.entry-title');
            }
            titleElement.textContent = entry.title;
            entryNode.querySelector('.entry-header').prepend(titleNode);

            let timeElement = entryNode.querySelector('.entry-date > time');
            timeElement.textContent = entry.date.toLocaleString();

            let contentElement = entryNode.querySelector('.entry-content');
            contentElement.srcdoc = entry.content;
            contentElement.title = entry.title;

            if (entry.files.length > 0) {
                let fileListNode = previewDocument
                        .importNode(entryFileListTemplateElement.content, true);
                fileListNode.querySelector('.entry-files-title').textContent =
                        browser.i18n.getMessage('filesTitle');
                let fileListElement =
                        fileListNode.querySelector('.entry-files-list');

                for (let file of entry.files) {
                    let fileNode = previewDocument
                            .importNode(entryFileTemplateElement.content, true);

                    let fileLinkElement =
                            fileNode.querySelector('.entry-file-link');
                    fileLinkElement.href = file.url;
                    fileLinkElement.title = file.filename;
                    fileLinkElement.textContent = file.filename;

                    fileNode.querySelector('.entry-file-info').textContent =
                            `(${file.type}, ${file.size} bytes)`;

                    fileListElement.appendChild(fileNode);
                }

                entryNode.querySelector('.entry').append(fileListNode);
            }

            previewDocument.body.append(entryNode);
        }

        return previewDocument;
    }
}

class RSS1Feed extends Feed {
    constructor(feedDocument) {
        super();

        let documentElement = feedDocument.documentElement;
        let titleElement = feedQueryXPath(feedDocument, documentElement,
                './rss:channel/rss:title');
        if (titleElement !== null) {
            this.title = titleElement.textContent;
        }

        let descriptionElement = feedQueryXPath(feedDocument, documentElement,
                './channel/description');
        if (descriptionElement !== null) {
            this.subtitle = descriptionElement.textContent;
        }

        let imageElement = feedQueryXPath(feedDocument, documentElement,
                './rss:image');
        if (imageElement !== null) {
            try {
                let logo = new RSS1Logo(feedDocument, imageElement);
                this.logo = logo;
            } catch (e) {}
        }

        let itemElements = feedQueryXPathAll(feedDocument, documentElement,
                './rss:item');
        for (let itemElement of itemElements) {
            let entry = new RSS1Entry(feedDocument, itemElement);
            if (typeof entry !== 'undefined') {
                this.entries.push(entry);
            }
        }
    }
}

class RSS2Feed extends Feed {
    constructor(feedDocument) {
        super();

        let documentElement = feedDocument.documentElement;
        let titleElement = feedQueryXPath(feedDocument, documentElement,
                './channel/title');
        if (titleElement !== null) {
            this.title = titleElement.textContent;
        }

        let descriptionElement = feedQueryXPath(feedDocument, documentElement,
                './channel/description');
        if (descriptionElement !== null) {
            this.subtitle = descriptionElement.textContent;
        }

        let imageElement = feedQueryXPath(feedDocument, documentElement,
                './channel/image');
        if (imageElement !== null) {
            try {
                let logo = new RSS2Logo(feedDocument, imageElement);
                this.logo = logo;
            } catch (e) {}
        }

        let itemElements = feedQueryXPathAll(feedDocument, documentElement,
                './channel/item');
        for (let itemElement of itemElements) {
            let entry = new RSS2Entry(feedDocument, itemElement);
            if (typeof entry !== 'undefined') {
                this.entries.push(entry);
            }
        }
    }
}

class AtomFeed extends Feed {
    constructor(feedDocument, atomVersion) {
        super();

        let documentElement = feedDocument.documentElement;
        let titleElement = feedQueryXPath(feedDocument, documentElement,
                './atom:title');
        if (titleElement !== null) {
            this.title = titleElement.textContent.trim();
        }

        let subtitleElement = feedQueryXPath(feedDocument, documentElement,
                './atom:subtitle');
        if (subtitleElement !== null) {
            this.subtitle = subtitleElement.textContent.trim();
        }

        let logoElement =  feedQueryXPath(feedDocument, documentElement,
                './atom:logo');
        if (logoElement !== null) {
            try {
                let logo = new AtomLogo(logoElement);
                this.logo = logo;
            } catch (e) {}
        }

        let entryElements = feedQueryXPathAll(feedDocument, documentElement,
                './atom:entry');
        for (let entryElement of entryElements) {
            this.entries.push(new AtomEntry(feedDocument, entryElement));
        }
    }
}

function probeFeedType(feedDocument) {
    if (feedDocument.documentElement.nodeName === 'feed') {
        let version = feedDocument.documentElement.getAttribute('version');
        if (version === null) {
            version = '1.0';
        }
        for (let attr of feedDocument.documentElement.attributes) {
            if (attr.name === 'xmlns' &&
                    attr.value === 'http://www.w3.org/2005/Atom') {
                return ['atom', version];
            }
        }
    } else if (feedDocument.documentElement.nodeName === 'rss') {
        let version = feedDocument.documentElement.getAttribute('version');
        if (version !== null) {
            return ['rss', version];
        }
    } else if (feedDocument.documentElement.localName.toLowerCase() === 'rdf') {
        for (let attr of feedDocument.documentElement.attributes) {
            if (attr.name === 'xmlns' &&
                    attr.value === 'http://my.netscape.com/rdf/simple/0.9/') {
                return ['rss', '0.9'];
            }
        }
    }

    return [undefined, undefined];
}

async function replaceDocumentWithPreview(type, version) {
    let feed;
    switch (type) {
        case 'rss':
            switch (version) {
                case '0.9':
                case '1.0':
                    feed = new RSS1Feed(document, version);
                    break;
                case '0.90':
                case '0.91':
                case '0.92':
                case '0.93':
                case '0.94':
                case '2.0':
                    feed = new RSS2Feed(document, version);
                    break;
                default:
                    return;
            }
            break;
        case 'atom':
            feed = new AtomFeed(document, version);
            break;
        default:
            return;
    }

    // replace original document with preview
    let previewDocument = await feed.createPreviewDocument();
    if (typeof previewDocument === 'undefined') {
        return;
    }
    let documentElement = previewDocument.documentElement;
    document.replaceChild(document.importNode(documentElement, true),
            document.documentElement);
}

let [type, version] = probeFeedType(document);
if (typeof type !== 'undefined') {
    replaceDocumentWithPreview(type, version);
}