import browser from 'webextension-polyfill'; // Parse a feed, given as a DOM element const parseFeed = (feed: Element, url: string | null) => { if (feed.tagName.toLowerCase() == 'channel') return parseRSSFeed(feed, url) return parseAtomFeed(feed, url) } // Parse an RSS feed const parseRSSFeed = (feed: Element, url: string | null) => { const imageElement = feed.getElementsByTagName('image')[0] return { feedData: { title: getNodeContent(feed, 'title'), description: getNodeContent(feed, 'description'), feedUrl: url?.length ? url : window.location.href, homeUrl: getNodeContent(feed, 'link'), image: imageElement ? { title: getNodeContent(imageElement, 'title'), imageUrl: getNodeContent(imageElement, 'url'), targetUrl: getNodeContent(imageElement, 'link'), } : null, html: { title: false, description: false, }, items: Array.from(feed.getElementsByTagName('item')).map((item) => { return { title: getNodeContent(item, 'title'), description: getNodeContent(item, 'description'), url: getNodeContent(item, 'link'), image: parseRSSItemImage(item), pubDate: getNodeContent(item, 'pubDate'), age: pubDateToInterval(getNodeContent(item, 'pubDate'),), categories: Array.from(item.getElementsByTagName('category')).map((cat) => cat.firstChild?.textContent ), html: { title: false, }, } }).sort((a, b) => itemTime(b) - itemTime(a)) } } } // Parse an Atom feed const parseAtomFeed = (feed: Element, url: string | null) => { const homeURL = getAtomLinksByType(feed, 'text/html')?.[0] const logoURL = toAbsoluteURL(getNodeContent(feed, 'logo')) const iconURL = toAbsoluteURL(getNodeContent(feed, 'icon')) return { feedData: { title: getNodeContent(feed, 'title'), description: getNodeContent(feed, 'subtitle'), feedUrl: url?.length ? url : window.location.href, homeUrl: homeURL, iconUrl: iconURL, image: { imageUrl: logoURL || iconURL, targetUrl: homeURL, }, html: { title: feed.getElementsByTagName('title')[0]?.getAttribute('type') === 'html', description: feed.getElementsByTagName('subtitle')[0]?.getAttribute('type') === 'html', }, items: Array.from(feed.getElementsByTagName('entry')).map((item) => { return { title: getNodeContent(item, 'title'), description: getNodeContent(item, 'content') || getNodeContent(item, 'summary'), url: getAtomLinksByType(item, 'text/html')?.[0], image: getAtomLinksByType(item, 'image/')?.[0], pubDate: getNodeContent(item, 'updated'), age: pubDateToInterval(getNodeContent(item, 'updated')), categories: Array.from(item.getElementsByTagName('category')).map((cat) => cat.firstChild?.textContent ), html: { title: item.getElementsByTagName('title')[0]?.getAttribute('type') === 'html', }, } }).sort((a, b) => itemTime(b) - itemTime(a)) } } } // Convert relative URLs to absolute const toAbsoluteURL = (link: string | null): string | null => { if (link?.length && !link.match(/^https?:\/\//)) { let port = window.location.port if (port.length) port = `:${port}` link = `${window.location.protocol}//${window.location.hostname}${port}${link}` } return link } // Get the raw text content of an XML node const getNodeContent = (parent: Element, tagName: string) => // @ts-ignore parent.getElementsByTagName(tagName)[0]?.firstChild?.wholeText // Extract the publication time of an item as a timestamp const itemTime = (item: {pubDate: string}) => { const dateStr = item.pubDate if (!dateStr?.length) return 0 return (new Date(dateStr)).getTime() } // Convert the publication date to an age string const pubDateToInterval = (dateStr: string) => { if (!dateStr?.length) return // @ts-ignore let interval = ((new Date()) - (new Date(dateStr))) / 1000 let unit = 'seconds' if (interval >= 60) { interval /= 60 unit = 'minutes' } if (unit == 'minutes' && interval >= 60) { interval /= 60 unit = 'hours' } if (unit == 'hours' && interval >= 24) { interval /= 24 unit = 'days' } if (unit == 'days' && interval >= 30) { interval /= 30 unit = 'months' } return `${interval.toFixed(0)} ${unit}` } // Extract the main image of an RSS item const parseRSSItemImage = (item: Element) => { const images = Array.from(item.getElementsByTagName('media:content')) .filter((content) => (content.getAttribute('type') || '').startsWith('image/') || content.getAttribute('medium') === 'image' ) if (!images.length) return const { url } = images.reduce((maxImage, content) => { const width = parseFloat(content.getAttribute('width') || '0') if (width > maxImage.width) { maxImage.url = content.getAttribute('url') || '' maxImage.width = width } return maxImage }, { width: parseFloat(images[0].getAttribute('width') || '0'), url: images[0].getAttribute('url'), }) return { url: url } } // Get the HREFs of an Atom element const getAtomLinksByType = (parent: Element, type: string): Array => { // @ts-ignore return Array.from(parent.children). filter( (e) => e.tagName.toLowerCase() == 'link' && (e.getAttribute('type') || '').toLowerCase().startsWith(type.toLowerCase()) && (e.getAttribute('href') || '').length > 0 ). map((e) => toAbsoluteURL(e.getAttribute('href'))). filter((l) => l != null) } // Get the RSS/Atom root element of the current page, if available const getFeedRoot = (): HTMLElement | null => { const xmlDoc = document.documentElement // Check if it's an RSS feed if (xmlDoc.tagName.toLowerCase() === 'rss') return xmlDoc // Check if it's an Atom feed if (xmlDoc.tagName.toLowerCase() === 'feed') return xmlDoc // Chrome-based browsers may wrap the XML into an HTML view const webkitSource = document.getElementById('webkit-xml-viewer-source-xml') if (webkitSource) return webkitSource // For some ugly reasons, some RSS feeds are rendered inside of a
 in a normal HTML DOM
  const preElements = document.getElementsByTagName('pre')
  if (preElements.length !== 1)
    return null

  return preElements[0]
}

// Convert an XML string to a DOM object if it's a valid feed
const textToDOM = (text: string) => {
  const parser = new DOMParser()
  let xmlDoc = null

  try {
    // @ts-ignore
    xmlDoc = parser.parseFromString(text, 'text/xml')
  } catch (e) { }

  if (!xmlDoc)
    return

  // @ts-ignore
  const root = xmlDoc.documentElement
  if (
    root.tagName.toLowerCase() === 'rss' ||
    root.tagName.toLowerCase() === 'feed'
  )
    return root
}

// Render a feed. It accepts an XML string as an argument.
// If not passed, it will try to render any feeds on the current page.
const renderFeed = (text: string, url: string | null) => {
  const xmlDoc = text?.length ? textToDOM(text) : getFeedRoot()
  if (!xmlDoc)
    // Not a feed
    return

  // Check if it's an RSS feed
  let feed = xmlDoc.getElementsByTagName('channel')[0]

  if (!feed) {
    // Check if it's an Atom feed
    if (xmlDoc.tagName.toLowerCase() !== 'feed')
      return

    feed = xmlDoc
  }

  // Save the parsed feed to the storage and redirect to the viewer
  browser.storage.local.set(parseFeed(feed, url))
  window.location.href = browser.runtime.getURL('viewer/index.html')
}

// Extract any feed URL published on the page
const extractFeedUrl = () => {
  const links = Array.from(document.getElementsByTagName('link'))
    .filter((link) =>
      link.getAttribute('rel') === 'alternate' &&
        (
          link.getAttribute('type')?.startsWith('application/rss+xml') ||
          link.getAttribute('type')?.startsWith('application/atom+xml')
        )
     )

  if (!links.length)
      return

  return toAbsoluteURL(links[0].getAttribute('href'))
}

// Main message listener
browser.runtime.onMessage.addListener(
  async (
    message: {
      type: string,
      url: string | null,
      document: string,
    }
  ) => {
    if (message.type === 'renderFeed')
      return renderFeed(message.document, message.url)
    if (message.type === 'extractFeedUrl')
      return extractFeedUrl()

    console.warn(`Received unknown message type: ${message.type}`)
  }
)