veekun_pokedex/pokedex/db/markdown.py

286 lines
9.7 KiB
Python

# encoding: utf8
u"""Implements the markup used for description and effect text in the database.
The language used is a variation of Markdown and Markdown Extra. There are
docs for each at http://daringfireball.net/projects/markdown/ and
http://michelf.com/projects/php-markdown/extra/ respectively.
Pokédex links are represented with the syntax `[label]{category:identifier}`,
e.g., `[Eevee]{pokemon:eevee}`. The label can (and should) be left out, in
which case it is replaced by the name of the thing linked to.
"""
from __future__ import absolute_import
import sys
import re
import markdown
import six
from sqlalchemy.orm.session import object_session
from markdown.util import etree, AtomicString
@six.python_2_unicode_compatible
class MarkdownString(object):
"""Wraps a Markdown string.
Use unicode() and __html__ for text and HTML representations.
The as_text() and as_html() functions do the same, but accept optional
arguments that may affect the rendering.
The `source_text` property holds the original text.
init args:
`source_text`: the text in Markdown syntax
`session`: A DB session used for looking up linked objects
`language`: The language the string is in. If None, the session default
is used.
"""
default_link_extension = None
def __init__(self, source_text, session, language):
self.source_text = source_text
self.session = session
self.language = language
def __str__(self):
return self.as_text()
def __html__(self):
return self.as_html()
def as_html(self, extension=None):
"""Returns the string as HTML.
Pass a custom `extension` to use your own extension object to generate
links.
The default is the session's markdown_extension. Usually that's a
`PokedexLinkExtension` described below, which is also the recommended
superclass.
"""
if extension is None:
extension = self.session.markdown_extension
md = markdown.Markdown(
extensions=['markdown.extensions.extra', EscapeHtml(), extension],
output_format='xhtml1',
)
return md.convert(self.source_text)
def as_text(self):
"""Returns the string in a plaintext-friendly form.
Currently there are no tunable parameters
"""
# Since Markdown is pretty readable by itself, we just have to replace
# the links by their text.
# XXX: The tables get unaligned
link_maker = PokedexLinkExtension(self.session)
pattern = PokedexLinkPattern(link_maker, self.session, self.language)
regex = '()%s()' % pattern.regex
def handleMatch(m):
return pattern.handleMatch(m).text
return re.sub(regex, handleMatch, self.source_text)
def _markdownify_effect_text(move, effect_text, language=None):
session = object_session(move)
if effect_text is None:
return effect_text
effect_text = effect_text.replace(
u'$effect_chance',
str(move.effect_chance),
)
# "The target" vs "each target"; for Conquest, but hopefully main series
# moves too eventually
if hasattr(move, 'range'):
effect_text = effect_text.replace(
u'$target',
_target_labels[move.range.targets > 1]
).replace(
u'$Target',
_target_labels[move.range.targets > 1].capitalize()
)
return MarkdownString(effect_text, session, language)
_target_labels = {
False: 'the target',
True: 'each target'
}
class MoveEffectProperty(object):
"""Property that wraps move effects. Used like this:
MoveClass.effect = MoveEffectProperty('effect')
some_move.effect # returns a MarkdownString
some_move.effect.as_html # returns a chunk of HTML
This class also performs simple substitution on the effect, replacing
`$effect_chance` with the move's actual effect chance.
Use `MoveEffectPropertyMap` for dict-like association proxies.
"""
def __init__(self, effect_column, relationship='move_effect'):
self.effect_column = effect_column
self.relationship = relationship
def __get__(self, obj, cls):
if obj is None:
return self
if obj.move_effect is None:
return None
thing = getattr(obj, self.relationship)
prop = getattr(thing, self.effect_column)
return _markdownify_effect_text(obj, prop)
class MoveEffectPropertyMap(MoveEffectProperty):
"""Similar to `MoveEffectProperty`, but works on dict-like association
proxies.
"""
def __get__(self, obj, cls):
if obj is None:
return self
prop = getattr(obj.move_effect, self.effect_column)
newdict = dict(prop)
for key in newdict:
newdict[key] = _markdownify_effect_text(obj, newdict[key], key)
return newdict
class PokedexLinkPattern(markdown.inlinepatterns.Pattern):
"""Matches [label]{category:target}.
Handles matches using factory
"""
if sys.version_info >= (3, 6):
regex = u'(?x: \\[ ([^]]*) \\] \\{ ([-a-z0-9]+) : ([-a-z0-9 ]+) \\} )'
else:
regex = u'(?x) \\[ ([^]]*) \\] \\{ ([-a-z0-9]+) : ([-a-z0-9 ]+) \\}'
def __init__(self, factory, session, string_language=None, game_language=None):
markdown.inlinepatterns.Pattern.__init__(self, self.regex)
self.factory = factory
self.session = session
self.string_language = string_language
self.game_language = game_language
def handleMatch(self, m):
from pokedex.db import tables
start, label, category, target, end = m.groups()
try:
table = dict(
ability=tables.Ability,
item=tables.Item,
location=tables.Location,
move=tables.Move,
pokemon=tables.PokemonSpecies,
type=tables.Type,
form=tables.PokemonForm,
)[category]
except KeyError:
obj = name = target
url = self.factory.identifier_url(category, obj)
else:
session = self.session
if table is tables.PokemonForm:
form_ident, pokemon_ident = target.split()
query = session.query(table)
query = query.filter(
tables.PokemonForm.form_identifier == form_ident)
query = query.join(tables.PokemonForm.pokemon)
query = query.join(tables.Pokemon.species)
query = query.filter(
tables.PokemonSpecies.identifier == pokemon_ident)
else:
query = session.query(table)
query = query.filter(table.identifier == target)
try:
obj = query.one()
except Exception:
obj = name = target
url = self.factory.identifier_url(category, obj)
else:
url = self.factory.object_url(category, obj)
url = url or self.factory.identifier_url(category, target)
name = None
# Translations can be incomplete; in which case we want to use
# a fallback.
if table in [tables.Type] and self.string_language:
# Type wants to be localized to the text language
name = obj.name_map.get(self.string_language)
if not name and self.game_language:
name = obj.name_map.get(self.game_language)
if not name:
name = obj.name
if url:
el = self.factory.make_link(category, obj, url, label or name)
else:
el = etree.Element('span')
el.text = AtomicString(label or name)
return el
class EscapeHtml(markdown.Extension):
u"""Markdown extension which escapes raw html elements.
This is the recommended replacement for safe_mode='escape',
which was deprecated in Markdown 2.5.
See https://python-markdown.github.io/change_log/release-2.5/
"""
def extendMarkdown(self, md, md_globals):
del md.preprocessors['html_block']
del md.inlinePatterns['html']
class PokedexLinkExtension(markdown.Extension):
u"""Markdown extension that translates the syntax used in effect text:
`[label]{category:identifier}` is treated as a link to a Pokédex object,
where `category` is the table's singular name, and `label` is an optional
link title that defaults to the object's name in the current language.
"""
def __init__(self, session):
self.session = session
def extendMarkdown(self, md, md_globals):
pattern = PokedexLinkPattern(self, self.session)
md.inlinePatterns['pokedex-link'] = pattern
def make_link(self, category, obj, url, text):
"""Make an <a> element
Override this to set custom attributes, e.g. title.
"""
el = etree.Element('a')
el.set('href', url)
el.text = AtomicString(text)
return el
def identifier_url(self, category, identifier):
"""Return the URL for the given {category:identifier} link. For ORM
objects, object_url is tried first.
Returns None by default, which causes <span> to be used in place of
<a>.
This method is also called for non-existent objects, e.g.
[]{pokemon:bogus}.
"""
return None
def object_url(self, category, obj):
u"""Return the URL for the ORM object `obj`.
Returns None by default, which causes identifier_url to be tried.
Note that obj may be a Pokémon form. Unlike other returned objects,
these do not have identifiers. Be sure to test this case.
"""
return None