mirror of
https://github.com/JockeTF/fimfarchive.git
synced 2024-11-22 05:17:59 +01:00
Add mapper for story slug
This commit is contained in:
parent
5bb146bfee
commit
e80a3d1c2c
2 changed files with 275 additions and 6 deletions
|
@ -5,7 +5,7 @@ Mappers for Fimfarchive.
|
||||||
|
|
||||||
#
|
#
|
||||||
# Fimfarchive, preserves stories from Fimfiction.
|
# Fimfarchive, preserves stories from Fimfiction.
|
||||||
# Copyright (C) 2015 Joakim Soderlund
|
# Copyright (C) 2018 Joakim Soderlund
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# 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
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
@ -23,13 +23,15 @@ Mappers for Fimfarchive.
|
||||||
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import string
|
||||||
from abc import abstractmethod
|
from abc import abstractmethod
|
||||||
from typing import Dict, Generic, Optional, Set, TypeVar
|
from html import unescape
|
||||||
|
from typing import Dict, Generic, Optional, Set, TypeVar, Union
|
||||||
|
|
||||||
from arrow import api as arrow, Arrow
|
from arrow import api as arrow, Arrow
|
||||||
|
|
||||||
from fimfarchive.exceptions import InvalidStoryError
|
from fimfarchive.exceptions import InvalidStoryError
|
||||||
from fimfarchive.flavors import MetaFormat
|
from fimfarchive.flavors import DataFormat, MetaFormat
|
||||||
from fimfarchive.stories import Story
|
from fimfarchive.stories import Story
|
||||||
from fimfarchive.utils import find_flavor
|
from fimfarchive.utils import find_flavor
|
||||||
|
|
||||||
|
@ -39,6 +41,7 @@ __all__ = (
|
||||||
'StaticMapper',
|
'StaticMapper',
|
||||||
'StoryDateMapper',
|
'StoryDateMapper',
|
||||||
'StoryPathMapper',
|
'StoryPathMapper',
|
||||||
|
'StorySlugMapper',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -119,6 +122,151 @@ class StoryPathMapper(Mapper[str]):
|
||||||
return os.path.join(directory, key)
|
return os.path.join(directory, key)
|
||||||
|
|
||||||
|
|
||||||
|
class StorySlugMapper(Mapper[str]):
|
||||||
|
"""
|
||||||
|
Returns a slug-based file path for a story.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, template: str = None) -> None:
|
||||||
|
"""
|
||||||
|
Constructor.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
template: Format string for the path.
|
||||||
|
"""
|
||||||
|
self.template = '{extension}/{group}/{author}/{story}.{extension}'
|
||||||
|
self.whitelist = set(string.ascii_letters + string.digits)
|
||||||
|
self.groupings = set(string.ascii_lowercase)
|
||||||
|
self.ignore = {'\''}
|
||||||
|
self.spacing = '_'
|
||||||
|
self.part_limit = 112
|
||||||
|
self.slug_limit = 255
|
||||||
|
|
||||||
|
if template is not None:
|
||||||
|
self.template = template
|
||||||
|
|
||||||
|
def slugify(self, text: str) -> str:
|
||||||
|
"""
|
||||||
|
Creates a slug from any text.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: The text to slufigy.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A slugified version of the text.
|
||||||
|
"""
|
||||||
|
chars = [self.spacing]
|
||||||
|
|
||||||
|
for char in unescape(str(text)):
|
||||||
|
if char in self.whitelist:
|
||||||
|
chars.append(char)
|
||||||
|
elif char not in self.ignore and chars[-1] != self.spacing:
|
||||||
|
chars.append(self.spacing)
|
||||||
|
|
||||||
|
slug = ''.join(chars).strip(self.spacing)
|
||||||
|
|
||||||
|
if self.part_limit < len(slug):
|
||||||
|
limit = self.part_limit + 1
|
||||||
|
chars = slug[:limit].split(self.spacing)
|
||||||
|
slug = self.spacing.join(chars[:-1])
|
||||||
|
|
||||||
|
return slug.lower()
|
||||||
|
|
||||||
|
def join(self, key: int, slug: Optional[str]) -> str:
|
||||||
|
"""
|
||||||
|
Appends a key to a slug.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: The key to append.
|
||||||
|
slug: The target slug.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A string with both slug and key.
|
||||||
|
"""
|
||||||
|
key = int(key)
|
||||||
|
|
||||||
|
if key < 0:
|
||||||
|
raise ValueError("Key must not be negative.")
|
||||||
|
|
||||||
|
if slug:
|
||||||
|
return f'{slug}-{key}'
|
||||||
|
else:
|
||||||
|
return f'{key}'
|
||||||
|
|
||||||
|
def group(self, slug: str) -> str:
|
||||||
|
"""
|
||||||
|
Returns the group for part of a path.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
slug: The slug to group.
|
||||||
|
|
||||||
|
Return:
|
||||||
|
A group for the slug.
|
||||||
|
"""
|
||||||
|
path = slug[:1]
|
||||||
|
|
||||||
|
if path not in self.groupings:
|
||||||
|
path = self.spacing
|
||||||
|
|
||||||
|
return path
|
||||||
|
|
||||||
|
def classify(self, story: Story) -> str:
|
||||||
|
"""
|
||||||
|
Returns a file extension for the story.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
story: The story to classify.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A file extension.
|
||||||
|
"""
|
||||||
|
for flavor in story.flavors:
|
||||||
|
if isinstance(flavor, DataFormat):
|
||||||
|
return flavor.name.lower()
|
||||||
|
|
||||||
|
return 'data'
|
||||||
|
|
||||||
|
def generate(self, story: Story) -> Dict[str, Union[int, str]]:
|
||||||
|
"""
|
||||||
|
Returns the parts of a path.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
story: The story to generate parts from.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The parts of the path.
|
||||||
|
"""
|
||||||
|
story_meta = story.meta
|
||||||
|
author_meta = story.meta['author']
|
||||||
|
|
||||||
|
story_slug = self.slugify(story_meta['title'])
|
||||||
|
author_slug = self.slugify(author_meta['name'])
|
||||||
|
story_path = self.join(story_meta['id'], story_slug)
|
||||||
|
author_path = self.join(author_meta['id'], author_slug)
|
||||||
|
group_path = self.group(author_path)
|
||||||
|
extension = self.classify(story)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'group': group_path,
|
||||||
|
'author': author_path,
|
||||||
|
'author_key': author_meta['id'],
|
||||||
|
'author_slug': author_path,
|
||||||
|
'story': story_path,
|
||||||
|
'story_key': story_meta['id'],
|
||||||
|
'story_slug': story_slug,
|
||||||
|
'extension': extension,
|
||||||
|
}
|
||||||
|
|
||||||
|
def __call__(self, story: Story) -> str:
|
||||||
|
parts = self.generate(story)
|
||||||
|
path = self.template.format(**parts)
|
||||||
|
|
||||||
|
if self.slug_limit < len(path):
|
||||||
|
raise ValueError("Path too long: {}".format(path))
|
||||||
|
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
class MetaFormatMapper(Mapper[Optional[MetaFormat]]):
|
class MetaFormatMapper(Mapper[Optional[MetaFormat]]):
|
||||||
"""
|
"""
|
||||||
Guesses the meta format of stories.
|
Guesses the meta format of stories.
|
||||||
|
|
|
@ -5,7 +5,7 @@ Mapper tests.
|
||||||
|
|
||||||
#
|
#
|
||||||
# Fimfarchive, preserves stories from Fimfiction.
|
# Fimfarchive, preserves stories from Fimfiction.
|
||||||
# Copyright (C) 2015 Joakim Soderlund
|
# Copyright (C) 2018 Joakim Soderlund
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# 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
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
@ -29,9 +29,10 @@ from unittest.mock import patch, MagicMock, PropertyMock
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from fimfarchive.exceptions import InvalidStoryError
|
from fimfarchive.exceptions import InvalidStoryError
|
||||||
from fimfarchive.flavors import MetaFormat
|
from fimfarchive.flavors import DataFormat, MetaFormat
|
||||||
from fimfarchive.mappers import (
|
from fimfarchive.mappers import (
|
||||||
MetaFormatMapper, StaticMapper, StoryDateMapper, StoryPathMapper
|
MetaFormatMapper, StaticMapper, StoryDateMapper,
|
||||||
|
StoryPathMapper, StorySlugMapper
|
||||||
)
|
)
|
||||||
from fimfarchive.stories import Story
|
from fimfarchive.stories import Story
|
||||||
|
|
||||||
|
@ -281,6 +282,126 @@ class TestStoryPathMapper:
|
||||||
assert story.key.__str__.called_once_with()
|
assert story.key.__str__.called_once_with()
|
||||||
|
|
||||||
|
|
||||||
|
class TestStorySlugMapper:
|
||||||
|
"""
|
||||||
|
StorySlugMapper tests.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mapper(self) -> StorySlugMapper:
|
||||||
|
"""
|
||||||
|
Returns a mapper instance.
|
||||||
|
"""
|
||||||
|
return StorySlugMapper()
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def story(self, story: Story) -> Story:
|
||||||
|
"""
|
||||||
|
Returns a story instance.
|
||||||
|
"""
|
||||||
|
meta = {
|
||||||
|
'id': 1337,
|
||||||
|
'title': 'title',
|
||||||
|
'author': {
|
||||||
|
'id': 42,
|
||||||
|
'name': 'name',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return story.merge(
|
||||||
|
meta=meta,
|
||||||
|
flavors=[DataFormat.EPUB]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_mapping(self, mapper, story):
|
||||||
|
"""
|
||||||
|
Tests a simple slug mapping.
|
||||||
|
"""
|
||||||
|
assert mapper(story) == 'epub/n/name-42/title-1337.epub'
|
||||||
|
|
||||||
|
def test_custom_mapping(self, story):
|
||||||
|
"""
|
||||||
|
Tests a slug mapping with a custom template.
|
||||||
|
"""
|
||||||
|
mapper = StorySlugMapper('{story}.{extension}')
|
||||||
|
|
||||||
|
assert mapper(story) == 'title-1337.epub'
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('text,result', (
|
||||||
|
('Project Sunflower: Harmony', 'project_sunflower_harmony'),
|
||||||
|
('The Enchanted Library', 'the_enchanted_library'),
|
||||||
|
('Hurricane\'s Way', 'hurricanes_way'),
|
||||||
|
('Sharers\' Day', 'sharers_day'),
|
||||||
|
('Paca ' * 32, ('paca_' * 22)[:-1]),
|
||||||
|
('Paca' * 32, ''),
|
||||||
|
(None, 'none'),
|
||||||
|
(' ', ''),
|
||||||
|
(' ', ''),
|
||||||
|
('', ''),
|
||||||
|
))
|
||||||
|
def test_slugify(self, mapper, text, result):
|
||||||
|
"""
|
||||||
|
Tests creating a slug for a title.
|
||||||
|
"""
|
||||||
|
assert mapper.slugify(text) == result
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('key,slug,result', (
|
||||||
|
(16, 'slug', 'slug-16'),
|
||||||
|
(32, None, '32'),
|
||||||
|
(64, '', '64'),
|
||||||
|
))
|
||||||
|
def test_join(self, mapper, key, slug, result):
|
||||||
|
"""
|
||||||
|
Tests joining a slug with a key.
|
||||||
|
"""
|
||||||
|
assert mapper.join(key, slug) == result
|
||||||
|
|
||||||
|
def test_join_with_negative_key(self, mapper):
|
||||||
|
"""
|
||||||
|
Tests `ValueError` is raised when joining a negative key.
|
||||||
|
"""
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
mapper.join('-1', 'slug')
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('slug,result', (
|
||||||
|
('alpaca', 'a'),
|
||||||
|
('pony', 'p'),
|
||||||
|
('42', '_'),
|
||||||
|
(' ', '_'),
|
||||||
|
(' ', '_'),
|
||||||
|
('', '_'),
|
||||||
|
))
|
||||||
|
def test_group(self, mapper, slug, result):
|
||||||
|
"""
|
||||||
|
Tests grouping of a slug.
|
||||||
|
"""
|
||||||
|
assert mapper.group(slug) == result
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('flavors,result', (
|
||||||
|
({MetaFormat.BETA, DataFormat.EPUB}, 'epub'),
|
||||||
|
({DataFormat.EPUB}, 'epub'),
|
||||||
|
({DataFormat.HTML}, 'html'),
|
||||||
|
({MetaFormat.BETA}, 'data'),
|
||||||
|
({}, 'data'),
|
||||||
|
))
|
||||||
|
def test_classify(self, mapper, story, flavors, result):
|
||||||
|
"""
|
||||||
|
Tests classify with a story.
|
||||||
|
"""
|
||||||
|
story = story.merge(flavors=flavors)
|
||||||
|
|
||||||
|
assert mapper.classify(story) == result
|
||||||
|
|
||||||
|
def test_map_with_long_template(self, story):
|
||||||
|
"""
|
||||||
|
Tests `ValueError` is raised when result is too long.
|
||||||
|
"""
|
||||||
|
mapper = StorySlugMapper('paca' * 256)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
mapper(story)
|
||||||
|
|
||||||
|
|
||||||
class TestMetaFormatMapper:
|
class TestMetaFormatMapper:
|
||||||
"""
|
"""
|
||||||
MetaFormatMapper tests.
|
MetaFormatMapper tests.
|
||||||
|
|
Loading…
Reference in a new issue