diff --git a/fimfarchive/commands/__init__.py b/fimfarchive/commands/__init__.py
index 96631e0..00ccc3c 100644
--- a/fimfarchive/commands/__init__.py
+++ b/fimfarchive/commands/__init__.py
@@ -24,9 +24,11 @@ Command module.
from .base import Command
from .root import RootCommand
+from .update import UpdateCommand
__all__ = (
'Command',
'RootCommand',
+ 'UpdateCommand',
)
diff --git a/fimfarchive/commands/root.py b/fimfarchive/commands/root.py
index 3f6611d..b5adcc9 100644
--- a/fimfarchive/commands/root.py
+++ b/fimfarchive/commands/root.py
@@ -25,6 +25,7 @@ Root command.
from typing import Dict, Type
from .base import Command
+from .update import UpdateCommand
__all__ = (
@@ -36,7 +37,9 @@ class RootCommand(Command):
"""
The main application command.
"""
- commands: Dict[str, Type[Command]] = dict()
+ commands: Dict[str, Type[Command]] = {
+ 'update': UpdateCommand,
+ }
def load(self, command: str) -> Command:
"""
diff --git a/fimfarchive/commands/update.py b/fimfarchive/commands/update.py
new file mode 100644
index 0000000..db20dfc
--- /dev/null
+++ b/fimfarchive/commands/update.py
@@ -0,0 +1,218 @@
+"""
+Update command.
+"""
+
+
+#
+# Fimfarchive, preserves stories from Fimfiction.
+# Copyright (C) 2015 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 .
+#
+
+
+import traceback
+from argparse import ArgumentParser, FileType
+from typing import Any, Iterable, Iterator, Optional
+
+from jmespath import search
+
+from fimfarchive.fetchers import FimfarchiveFetcher, FimfictionFetcher
+from fimfarchive.flavors import UpdateStatus
+from fimfarchive.signals import SignalReceiver
+from fimfarchive.stories import Story
+from fimfarchive.tasks import UpdateTask
+
+from .base import Command
+
+
+__all__ = (
+ 'UpdateCommand',
+)
+
+
+class StoryFormatter(Iterable[str]):
+ """
+ Generates a text representation of story meta.
+ """
+ attrs = (
+ 'title',
+ 'author',
+ 'status',
+ 'words',
+ 'likes',
+ 'dislikes',
+ 'approval',
+ 'chapters',
+ 'action',
+ )
+
+ def __init__(self, story: Story) -> None:
+ """
+ Constructor.
+
+ Args:
+ story: Instance to represent.
+ """
+ self.story = story
+
+ def __getattr__(self, key: str) -> Any:
+ """
+ Returns a value from story meta, or None.
+ """
+ meta = self.story.meta
+ return meta.get(key)
+
+ def __iter__(self) -> Iterator[str]:
+ """
+ Yields the text representation line by line.
+ """
+ for attr in self.attrs:
+ label = attr.capitalize()
+ value = getattr(self, attr)
+ yield f"{label}: {value}"
+
+ def __str__(self) -> str:
+ """
+ Returns the entire text representation.
+ """
+ return '\n'.join(self)
+
+ @property
+ def author(self) -> Optional[str]:
+ """
+ Returns the name of the author, or None.
+ """
+ meta = self.story.meta
+ return search('author.name', meta)
+
+ @property
+ def approval(self) -> Optional[str]:
+ """
+ Returns the likes to dislikes ratio, or None.
+ """
+ meta = self.story.meta
+ likes = meta.get('likes')
+ dislikes = meta.get('dislikes')
+
+ try:
+ ratio = likes / (likes + dislikes)
+ except TypeError:
+ return None
+ except ZeroDivisionError:
+ return f"{0:.0%}"
+ else:
+ return f"{ratio:.0%}"
+
+ @property
+ def chapters(self) -> Optional[int]:
+ """
+ Returns the number of chapters, or None.
+ """
+ meta = self.story.meta
+ chapters = meta.get('chapters')
+
+ try:
+ return len(chapters)
+ except TypeError:
+ return None
+
+ @property
+ def action(self) -> Optional[str]:
+ """
+ Returns the `UpdateStatus` name, or None.
+ """
+ for flavor in self.story.flavors:
+ if isinstance(flavor, UpdateStatus):
+ return flavor.name.capitalize()
+
+ return None
+
+
+class UpdatePrinter(SignalReceiver):
+ """
+ Prints story information.
+ """
+
+ def on_attempt(self, sender, key, skips, retries):
+ """
+ Shows an upcoming fetch attempt.
+ """
+ print(f"\nStory: {key}")
+
+ if retries:
+ print(f"Retries: {retries}")
+ else:
+ print(f"Skips: {skips}")
+
+ def on_success(self, sender, key, story):
+ """
+ Shows the story from a successful fetch.
+ """
+ print(StoryFormatter(story))
+
+ def on_skipped(self, sender, key, story):
+ """
+ Shows information from a skipped fetch.
+ """
+ if story:
+ print(StoryFormatter(story))
+ else:
+ print("Status: Missing")
+
+ def on_failure(self, sender, key, error):
+ """
+ Shows the exception from a failed fetch.
+ """
+ print("Error:", error)
+ traceback.print_exc()
+
+
+class UpdateCommand(Command):
+ """
+ Fetches updates from Fimfiction.
+ """
+
+ @property
+ def parser(self) -> ArgumentParser:
+ """
+ Returns a command line arguments parser.
+ """
+ parser = ArgumentParser(
+ prog='',
+ description=self.__doc__,
+ )
+
+ parser.add_argument(
+ '--archive',
+ help="previous version of the archive",
+ type=FileType('rb'),
+ required=True,
+ metavar='PATH',
+ )
+
+ return parser
+
+ def __call__(self, *args):
+ opts = self.parser.parse_args(args)
+
+ task = UpdateTask(
+ fimfarchive=FimfarchiveFetcher(opts.archive),
+ fimfiction=FimfictionFetcher(),
+ )
+
+ with UpdatePrinter(task):
+ task.run()
+
+ return 0
diff --git a/requirements.txt b/requirements.txt
index d9bc224..14f5624 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,2 +1,3 @@
blinker
+jmespath
requests
diff --git a/tests/commands/test_update.py b/tests/commands/test_update.py
new file mode 100644
index 0000000..931b8b0
--- /dev/null
+++ b/tests/commands/test_update.py
@@ -0,0 +1,136 @@
+"""
+Update command tests.
+"""
+
+
+#
+# Fimfarchive, preserves stories from Fimfiction.
+# Copyright (C) 2015 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 textwrap import dedent
+from typing import Any, Dict, Set
+
+from fimfarchive.flavors import Flavor, MetaPurity, UpdateStatus
+from fimfarchive.commands.update import StoryFormatter
+
+
+class TestStoryFormatter():
+ """
+ StoryFormatter tests.
+ """
+
+ def assert_formatted_equals(self, expected, story):
+ """
+ Asserts that the formatted story matches the expected text.
+ """
+ formatted = str(StoryFormatter(story))
+ dedented = dedent(expected).strip()
+
+ assert dedented == formatted
+
+ def test_empty_meta(self, story):
+ """
+ Tests story formatting with empty meta.
+ """
+ flavors: Set[Flavor] = set()
+ meta: Dict[str, Any] = dict()
+
+ expected = """
+ Title: None
+ Author: None
+ Status: None
+ Words: None
+ Likes: None
+ Dislikes: None
+ Approval: None
+ Chapters: None
+ Action: None
+ """
+
+ story = story.merge(meta=meta, flavors=flavors)
+ self.assert_formatted_equals(expected, story)
+
+ def test_complete_meta(self, story):
+ """
+ Tests story formatting with complete meta.
+ """
+ flavors: Set[Flavor] = {
+ UpdateStatus.CREATED,
+ }
+
+ meta: Dict[str, Any] = {
+ 'title': 'A',
+ 'author': {
+ 'name': 'B'
+ },
+ 'status': 'C',
+ 'words': 4,
+ 'likes': 3,
+ 'dislikes': 2,
+ 'chapters': [
+ 1
+ ],
+ }
+
+ expected = """
+ Title: A
+ Author: B
+ Status: C
+ Words: 4
+ Likes: 3
+ Dislikes: 2
+ Approval: 60%
+ Chapters: 1
+ Action: Created
+ """
+
+ story = story.merge(meta=meta, flavors=flavors)
+ self.assert_formatted_equals(expected, story)
+
+ def test_edge_meta(self, story):
+ """
+ Tests story formatting with some edge cases.
+ """
+ flavors: Set[Flavor] = {
+ MetaPurity.DIRTY,
+ }
+
+ meta: Dict[str, Any] = {
+ 'title': None,
+ 'author': {},
+ 'status': {},
+ 'words': 0,
+ 'likes': 0,
+ 'dislikes': 0,
+ 'chapters': (),
+ }
+
+ expected = """
+ Title: None
+ Author: None
+ Status: {}
+ Words: 0
+ Likes: 0
+ Dislikes: 0
+ Approval: 0%
+ Chapters: 0
+ Action: None
+ """
+
+ story = story.merge(meta=meta, flavors=flavors)
+ self.assert_formatted_equals(expected, story)