gpodder/src/gpodder/opml.py

198 lines
7.0 KiB
Python

# -*- coding: utf-8 -*-
#
# gPodder - A media aggregator and podcast client
# Copyright (c) 2005-2018 The gPodder Team
#
# gPodder is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# gPodder 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
#
# opml.py -- OPML import and export functionality
# Thomas Perl <thp@perli.net> 2007-08-19
#
# based on: libopmlreader.py (2006-06-13)
# libopmlwriter.py (2005-12-08)
#
"""OPML import and export functionality
This module contains helper classes to import subscriptions
from OPML files on the web and to export a list of channel
objects to valid OPML 1.1 files that can be used to backup
or distribute gPodder's channel subscriptions.
"""
import logging
logger = logging.getLogger(__name__)
from gpodder import util
import xml.dom.minidom
import os.path
import os
import shutil
from email.utils import formatdate
import gpodder
class Importer(object):
"""
Helper class to import an OPML feed from protocols
supported by urllib2 (e.g. HTTP) and return a GTK
ListStore that can be displayed in the GUI.
This class should support standard OPML feeds and
contains workarounds to support odeo.com feeds.
"""
VALID_TYPES = ('rss', 'link')
def __init__( self, url):
"""
Parses the OPML feed from the given URL into
a local data structure containing channel metadata.
"""
self.items = []
try:
if os.path.exists(url):
doc = xml.dom.minidom.parse(url)
else:
# FIXME: is it ok to pass bytes to parseString?
doc = xml.dom.minidom.parseString(util.urlopen(url).read())
for outline in doc.getElementsByTagName('outline'):
# Make sure we are dealing with a valid link type (ignore case)
otl_type = outline.getAttribute('type')
if otl_type is None or otl_type.lower() not in self.VALID_TYPES:
continue
if outline.getAttribute('xmlUrl') or outline.getAttribute('url'):
channel = {
'url': outline.getAttribute('xmlUrl') or outline.getAttribute('url'),
'title': outline.getAttribute('title') or outline.getAttribute('text') or outline.getAttribute('xmlUrl') or outline.getAttribute('url'),
'description': outline.getAttribute('text') or outline.getAttribute('xmlUrl') or outline.getAttribute('url'),
}
if channel['description'] == channel['title']:
channel['description'] = channel['url']
for attr in ( 'url', 'title', 'description' ):
channel[attr] = channel[attr].strip()
self.items.append( channel)
if not len(self.items):
logger.info('OPML import finished, but no items found: %s', url)
except:
logger.error('Cannot import OPML from URL: %s', url, exc_info=True)
class Exporter(object):
"""
Helper class to export a list of channel objects
to a local file in OPML 1.1 format.
See www.opml.org for the OPML specification.
"""
FEED_TYPE = 'rss'
def __init__( self, filename):
if filename is None:
self.filename = None
elif filename.endswith( '.opml') or filename.endswith( '.xml'):
self.filename = filename
else:
self.filename = '%s.opml' % ( filename, )
def create_node( self, doc, name, content):
"""
Creates a simple XML Element node in a document
with tag name "name" and text content "content",
as in <name>content</name> and returns the element.
"""
node = doc.createElement( name)
node.appendChild( doc.createTextNode( content))
return node
def create_outline( self, doc, channel):
"""
Creates a OPML outline as XML Element node in a
document for the supplied channel.
"""
outline = doc.createElement( 'outline')
outline.setAttribute( 'title', channel.title)
outline.setAttribute( 'text', channel.description)
outline.setAttribute( 'xmlUrl', channel.url)
outline.setAttribute( 'type', self.FEED_TYPE)
return outline
def write( self, channels):
"""
Creates a XML document containing metadata for each
channel object in the "channels" parameter, which
should be a list of channel objects.
OPML 2.0 specification: http://www.opml.org/spec2
Returns True on success or False when there was an
error writing the file.
"""
if self.filename is None:
return False
doc = xml.dom.minidom.Document()
opml = doc.createElement('opml')
opml.setAttribute('version', '2.0')
doc.appendChild(opml)
head = doc.createElement( 'head')
head.appendChild( self.create_node( doc, 'title', 'gPodder subscriptions'))
head.appendChild( self.create_node( doc, 'dateCreated', formatdate(localtime=True)))
opml.appendChild( head)
body = doc.createElement( 'body')
for channel in channels:
body.appendChild( self.create_outline( doc, channel))
opml.appendChild( body)
try:
data = doc.toprettyxml(encoding='utf-8', indent=' ', newl=os.linesep)
# We want to have at least 512 KiB free disk space after
# saving the opml data, if this is not possible, don't
# try to save the new file, but keep the old one so we
# don't end up with a clobbed, empty opml file.
FREE_DISK_SPACE_AFTER = 1024*512
path = os.path.dirname(self.filename) or os.path.curdir
available = util.get_free_disk_space(path)
if available != -1 and available < 2*len(data)+FREE_DISK_SPACE_AFTER:
# On Windows, if we have zero bytes available, assume that we have
# not had the win32file module available + assume enough free space
if not gpodder.ui.win32 or available > 0:
logger.error('Not enough free disk space to save channel list to %s', self.filename)
return False
fp = open(self.filename+'.tmp', 'wb')
fp.write(data)
fp.close()
util.atomic_rename(self.filename+'.tmp', self.filename)
except:
logger.error('Could not open file for writing: %s', self.filename,
exc_info=True)
return False
return True