libervia-backend/src/plugins/plugin_blog_import_dokuwiki.py

392 lines
15 KiB
Python
Executable file

#!/usr/bin/env python2
# -*- coding: utf-8 -*-
# SàT plugin to import dokuwiki blogs
# Copyright (C) 2009-2016 Jérôme Poisson (goffi@goffi.org)
# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.org)
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# This program 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 Affero General Public License for more details.
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from sat.core.i18n import _, D_
from sat.core.constants import Const as C
from sat.core.log import getLogger
log = getLogger(__name__)
from sat.core import exceptions
from sat.tools import xml_tools
from twisted.internet import threads
from collections import OrderedDict
import calendar
import urllib
import urlparse
import tempfile
import re
import time
import os.path
try:
from dokuwiki import DokuWiki, DokuWikiError # this is a new dependency
except ImportError:
raise exceptions.MissingModule(u'Missing module dokuwiki, please install it with "pip install dokuwiki"')
try:
from PIL import Image # this is already needed by plugin XEP-0054
except:
raise exceptions.MissingModule(u"Missing module pillow, please download/install it from https://python-pillow.github.io")
PLUGIN_INFO = {
C.PI_NAME: "Dokuwiki import",
C.PI_IMPORT_NAME: "IMPORT_DOKUWIKI",
C.PI_TYPE: C.PLUG_TYPE_BLOG,
C.PI_DEPENDENCIES: ["BLOG_IMPORT"],
C.PI_MAIN: "DokuwikiImport",
C.PI_HANDLER: "no",
C.PI_DESCRIPTION: _("""Blog importer for Dokuwiki blog engine.""")
}
SHORT_DESC = D_(u"import posts from Dokuwiki blog engine")
LONG_DESC = D_(u"""This importer handle Dokuwiki blog engine.
To use it, you need an admin access to a running Dokuwiki website
(local or on the Internet). The importer retrieves the data using
the XMLRPC Dokuwiki API.
You can specify a namespace (that could be a namespace directory
or a single post) or leave it empty to use the root namespace "/"
and import all the posts.
You can specify a new media repository to modify the internal
media links and make them point to the URL of your choice, but
note that the upload is not done automatically: a temporary
directory will be created on your local drive and you will
need to upload it yourself to your repository via SSH or FTP.
Following options are recognized:
location: DokuWiki site URL
user: DokuWiki admin user
passwd: DokuWiki admin password
namespace: DokuWiki namespace to import (default: root namespace "/")
media_repo: URL to the new remote media repository (default: none)
limit: maximal number of posts to import (default: 100)
Example of usage (with jp frontend):
jp import dokuwiki -p dave --pwd xxxxxx --connect
http://127.0.1.1 -o user souliane -o passwd qwertz
-o namespace public:2015:10
-o media_repo http://media.diekulturvermittlung.at
This retrieves the 100 last blog posts from http://127.0.1.1 that
are inside the namespace "public:2015:10" using the Dokuwiki user
"souliane", and it imports them to sat profile dave's microblog node.
Internal Dokuwiki media that were hosted on http://127.0.1.1 are now
pointing to http://media.diekulturvermittlung.at.
""")
DEFAULT_MEDIA_REPO = ""
DEFAULT_NAMESPACE = "/"
DEFAULT_LIMIT = 100 # you might get a DBUS timeout (no reply) if it lasts too long
class Importer(DokuWiki):
def __init__(self, url, user, passwd, media_repo=DEFAULT_MEDIA_REPO, limit=DEFAULT_LIMIT):
"""
@param url (unicode): DokuWiki site URL
@param user (unicode): DokuWiki admin user
@param passwd (unicode): DokuWiki admin password
@param media_repo (unicode): New remote media repository
"""
DokuWiki.__init__(self, url, user, passwd)
self.url = url
self.media_repo = media_repo
self.temp_dir = tempfile.mkdtemp() if self.media_repo else None
self.limit = limit
self.posts_data = OrderedDict()
def getPostId(self, post):
"""Return a unique and constant post id
@param post(dict): parsed post data
@return (unicode): post unique item id
"""
return unicode(post['id'])
def getPostUpdated(self, post):
"""Return the update date.
@param post(dict): parsed post data
@return (unicode): update date
"""
return unicode(post['mtime'])
def getPostPublished(self, post):
"""Try to parse the date from the message ID, else use "mtime".
The date can be extracted if the message ID looks like one of:
- namespace:YYMMDD_short_title
- namespace:YYYYMMDD_short_title
@param post (dict): parsed post data
@return (unicode): publication date
"""
id_, default = unicode(post["id"]), unicode(post["mtime"])
try:
date = id_.split(":")[-1].split("_")[0]
except KeyError:
return default
try:
time_struct = time.strptime(date, "%y%m%d")
except ValueError:
try:
time_struct = time.strptime(date, "%Y%m%d")
except ValueError:
return default
return unicode(calendar.timegm(time_struct))
def processPost(self, post, profile_jid):
"""Process a single page.
@param post (dict): parsed post data
@param profile_jid
"""
# get main information
id_ = self.getPostId(post)
updated = self.getPostUpdated(post)
published = self.getPostPublished(post)
# manage links
backlinks = self.pages.backlinks(id_)
for link in self.pages.links(id_):
if link["type"] != "extern":
assert link["type"] == "local"
page = link["page"]
backlinks.append(page[1:] if page.startswith(":") else page)
self.pages.get(id_)
content_xhtml = self.processContent(self.pages.html(id_), backlinks, profile_jid)
# XXX: title is already in content_xhtml and difficult to remove, so leave it
# title = content.split("\n")[0].strip(u"\ufeff= ")
# build the extra data dictionary
mb_data = {"id": id_,
"published": published,
"updated": updated,
"author": profile_jid.user,
# "content": content, # when passed, it is displayed in Libervia instead of content_xhtml
"content_xhtml": content_xhtml,
# "title": title,
"allow_comments": "true",
}
# find out if the message access is public or restricted
namespace = id_.split(":")[0]
if namespace and namespace.lower() not in ("public", "/"):
mb_data["group"] = namespace # roster group must exist
self.posts_data[id_] = {'blog': mb_data, 'comments':[[]]}
def process(self, client, namespace=DEFAULT_NAMESPACE):
"""Process a namespace or a single page.
@param namespace (unicode): DokuWiki namespace (or page) to import
"""
profile_jid = client.jid
log.info("Importing data from DokuWiki %s" % self.version)
try:
pages_list = self.pages.list(namespace)
except DokuWikiError:
log.warning('Could not list Dokuwiki pages: please turn the "display_errors" setting to "Off" in the php.ini of the webserver hosting DokuWiki.')
return
if not pages_list: # namespace is actually a page?
names = namespace.split(":")
real_namespace = ":".join(names[0:-1])
pages_list = self.pages.list(real_namespace)
pages_list = [page for page in pages_list if page["id"] == namespace]
namespace = real_namespace
count = 0
for page in pages_list:
self.processPost(page, profile_jid)
count += 1
if count >= self.limit :
break
return (self.posts_data.itervalues(), len(self.posts_data))
def processContent(self, text, backlinks, profile_jid):
"""Do text substitutions and file copy.
@param text (unicode): message content
@param backlinks (list[unicode]): list of backlinks
"""
text = text.strip(u"\ufeff") # this is at the beginning of the file (BOM)
for backlink in backlinks:
src = '/doku.php?id=%s"' % backlink
tgt = '/blog/%s/%s" target="#"' % (profile_jid.user, backlink)
text = text.replace(src, tgt)
subs = {}
link_pattern = r"""<(img|a)[^>]* (src|href)="([^"]+)"[^>]*>"""
for tag in re.finditer(link_pattern, text):
type_, attr, link = tag.group(1), tag.group(2), tag.group(3)
assert (type_ == "img" and attr == "src") or (type_ == "a" and attr == "href")
if re.match(r"^\w*://", link): # absolute URL to link directly
continue
if self.media_repo:
self.moveMedia(link, subs)
elif link not in subs:
subs[link] = urlparse.urljoin(self.url, link)
for url, new_url in subs.iteritems():
text = text.replace(url, new_url)
return text
def moveMedia(self, link, subs):
"""Move a media from the DokuWiki host to the new repository.
This also updates the hyperlinks to internal media files.
@param link (unicode): media link
@param subs (dict): substitutions data
"""
url = urlparse.urljoin(self.url, link)
user_media = re.match(r"(/lib/exe/\w+.php\?)(.*)", link)
thumb_width = None
if user_media: # media that has been added by the user
params = urlparse.parse_qs(urlparse.urlparse(url).query)
try:
media = params["media"][0]
except KeyError:
log.warning("No media found in fetch URL: %s" % user_media.group(2))
return
if re.match(r"^\w*://", media): # external URL to link directly
subs[link] = media
return
try: # create thumbnail
thumb_width = params["w"][0]
except KeyError:
pass
filename = media.replace(":", "/")
# XXX: avoid "precondition failed" error (only keep the media parameter)
url = urlparse.urljoin(self.url, "/lib/exe/fetch.php?media=%s" % media)
elif link.startswith("/lib/plugins/"):
# other link added by a plugin or something else
filename = link[13:]
else: # fake alert... there's no media (or we don't handle it yet)
return
filepath = os.path.join(self.temp_dir, filename)
self.downloadMedia(url, filepath)
if thumb_width:
filename = os.path.join("thumbs", thumb_width, filename)
thumbnail = os.path.join(self.temp_dir, filename)
self.createThumbnail(filepath, thumbnail, thumb_width)
new_url = os.path.join(self.media_repo, filename)
subs[link] = new_url
def downloadMedia(self, source, dest):
"""Copy media to localhost.
@param source (unicode): source url
@param dest (unicode): target path
"""
dirname = os.path.dirname(dest)
if not os.path.exists(dest):
if not os.path.exists(dirname):
os.makedirs(dirname)
urllib.urlretrieve(source, dest)
log.debug("DokuWiki media file copied to %s" % dest)
def createThumbnail(self, source, dest, width):
"""Create a thumbnail.
@param source (unicode): source file path
@param dest (unicode): destination file path
@param width (unicode): thumbnail's width
"""
thumb_dir = os.path.dirname(dest)
if not os.path.exists(thumb_dir):
os.makedirs(thumb_dir)
try:
im = Image.open(source)
im.thumbnail((width, int(width) * im.size[0] / im.size[1]))
im.save(dest)
log.debug("DokuWiki media thumbnail created: %s" % dest)
except IOError:
log.error("Cannot create DokuWiki media thumbnail %s" % dest)
class DokuwikiImport(object):
def __init__(self, host):
log.info(_("plugin Dokuwiki Import initialization"))
self.host = host
self._blog_import = host.plugins['BLOG_IMPORT']
self._blog_import.register('dokuwiki', self.DkImport, SHORT_DESC, LONG_DESC)
def DkImport(self, client, location, options=None):
"""Import from DokuWiki to PubSub
@param location (unicode): DokuWiki site URL
@param options (dict, None): DokuWiki import parameters
- user (unicode): DokuWiki admin user
- passwd (unicode): DokuWiki admin password
- namespace (unicode): DokuWiki namespace to import
- media_repo (unicode): New remote media repository
"""
options[self._blog_import.OPT_HOST] = location
try:
user = options["user"]
except KeyError:
raise exceptions.DataError('parameter "user" is required')
try:
passwd = options["passwd"]
except KeyError:
raise exceptions.DataError('parameter "passwd" is required')
opt_upload_images = options.get(self._blog_import.OPT_UPLOAD_IMAGES, None)
try:
media_repo = options["media_repo"]
if opt_upload_images:
options[self._blog_import.OPT_UPLOAD_IMAGES] = False # force using --no-images-upload
info_msg = _("DokuWiki media files will be *downloaded* to {temp_dir} - to finish the import you have to upload them *manually* to {media_repo}")
except KeyError:
media_repo = DEFAULT_MEDIA_REPO
if opt_upload_images:
info_msg = _("DokuWiki media files will be *uploaded* to the XMPP server. Hyperlinks to these media may not been updated though.")
else:
info_msg = _("DokuWiki media files will *stay* on {location} - some of them may be protected by DokuWiki ACL and will not be accessible.")
try:
namespace = options["namespace"]
except KeyError:
namespace = DEFAULT_NAMESPACE
try:
limit = options["limit"]
except KeyError:
limit = DEFAULT_LIMIT
dk_importer = Importer(location, user, passwd, media_repo, limit)
info_msg = info_msg.format(temp_dir=dk_importer.temp_dir, media_repo=media_repo, location=location)
self.host.actionNew({'xmlui': xml_tools.note(info_msg).toXml()}, profile=client.profile)
d = threads.deferToThread(dk_importer.process, client, namespace)
return d