view js/background.js @ 61:c8fa2430eab2

Show URL of each feed as a tooltip in the feed selection popup
author Guido Berhoerster <guido+feed-preview@berhoerster.name>
date Mon, 09 Nov 2020 18:07:06 +0100
parents ede87e1004f9
children fc5fca9af05f
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';

import * as feedParser from './feed-parser.js';
import {renderFeedPreview} from './feed-preview.js';

const FEED_READERS_PRESET = [
    {
        title: 'Feedly',
        urlTemplate: 'https://feedly.com/#subscription/feed/%s'
    },
    {
        title: 'FlowReader',
        urlTemplate: 'https://www.flowreader.com/subscribe?url=%s'
    },
    {
        title: 'InoReader',
        urlTemplate: 'https://www.inoreader.com/feed/%s'
    },
    {
        title: 'Kouio',
        urlTemplate: 'https://kouio.com/subscribe?url=%s'
    },
    {
        title: 'My Yahoo',
        urlTemplate: 'https://add.my.yahoo.com/rss?url=%s'
    },
    {
        title: 'Netvibes',
        urlTemplate: 'https://www.netvibes.com/subscribe.php?url=%s'
    },
    {
        title: 'NewsBlur',
        urlTemplate: 'https://www.newsblur.com/?url=%s'
    },
    {
        title: 'The Old Reader',
        urlTemplate: 'https://theoldreader.com/feeds/subscribe?url=%s'
    }
];
const FEED_MAGIC = [
    '<rss',
    '<feed',
    feedParser.XMLNS.ATOM03,
    feedParser.XMLNS.ATOM10,
    feedParser.XMLNS.RSS09,
    feedParser.XMLNS.RSS10
];
var tabsFeeds = new Map();
var tabsFeedPreviews = new Map();
var fetchingFeedPreview = fetch('web_resources/feed-preview.xhtml')
        .then(response => response.text());
var feedPreviewOptions = {
    expandEntries: false
};

function parseContentType(header) {
    let contentType = {
        mediaType: '',
        charset: 'utf-8'
    };
    let parts = header.toLowerCase().split(';');
    contentType.mediaType = parts.shift().trim();
    for (let parameter of parts) {
        let [, name, value, ] = parameter.trim().split(/([^=]+)="?([^"]*)"?/);
        if (name.toLowerCase() === 'charset') {
            contentType.charset = value.toLowerCase();
            break;
        }
    }

    return contentType;
}

async function handleFeed(inputText, tabId, url) {
    // fast-path: eliminate XML documents which cannot be Atom nor RSS feeds
    let inputTextStart = inputText.substring(0, 512);
    if (!FEED_MAGIC.some(element => inputTextStart.includes(element))) {
        return inputText;
    }

    let feed;
    try {
        feed = (new feedParser.FeedParser).parseFromString(inputText, url);
    } catch (e) {
        if (e instanceof feedParser.ParserError ||
                e instanceof feedParser.UnsupportedFeedTypeError) {
            // let the browser deal with non-well formed XML or XML documents
            // which are not supported Atom or RSS feeds
            return inputText;
        }
        throw e;
    }
    console.log(`parsed feed ${url}:\n`, feed);

    // mark this feed preview for content script injection
    tabsFeedPreviews.set(tabId, url);

    // render the preview document
    let feedPreviewDocument = new DOMParser()
            .parseFromString(await fetchingFeedPreview, 'text/html');
    renderFeedPreview(feedPreviewDocument, feed,
            feedPreviewOptions.expandEntries);

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

browser.webRequest.onHeadersReceived.addListener(details => {
    if (details.statusCode !== 200) {
        return {};
    }

    let contentTypeIndex = details.responseHeaders.findIndex(header =>
            header.name.toLowerCase() === 'content-type' &&
            typeof header.value !== 'undefined');
    if (contentTypeIndex < 0) {
        // no Content-Type header found
        return {};
    }
    let headerValue = details.responseHeaders[contentTypeIndex].value
    let contentType = parseContentType(headerValue);
    // until content handlers become available to webextensions
    // (https://bugzilla.mozilla.org/show_bug.cgi?id=1457500) intercept all
    // responses and change the content type from application/atom+xml or
    // application/rss+xml to application/xml which will then be probed for
    // Atom or RSS content
    switch (contentType.mediaType) {
        case 'application/atom+xml':
        case 'application/rss+xml':
        case 'application/rdf+xml':
        case 'application/xml':
        case 'text/xml':
            break;
        default:
            // non-XML media type
            return {};
    }
    console.log(`response is an XML document\n`,
            `media type: ${contentType.mediaType}\n`,
            `charset: ${contentType.charset}`);

    let decoder;
    try {
        decoder = new TextDecoder(contentType.charset);
    } catch (e) {
        if (e instanceof RangeError) {
            // unsupported charset
            return {};
        } else {
            throw e;
        }
    }
    let encoder = new TextEncoder();
    let inputText = '';
    let filter = browser.webRequest.filterResponseData(details.requestId);
    filter.addEventListener('data', ev => {
        inputText += decoder.decode(ev.data, {stream: true});
    });
    filter.addEventListener('stop', async ev => {
        let result = await handleFeed(inputText, details.tabId, details.url);
        filter.write(encoder.encode(result));
        filter.close();
    });

    details.responseHeaders[contentTypeIndex] = {
        name: 'Content-Type',
        value: 'application/xml;charset=utf-8'
    };

    return {responseHeaders: details.responseHeaders};
},
        {urls: ['http://*/*', 'https://*/*'], types: ['main_frame']},
        ['blocking', 'responseHeaders']);

browser.runtime.onMessage.addListener((request, sender, sendResponse) => {
    // popup querying feeds
    sendResponse(tabsFeeds.get(request));
});

browser.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => {
    if (changeInfo.status !== 'complete') {
        // hide the page action when the URL changes since it is no longer
        // valid, it will be shown again if the content script detects a feed
        browser.pageAction.hide(tabId);
        return;
    }

    if (tabsFeedPreviews.get(tabId) === tab.url) {
        // inject content script once if the requested URL is a feed preview
        browser.tabs.executeScript(tabId, {
            file: 'content_scripts/feed-readers.js'
        });
        tabsFeedPreviews.delete(tabId);
    } else {
        // query available feeds
        let feeds = await browser.tabs.sendMessage(tabId, {});
        if (feeds.length > 0) {
            tabsFeeds.set(tabId, feeds);
            browser.pageAction.show(tabId);
            console.log(`detected feeds in tab ${tabId} for ${tab.url}:\n`,
                    feeds);
        } else {
            console.log(`no feeds detected in tab ${tabId} for ${tab.url}\n`);
        }
    }
}, {properties: ["status"]});

browser.tabs.onRemoved.addListener((tabId, removeInfo) => {
    tabsFeeds.delete(tabId);
    tabsFeedPreviews.delete(tabId);
});

browser.runtime.onInstalled.addListener(async details => {
    if (details.reason === 'install' ||
            (details.reason === 'update' && details.previousVersion < 2)) {
        let {feedReaders = []} = await browser.storage.sync.get('feedReaders');
        let feedReadersSet =
                new Set(feedReaders.map(feedReader => feedReader.urlTemplate));
        for (let feedReader of FEED_READERS_PRESET) {
            if (!feedReadersSet.has(feedReader.urlTemplate)) {
                feedReaders.push(feedReader);
            }
        }
        console.log('set feedReaders to', feedReaders);
        browser.storage.sync.set({feedReaders});
    }
});

browser.storage.sync.get('feedPreview').then(({feedPreview}) => {
    if (typeof feedPreview !== 'undefined' &&
            feedPreview === Object(feedPreview)) {
        feedPreviewOptions.expandEntries = !!feedPreview.expandEntries;
    }
});

browser.storage.onChanged.addListener((changes, areaName) => {
    if (areaName !== 'sync' || typeof changes.feedPreview === 'undefined') {
        return;
    }

    let newValue = changes.feedPreview.newValue;
    if (typeof newValue !== 'undefined' && newValue === Object(newValue)) {
        feedPreviewOptions.expandEntries = !!newValue.expandEntries;
    }
});