diff --git a/fimfarchive/converters/__init__.py b/fimfarchive/converters/__init__.py
index 813a406..85abf57 100644
--- a/fimfarchive/converters/__init__.py
+++ b/fimfarchive/converters/__init__.py
@@ -24,6 +24,7 @@ Converter module.
from .base import Converter
from .alpha_beta import AlphaBetaConverter
+from .fpub_epub import FpubEpubConverter
from .json_fpub import JsonFpubConverter
from .local_utc import LocalUtcConverter
@@ -31,6 +32,7 @@ from .local_utc import LocalUtcConverter
__all__ = (
'Converter',
'AlphaBetaConverter',
+ 'FpubEpubConverter',
'JsonFpubConverter',
'LocalUtcConverter',
)
diff --git a/fimfarchive/converters/fpub_epub.py b/fimfarchive/converters/fpub_epub.py
new file mode 100644
index 0000000..d3a2bf4
--- /dev/null
+++ b/fimfarchive/converters/fpub_epub.py
@@ -0,0 +1,100 @@
+"""
+FPUB to EPUB converter for story data.
+"""
+
+
+#
+# Fimfarchive, preserves stories from Fimfiction.
+# Copyright (C) 2019 Joakim Soderlund
+#
+# This program 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.
+#
+# 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 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 .
+#
+
+
+from functools import partial
+from pathlib import Path
+from shutil import rmtree
+from subprocess import DEVNULL, STDOUT, run
+from tempfile import mkdtemp
+from typing import Union
+
+from fimfarchive.flavors import DataFormat
+from fimfarchive.stories import Story
+from fimfarchive.utils import get_path
+
+from .base import Converter
+
+
+__all__ = (
+ 'FpubEpubConverter',
+)
+
+
+SOURCE = 'source.epub'
+TARGET = 'target.epub'
+TIMEOUT = 300
+
+PROGRAM = 'ebook-convert'
+ARGUMENTS = ('--no-default-epub-cover',)
+
+proc = partial(run, stderr=STDOUT, timeout=TIMEOUT, check=True)
+
+
+def ebook_convert(data: bytes, pipe: int) -> bytes:
+ """
+ Calls the external ebook-convert program.
+ """
+ parent = Path(mkdtemp())
+ source = parent / SOURCE
+ target = parent / TARGET
+
+ command = (PROGRAM, str(source), str(target), *ARGUMENTS)
+
+ try:
+ source.write_bytes(data)
+ proc(command, stdout=pipe)
+ except Exception:
+ raise
+ else:
+ return target.read_bytes()
+ finally:
+ rmtree(parent)
+
+
+class FpubEpubConverter(Converter):
+ """
+ Converts story data from FPUB to EPUB.
+ """
+
+ def __init__(self, logdir: Union[Path, str] = None) -> None:
+ self.logdir = get_path(logdir)
+
+ if self.logdir and not self.logdir.is_dir():
+ raise ValueError("Logdir must be a directory.")
+
+ def __call__(self, story: Story) -> Story:
+ if DataFormat.FPUB not in story.flavors:
+ raise ValueError(f"Missing flavor: {DataFormat.FPUB}")
+
+ if self.logdir is not None:
+ with open(self.logdir / str(story.key), 'a') as fobj:
+ data = ebook_convert(story.data, fobj.fileno())
+ else:
+ data = ebook_convert(story.data, DEVNULL)
+
+ flavors = set(story.flavors)
+ flavors.remove(DataFormat.FPUB)
+ flavors.add(DataFormat.EPUB)
+
+ return story.merge(data=data, flavors=flavors)
diff --git a/tests/converters/test_fpub_epub.py b/tests/converters/test_fpub_epub.py
new file mode 100644
index 0000000..511fc28
--- /dev/null
+++ b/tests/converters/test_fpub_epub.py
@@ -0,0 +1,143 @@
+"""
+FPUB to EPUB converter tests.
+"""
+
+
+#
+# Fimfarchive, preserves stories from Fimfiction.
+# Copyright (C) 2019 Joakim Soderlund
+#
+# This program 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.
+#
+# 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 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 .
+#
+
+
+from os import urandom, write
+from pathlib import Path
+from subprocess import DEVNULL
+from unittest.mock import patch
+
+import pytest
+
+from fimfarchive.converters import fpub_epub, FpubEpubConverter
+from fimfarchive.flavors import DataFormat
+
+
+class TestFpubEpubConverter:
+ """
+ FpubEpubConverter tests.
+ """
+
+ @pytest.fixture
+ def fpub(self):
+ """
+ Returns bytes simulating FPUB data.
+ """
+ return urandom(16)
+
+ @pytest.fixture
+ def epub(self):
+ """
+ Returns bytes simulating EPUB data.
+ """
+ return urandom(16)
+
+ @pytest.fixture
+ def log(self):
+ """
+ Returns bytes simulating log data.
+ """
+ return urandom(16)
+
+ @pytest.fixture
+ def calibre(self, fpub, epub, log):
+ """
+ Returns a function simulating Calibre.
+ """
+ def function(*args, **kwargs):
+ source = Path(args[0][1])
+ target = Path(args[0][2])
+ stdout = kwargs['stdout']
+
+ if 0 <= stdout:
+ write(stdout, log)
+
+ if fpub == source.read_bytes():
+ target.write_bytes(epub)
+
+ return function
+
+ @pytest.fixture
+ def proc(self, calibre):
+ """
+ Yeilds a mock for simulating process calls.
+ """
+ with patch.object(fpub_epub, 'proc') as mock:
+ mock.side_effect = calibre
+ yield mock
+
+ @pytest.fixture
+ def story(self, story, fpub):
+ """
+ Returns an FPUB story instance.
+ """
+ return story.merge(data=fpub, flavors=[DataFormat.FPUB])
+
+ def verify_call(self, call, pipe):
+ """
+ Verifies process call arguments.
+ """
+ name, args, kwargs = call
+ program, source, target, cover = args[0]
+ stdout = kwargs['stdout']
+
+ assert 1 == len(args)
+ assert 1 == len(kwargs)
+
+ assert 'ebook-convert' == program
+ assert 'source.epub' == Path(source).name
+ assert 'target.epub' == Path(target).name
+ assert '--no-default-epub-cover' == cover
+
+ assert pipe is (DEVNULL != stdout)
+
+ def test_without_log(self, story, fpub, epub, proc):
+ """
+ Tests convertion without logging.
+ """
+ convert = FpubEpubConverter()
+ output = convert(story)
+ calls = proc.mock_calls
+
+ assert 1 == len(calls)
+ assert fpub == story.data
+ assert epub == output.data
+
+ self.verify_call(calls[0], False)
+
+ def test_with_log(self, tmpdir, story, fpub, epub, log, proc):
+ """
+ Tests convertion with logging.
+ """
+ tmppath = Path(str(tmpdir))
+ convert = FpubEpubConverter(tmppath)
+ logfile = tmppath / str(story.key)
+ output = convert(story)
+ calls = proc.mock_calls
+
+ assert 1 == len(calls)
+ assert fpub == story.data
+ assert epub == output.data
+ assert log == logfile.read_bytes()
+
+ self.verify_call(calls[0], True)