diff --git a/songbook b/songbook index 73e96de8..09f14812 100755 --- a/songbook +++ b/songbook @@ -32,7 +32,7 @@ class ParseStepsAction(argparse.Action): ( getattr(namespace, self.dest) + [value.strip() for value in values[0].split(',')] - ), + ), ) class VerboseAction(argparse.Action): diff --git a/songbook_core/authors.py b/songbook_core/authors.py index cea20439..32afb09a 100644 --- a/songbook_core/authors.py +++ b/songbook_core/authors.py @@ -3,6 +3,37 @@ """Authors string management.""" +import re + +DEFAULT_AUTHWORDS = { + "after": ["by"], + "ignore": ["unknown"], + "sep": ["and"], + } + +def compile_authwords(authwords): + """Convert strings of authwords to compiled regular expressions. + + This regexp will later be used to match these words in authors strings. + """ + # Fill missing values + for (key, value) in DEFAULT_AUTHWORDS.items(): + if key not in authwords: + authwords[key] = value + + # Compilation + authwords['after'] = [ + re.compile(r"^.*%s\b(.*)" % word) + for word in authwords['after'] + ] + authwords['sep'] = [ + re.compile(r"^(.*)%s (.*)$" % word) + for word in ([" %s" % word for word in authwords['sep']] + [',']) + ] + + return authwords + + def split_author_names(string): r"""Split author between first and last name. diff --git a/songbook_core/build.py b/songbook_core/build.py index 4101341b..2c8e5fa3 100755 --- a/songbook_core/build.py +++ b/songbook_core/build.py @@ -7,23 +7,17 @@ import codecs import glob import logging import os.path -import re from subprocess import Popen, PIPE, call from songbook_core import __DATADIR__ +from songbook_core import authors +from songbook_core import content from songbook_core import errors -from songbook_core.files import recursive_find from songbook_core.index import process_sxd -from songbook_core.songs import Song, SongbookContent from songbook_core.templates import TexRenderer LOGGER = logging.getLogger(__name__) EOL = "\n" -DEFAULT_AUTHWORDS = { - "after": ["by"], - "ignore": ["unknown"], - "sep": ["and"], - } DEFAULT_STEPS = ['tex', 'pdf', 'sbx', 'pdf', 'clean'] GENERATED_EXTENSIONS = [ "_auth.sbx", @@ -36,6 +30,12 @@ GENERATED_EXTENSIONS = [ "_title.sbx", "_title.sxd", ] +DEFAULT_CONFIG = { + 'template': "default.tex", + 'lang': 'english', + 'content': [], + 'titleprefixwords': [], + } @@ -50,83 +50,12 @@ class Songbook(object): def __init__(self, raw_songbook, basename): super(Songbook, self).__init__() + self.config = raw_songbook self.basename = basename - # Default values: will be updated while parsing raw_songbook - self.config = { - 'template': "default.tex", - 'lang': 'english', - 'sort': [u"by", u"album", u"@title"], - 'content': None, - } - self._parse_raw(raw_songbook) - - @staticmethod - def _set_songs_default(config): - """Set the default values for the Song() class. - - Argument: - - config : a dictionary containing the configuration - """ - Song.sort = config['sort'] - if 'titleprefixwords' in config: - Song.prefixes = config['titleprefixwords'] - else: - Song.prefixes = [] - Song.authwords['after'] = [ - re.compile(r"^.*%s\b(.*)" % after) - for after - in config['authwords']["after"] - ] - Song.authwords['ignore'] = config['authwords']['ignore'] - Song.authwords['sep'] = [ - re.compile(r"^(.*)%s (.*)$" % sep) - for sep in ([ - " %s" % sep for sep in config['authwords']["sep"] - ] + [',']) - ] - - def _parse_raw(self, raw_songbook): - """Parse raw_songbook. - - The principle is: some special keys have their value processed; others - are stored verbatim in self.config. - """ - self.config.update(raw_songbook) + self.contentlist = [] + # Some special keys have their value processed. self._set_datadir() - # Compute song list - if self.config['content'] is None: - self.config['content'] = [( - "song", - os.path.relpath( - filename, - os.path.join(self.config['datadir'][0], 'songs'), - )) - for filename - in recursive_find( - os.path.join(self.config['datadir'][0], 'songs'), - '*.sg', - ) - ] - else: - content = self.config["content"] - self.config["content"] = [] - for elem in content: - if isinstance(elem, basestring): - self.config["content"].append(("song", elem)) - elif isinstance(elem, list): - self.config["content"].append((elem[0], elem[1])) - else: - raise errors.SBFileError( - "Syntax error: could not decode the content " - "of {0}".format(self.basename) - ) - - # Ensure self.config['authwords'] contains all entries - for (key, value) in DEFAULT_AUTHWORDS.items(): - if key not in self.config['authwords']: - self.config['authwords'][key] = value - def _set_datadir(self): """Set the default values for datadir""" try: @@ -140,16 +69,17 @@ class Songbook(object): if os.path.exists(path) and os.path.isdir(path): abs_datadir.append(os.path.abspath(path)) else: - LOGGER.warning("Ignoring non-existent datadir '{}'.".format(path)) + LOGGER.warning( + "Ignoring non-existent datadir '{}'.".format(path) + ) abs_datadir.append(__DATADIR__) self.config['datadir'] = abs_datadir - - def _parse_songs(self): - """Parse content included in songbook.""" - self.contentlist = SongbookContent(self.config['datadir']) - self.contentlist.append_list(self.config['content']) + self.config['_songdir'] = [ + os.path.join(path, 'songs') + for path in self.config['datadir'] + ] def write_tex(self, output): """Build the '.tex' file corresponding to self. @@ -157,21 +87,32 @@ class Songbook(object): Arguments: - output: a file object, in which the file will be written. """ - self._parse_songs() + # Updating configuration + config = DEFAULT_CONFIG + config.update(self.config) renderer = TexRenderer( - self.config['template'], - self.config['datadir'], - self.config['lang'], + config['template'], + config['datadir'], + config['lang'], ) + config.update(self.config) + config.update(renderer.get_variables()) + + config['authwords'] = authors.compile_authwords(config['authwords']) - context = renderer.get_variables() - context.update(self.config) - context['titleprefixkeys'] = ["after", "sep", "ignore"] - context['content'] = self.contentlist - context['filename'] = output.name[:-4] + self.config = config + # Configuration set + + self.contentlist = content.process_content( + self.config.get('content', []), + self.config, + ) + self.config['render_content'] = content.render_content + self.config['titleprefixkeys'] = ["after", "sep", "ignore"] + self.config['content'] = self.contentlist + self.config['filename'] = output.name[:-4] - self._set_songs_default(context) - renderer.render_tex(output, context) + renderer.render_tex(output, self.config) class SongbookBuilder(object): diff --git a/songbook_core/content/__init__.py b/songbook_core/content/__init__.py new file mode 100644 index 00000000..6096c6dc --- /dev/null +++ b/songbook_core/content/__init__.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +"""Content plugin management. + +Content that can be included in a songbook is controlled by plugins. From the +user (or .sb file) point of view, each piece of content is introduced by a +keyword. This keywold is associated with a plugin (a submodule of this very +module), which parses the content, and return a list of instances of the +Content class. + +# Plugin definition + +A plugin is a submodule of this module, which have a variable +CONTENT_PLUGINS, which is a dictionary where: + - keys are keywords, + - values are parsers (see below). + +When analysing the content field of the .sb file, when those keywords are +met, the corresponding parser is called. + +# Parsers + +A parser is a function which takes as arguments: + - keyword: the keyword triggering this function; + - argument: the argument of the keyword (see below); + - contentlist: the list of content, that is, the part of the list + following the keyword (see example below); + - config: the configuration object of the current songbook. Plugins can + change it. + +A parser returns a list of instances of the Content class, defined in +this module (or of subclasses of this class). + +Example: When the following piece of content is met + + ["sorted(author, @title)", "a_song.sg", "another_song.sg"] + +the parser associated to keyword 'sorted' get the arguments: + - keyword = "sorted" + - argument = "author, @title" + - contentlist = ["a_song.sg", "another_song.sg"] + - config = . + +# Keyword + +A keyword is either an identifier (alphanumeric characters, and underscore), +or such an identifier, with some text surrounded by parenthesis (like a +function definition); this text is called the argument to the keyword. +Examples: + - sorted + - sorted(author, @title) + - cwd(some/path) + +If the keyword has an argument, it can be anything, given that it is +surrounded by parenthesis. It is up to the plugin to parse this argument. For +intance, keyword "foo()(( bar()" is a perfectly valid keyword, and the parser +associated to "foo" will get as argument the string ")(( bar(". + +# Content class + +The content classes are subclasses of class Content defined in this module. +Content is a perfectly valid class, but instances of it will not generate +anything in the resulting .tex. + +More documentation in the docstring of Content. + +""" + +import glob +import importlib +import jinja2 +import logging +import os +import re + +from songbook_core import files +from songbook_core.errors import SongbookError + +LOGGER = logging.getLogger(__name__) +EOL = '\n' + +#pylint: disable=no-self-use +class Content(object): + """Content item. Will render to something in the .tex file. + + The current jinja2.runtime.Context is passed to all function defined + here. + """ + + def render(self, __context): + """Render this content item. + + Returns a string, to be placed verbatim in the generated .tex file. + """ + return "" + + # Block management + + def begin_new_block(self, __previous, __context): + """Return a boolean stating if a new block is to be created. + + # Arguments + + - __previous: the songbook.content.Content object of the previous item. + - __context: see Content() documentation. + + # Return + + - True if the renderer has to close previous block, and begin a new + one, + - False otherwise (the generated code for this item is part of the + current block). + """ + return True + + def begin_block(self, __context): + """Return the string to begin a block.""" + return "" + + def end_block(self, __context): + """Return the string to end a block.""" + return "" + +class ContentError(SongbookError): + """Error in a content plugin.""" + def __init__(self, keyword, message): + super(ContentError, self).__init__() + self.keyword = keyword + self.message = message + + def __str__(self): + return "Content: {}: {}".format(self.keyword, self.message) + +def load_plugins(): + """Load all content plugins, and return a dictionary of those plugins. + + Return value: a dictionary where: + - keys are the keywords ; + - values are functions triggered when this keyword is met. + """ + plugins = {} + for name in glob.glob(os.path.join(os.path.dirname(__file__), "*.py")): + if name.endswith(".py") and os.path.basename(name) != "__init__.py": + plugin = importlib.import_module( + 'songbook_core.content.{}'.format( + os.path.basename(name[:-len('.py')]) + ) + ) + for (key, value) in plugin.CONTENT_PLUGINS.items(): + if key in plugins: + LOGGER.warning( + "File %s: Keyword '%s' is already used. Ignored.", + files.relpath(name), + key, + ) + continue + plugins[key] = value + return plugins + +@jinja2.contextfunction +def render_content(context, content): + """Render the content of the songbook as a LaTeX code. + + Arguments: + - context: the jinja2.runtime.context of the current template + compilation. + - content: a list of Content() instances, as the one that was returned by + process_content(). + """ + rendered = "" + previous = None + last = None + for elem in content: + if not isinstance(elem, Content): + LOGGER.error("Ignoring bad content item '{}'.".format(elem)) + continue + + last = elem + if elem.begin_new_block(previous, context): + if previous: + rendered += previous.end_block(context) + EOL + rendered += elem.begin_block(context) + EOL + rendered += elem.render(context) + EOL + previous = elem + + if isinstance(last, Content): + rendered += last.end_block(context) + EOL + + return rendered + +def process_content(content, config=None): + """Process content, and return a list of Content() objects. + + Arguments are: + - content: the content field of the .sb file, which should be a list, and + describe what is to be included in the songbook; + - config: the configuration dictionary of the current songbook. + + Return: a list of Content objects, corresponding to the content to be + included in the .tex file. + """ + contentlist = [] + plugins = load_plugins() + keyword_re = re.compile(r'^ *(?P\w*) *(\((?P.*)\))? *$') + if not content: + content = [["song"]] + for elem in content: + if isinstance(elem, basestring): + elem = ["song", elem] + if len(content) == 0: + content = ["song"] + try: + match = keyword_re.match(elem[0]).groupdict() + except AttributeError: + raise ContentError(elem[0], "Cannot parse content type.") + (keyword, argument) = (match['keyword'], match['argument']) + if keyword not in plugins: + raise ContentError(keyword, "Unknown content type.") + contentlist.extend(plugins[keyword]( + keyword, + argument=argument, + contentlist=elem[1:], + config=config, + )) + return contentlist diff --git a/songbook_core/content/cwd.py b/songbook_core/content/cwd.py new file mode 100644 index 00000000..c1d3cc36 --- /dev/null +++ b/songbook_core/content/cwd.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +"""Change base directory before importing songs.""" + +import os + +from songbook_core.content import process_content + +#pylint: disable=unused-argument +def parse(keyword, config, argument, contentlist): + """Return a list songs included in contentlist, whith a different base path. + + Arguments: + - keyword: unused; + - config: the current songbook configuration dictionary; + - argument: a directory; + - contentlist: songbook content, that is parsed by + songbook_core.content.process_content(). + + This function adds 'argument' to the directories where songs are searched + for, and then processes the content. + + The 'argument' is added: + - first as a relative path to the current directory; + - then as a relative path to every path already present in + config['songdir']. + """ + old_songdir = config['_songdir'] + config['_songdir'] = ( + [argument] + + [os.path.join(path, argument) for path in config['_songdir']] + + config['_songdir'] + ) + processed_content = process_content(contentlist, config) + config['_songdir'] = old_songdir + return processed_content + +CONTENT_PLUGINS = {'cwd': parse} diff --git a/songbook_core/content/section.py b/songbook_core/content/section.py new file mode 100644 index 00000000..7ce247bf --- /dev/null +++ b/songbook_core/content/section.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +"""Allow LaTeX sections (starred or not) as content of a songbook.""" + +from songbook_core.content import Content, ContentError + +KEYWORDS = [ + "part", + "chapter", + "section", + "subsection", + "subsubsection", + "paragraph", + "subparagraph", + ] +FULL_KEYWORDS = KEYWORDS + ["{}*".format(word) for word in KEYWORDS] + +class Section(Content): + """A LaTeX section.""" + + def __init__(self, keyword, name, short=None): + self.keyword = keyword + self.name = name + self.short = short + + def render(self, __context): + if self.short is None: + return r'\{}{{{}}}'.format(self.keyword, self.name) + else: + return r'\{}[{}]{{{}}}'.format(self.keyword, self.short, self.name) + +#pylint: disable=unused-argument +def parse(keyword, argument, contentlist, config): + """Parse the contentlist. + + Arguments: + - keyword (one of "part", "chapter", "section", ... , "subparagraph", and + their starred versions "part*", "chapter*", ... , "subparagraph*"): the + section to use; + - argument: unused; + - contentlist: a list of one or two strings, which are the names (short + and long) of the section; + - config: configuration dictionary of the current songbook. + """ + if (keyword not in KEYWORDS) and (len(contentlist) != 1): + raise ContentError( + keyword, + "Starred section names must have exactly one argument." + ) + if (len(contentlist) not in [1, 2]): + raise ContentError(keyword, "Section can have one or two arguments.") + return [Section(keyword, *contentlist)] #pylint: disable=star-args + + +CONTENT_PLUGINS = dict([ + (word, parse) + for word + in FULL_KEYWORDS + ]) diff --git a/songbook_core/content/song.py b/songbook_core/content/song.py new file mode 100644 index 00000000..432efdad --- /dev/null +++ b/songbook_core/content/song.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +"""Plugin to include songs to the songbook.""" + +import glob +import jinja2 +import logging +import os + +from songbook_core.content import Content, process_content, ContentError +from songbook_core import files +from songbook_core.songs import Song + +LOGGER = logging.getLogger(__name__) + +class SongRenderer(Content, Song): + """Render a song in the .tex file.""" + + def begin_new_block(self, previous, __context): + """Return a boolean stating if a new block is to be created.""" + return not isinstance(previous, SongRenderer) + + def begin_block(self, context): + """Return the string to begin a block.""" + indexes = context.resolve("indexes") + if isinstance(indexes, jinja2.runtime.Undefined): + indexes = "" + return r'\begin{songs}{%s}' % indexes + + def end_block(self, __context): + """Return the string to end a block.""" + return r'\end{songs}' + + def render(self, context): + """Return the string that will render the song.""" + return r'\input{{{}}}'.format(files.relpath( + self.path, + os.path.dirname(context['filename']) + )) + +#pylint: disable=unused-argument +def parse(keyword, argument, contentlist, config): + """Parse data associated with keyword 'song'. + + Arguments: + - keyword: unused; + - argument: unused; + - contentlist: a list of strings, which are interpreted as regular + expressions (interpreted using the glob module), referring to songs. + - config: the current songbook configuration dictionary. + + Return a list of SongRenderer() instances. + """ + if 'languages' not in config: + config['_languages'] = set() + songlist = [] + for songdir in config['_songdir']: + if contentlist: + break + contentlist = [ + files.relpath(filename, songdir) + for filename + in files.recursive_find(songdir, "*.sg") + ] + for elem in contentlist: + before = len(songlist) + for songdir in config['_songdir']: + for filename in glob.iglob(os.path.join(songdir, elem)): + LOGGER.debug('Parsing file "{}"…'.format(filename)) + song = SongRenderer(filename, config) + songlist.append(song) + config["_languages"].update(song.languages) + if len(songlist) > before: + break + if len(songlist) == before: + # No songs were added + LOGGER.warning( + "Expression '{}' did not match any file".format(elem) + ) + return songlist + + +CONTENT_PLUGINS = {'song': parse} + + +class OnlySongsError(ContentError): + "A list that should contain only songs also contain other type of content." + def __init__(self, not_songs): + super(OnlySongsError, self).__init__() + self.not_songs = not_songs + + def __str__(self): + return ( + "Only songs are allowed, and the following items are not:" + + str(self.not_songs) + ) + +def process_songs(content, config=None): + """Process content that containt only songs. + + Call songbook_core.content.process_content(), checks if the returned list + contains only songs, and raise an exception if not. + """ + contentlist = process_content(content, config) + not_songs = [ + item + for item + in contentlist + if not isinstance(item, SongRenderer) + ] + if not_songs: + raise OnlySongsError(not_songs) + return contentlist diff --git a/songbook_core/content/songsection.py b/songbook_core/content/songsection.py new file mode 100644 index 00000000..32ff40af --- /dev/null +++ b/songbook_core/content/songsection.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +"""Allow 'songchapter' and 'songsection' as content of a songbook.""" + +from songbook_core.content import Content, ContentError + +KEYWORDS = [ + "songchapter", + "songsection", + ] + +class SongSection(Content): + """A songsection or songchapter.""" + + def __init__(self, keyword, name): + self.keyword = keyword + self.name = name + + def render(self, __context): + """Render this section or chapter.""" + return r'\{}{{{}}}'.format(self.keyword, self.name) + +#pylint: disable=unused-argument +def parse(keyword, argument, contentlist, config): + """Parse the contentlist. + + Arguments: + - keyword ("songsection" or "songchapter"): the section to use; + - argument: unused; + - contentlist: a list of one string, which is the name of the section; + - config: configuration dictionary of the current songbook. + """ + if (keyword not in KEYWORDS) and (len(contentlist) != 1): + raise ContentError( + keyword, + "Starred section names must have exactly one argument.", + ) + return [SongSection(keyword, contentlist[0])] + + +CONTENT_PLUGINS = dict([ + (word, parse) + for word + in KEYWORDS + ]) diff --git a/songbook_core/content/sorted.py b/songbook_core/content/sorted.py new file mode 100644 index 00000000..61a7499f --- /dev/null +++ b/songbook_core/content/sorted.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +"""Sorted list of songs. + +This plugin provides keyword 'sorted', used to include a sorted list of songs +to a songbook. +""" + +import locale +import logging + +from songbook_core import files +from songbook_core.content import ContentError +from songbook_core.content.song import OnlySongsError, process_songs + +LOGGER = logging.getLogger(__name__) + +DEFAULT_SORT = ['by', 'album', '@title'] + +def normalize_string(string): + """Return a normalized string. + + Normalized means: + - no surrounding spaces; + - lower case; + - passed through locale.strxfrm(). + """ + return locale.strxfrm(string.lower().strip()) + +def normalize_field(field): + """Return a normalized field, it being a string or a list of strings.""" + if isinstance(field, basestring): + return normalize_string(field) + elif isinstance(field, list): + return [normalize_string(string) for string in field] + +def key_generator(sort): + """Return a function that returns the list of values used to sort the song. + + Arguments: + - sort: the list of keys used to sort. + """ + + def ordered_song_keys(song): + """Return the list of values used to sort the song.""" + songkey = [] + for key in sort: + if key == "@title": + field = song.unprefixed_titles + elif key == "@path": + field = song.path + elif key == "by": + field = song.authors + else: + try: + field = song.args[key] + except KeyError: + LOGGER.debug( + "Ignoring unknown key '{}' for song {}.".format( + key, + files.relpath(song.path), + ) + ) + field = "" + songkey.append(normalize_field(field)) + return songkey + return ordered_song_keys + +#pylint: disable=unused-argument +def parse(keyword, config, argument, contentlist): + """Return a sorted list of songs contained in 'contentlist'. + + Arguments: + - keyword: the string 'sorted'; + - config: the current songbook configuration dictionary; + - argument: the list of the fields used to sort songs, as strings + separated by commas (e.g. "by, album, @title"); + - contentlist: the list of content to be sorted. If this content + contain something else than a song, an exception is raised. + """ + if argument: + sort = [key.strip() for key in argument.split(",")] + else: + sort = DEFAULT_SORT + try: + songlist = process_songs(contentlist, config) + except OnlySongsError as error: + raise ContentError(keyword, ( + "Content list of this keyword can be only songs (or content " + "that result into songs), and the following are not:" + + str(error.not_songs) + )) + return sorted(songlist, key=key_generator(sort)) + +CONTENT_PLUGINS = {'sorted': parse} diff --git a/songbook_core/content/tex.py b/songbook_core/content/tex.py new file mode 100644 index 00000000..a1c6ec41 --- /dev/null +++ b/songbook_core/content/tex.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +"""Include LaTeX raw code in the songbook.""" + +import logging +import os + +from songbook_core import files +from songbook_core.content import Content + +LOGGER = logging.getLogger(__name__) + +class LaTeX(Content): + """Inclusion of LaTeX code""" + + def __init__(self, filename): + self.filename = filename + + def render(self, context): + return r'\input{{{}}}'.format(files.relpath( + self.filename, + os.path.dirname(context['filename']), + )) + +#pylint: disable=unused-argument +def parse(keyword, argument, contentlist, config): + """Parse the contentlist. + + Arguments: + - keyword: unused; + - argument: unused; + - contentlist: a list of name of tex files; + - config: configuration dictionary of the current songbook. + """ + if not contentlist: + LOGGER.warning( + "Useless 'tex' content: list of files to include is empty." + ) + filelist = [] + for filename in contentlist: + checked_file = None + for path in config['_songdir']: + if os.path.exists(os.path.join(path, filename)): + checked_file = os.path.relpath(os.path.join(path, filename)) + break + if not checked_file: + LOGGER.warning( + ("Cannot find file '{}' in '{}'. Compilation may fail " + "later.").format(filename, str(config['_songdir'])) + ) + continue + filelist.append(LaTeX(checked_file)) + + return filelist + + +CONTENT_PLUGINS = {'tex': parse} diff --git a/songbook_core/data/templates/default.tex b/songbook_core/data/templates/default.tex index 802d8806..6c756fad 100644 --- a/songbook_core/data/templates/default.tex +++ b/songbook_core/data/templates/default.tex @@ -98,11 +98,11 @@ (* endfor*) (* for key in titleprefixkeys *) (* for word in authwords.key *) - (* if key=="after" *) - \authbyword{((word))} - (* else *) - \auth((key))word{((word))} - (* endif *) + (* if key=="after" *) + \authbyword{((word))} + (* else *) + \auth((key))word{((word))} + (* endif *) (* endfor *) (* endfor*) diff --git a/songbook_core/data/templates/songs.tex b/songbook_core/data/templates/songs.tex index 2b208f08..7e51e0bb 100644 --- a/songbook_core/data/templates/songs.tex +++ b/songbook_core/data/templates/songs.tex @@ -70,7 +70,7 @@ (* block songbookpreambule *) (( super() )) - (* for lang in content.languages() *) + (* for lang in _languages *) \PassOptionsToPackage{((lang))}{babel} (* endfor *) \usepackage[((lang))]{babel} @@ -93,15 +93,6 @@ \phantomsection \addcontentsline{toc}{section}{\songlistname} - \begin{songs}{((indexes|default('')))} - (* for type, elem in content.content *) - (* if type=="song" *) - \input{((elem.path))} - (* elif type=="section" *) - \end{songs} - \songsection{((elem))} - \begin{songs}{((indexes|default('')))} - (* endif *) - (* endfor *) - \end{songs} + ((render_content(content) )) + (* endblock *) diff --git a/songbook_core/files.py b/songbook_core/files.py index 65acc153..31cc4e2e 100644 --- a/songbook_core/files.py +++ b/songbook_core/files.py @@ -17,3 +17,12 @@ def recursive_find(root_directory, pattern): for filename in fnmatch.filter(filenames, pattern): matches.append(os.path.join(root, filename)) return matches + +def relpath(path, start=None): + """Return relative filepath to path if a subpath of start.""" + if start is None: + start = os.curdir + if os.path.abspath(path).startswith(os.path.abspath(start)): + return os.path.relpath(path, start) + else: + return os.path.abspath(path) diff --git a/songbook_core/index.py b/songbook_core/index.py index 78b64165..50c5f946 100755 --- a/songbook_core/index.py +++ b/songbook_core/index.py @@ -13,7 +13,7 @@ import locale import re import codecs -from songbook_core.authors import processauthors +from songbook_core import authors from songbook_core.plastex import simpleparse EOL = u"\n" @@ -30,7 +30,9 @@ def sortkey(value): don't forget to call locale.setlocale(locale.LC_ALL, '')). It also handles the sort with latex escape sequences. """ - return locale.strxfrm(unidecode(simpleparse(value).replace(' ', 'A'))) + return locale.strxfrm( + unidecode(simpleparse(value).replace(' ', 'A')).lower() + ) def process_sxd(filename): @@ -65,8 +67,8 @@ class Index(object): def __init__(self, indextype): self.data = dict() self.keywords = dict() + self.authwords = dict() self.prefix_patterns = [] - self.authwords = {"after": [], "ignore": [], "sep": []} if indextype == "TITLE INDEX DATA FILE": self.indextype = "TITLE" elif indextype == "SCRIPTURE INDEX DATA FILE": @@ -100,26 +102,7 @@ class Index(object): )) if self.indextype == "AUTHOR": - for key in self.keywords: - if key in self.authwords: - self.authwords[key] = self.keywords[key] - for word in self.authwords.keys(): - if word in self.keywords: - if word == "after": - self.authwords[word] = [ - re.compile(r"^.*{after}\b(.*)".format(after=after)) - for after in self.keywords[word] - ] - elif word == "sep": - self.authwords[word] = [" {sep}".format(sep=sep) - for sep in self.authwords[word] - ] + [","] - self.authwords[word] = [ - re.compile(r"^(.*){sep} (.*)$".format(sep=sep)) - for sep in self.authwords[word] - ] - else: - self.authwords[word] = self.keywords[word] + self.authwords = authors.compile_authwords(self.keywords) def _raw_add(self, key, number, link): """Add a song to the list. @@ -157,7 +140,7 @@ class Index(object): if self.indextype == "AUTHOR": # Processing authors - for author in processauthors( + for author in authors.processauthors( key, **self.authwords): self._raw_add(author, number, link) diff --git a/songbook_core/plastex.py b/songbook_core/plastex.py index b743bd00..6ffab971 100644 --- a/songbook_core/plastex.py +++ b/songbook_core/plastex.py @@ -13,6 +13,7 @@ import sys def process_unbr_spaces(node): + #pylint: disable=line-too-long r"""Replace '~' and '\ ' in node by nodes that will be rendered as unbreakable space. diff --git a/songbook_core/songs.py b/songbook_core/songs.py index 2bd1fc2e..51f56b52 100644 --- a/songbook_core/songs.py +++ b/songbook_core/songs.py @@ -4,79 +4,41 @@ """Song management.""" from unidecode import unidecode -import glob -import locale -import os.path import re -import logging from songbook_core.authors import processauthors from songbook_core.plastex import parsetex -LOGGER = logging.getLogger(__name__) - # pylint: disable=too-few-public-methods class Song(object): """Song management""" - #: Ordre de tri - sort = [] - #: Préfixes à ignorer pour le tri par titres - prefixes = [] - #: Dictionnaire des options pour le traitement des auteurs - authwords = {"after": [], "ignore": [], "sep": []} - - def __init__(self, path, languages, titles, args): - self.titles = titles - self.normalized_titles = [ - locale.strxfrm( - unprefixed_title( - unidecode(unicode(title, "utf-8")), - self.prefixes - ) + def __init__(self, filename, config): + # Data extraction from the song with plastex + data = parsetex(filename) + self.titles = data['titles'] + self.unprefixed_titles = [ + unprefixed_title( + unidecode(unicode(title, "utf-8")), + config['titleprefixwords'] ) for title - in titles + in self.titles ] - self.args = args - self.path = path - self.languages = languages + self.args = data['args'] + self.path = filename + self.languages = data['languages'] if "by" in self.args.keys(): - self.normalized_authors = [ - locale.strxfrm(author) - for author - in processauthors(self.args["by"], **self.authwords) - ] + self.authors = processauthors( + self.args["by"], + **config["authwords"] + ) else: - self.normalized_authors = [] + self.authors = [] def __repr__(self): return repr((self.titles, self.args, self.path)) - def __cmp__(self, other): - if not isinstance(other, Song): - return NotImplemented - for key in self.sort: - if key == "@title": - self_key = self.normalized_titles - other_key = other.normalized_titles - elif key == "@path": - self_key = locale.strxfrm(self.path) - other_key = locale.strxfrm(other.path) - elif key == "by": - self_key = self.normalized_authors - other_key = other.normalized_authors - else: - self_key = locale.strxfrm(self.args.get(key, "")) - other_key = locale.strxfrm(other.args.get(key, "")) - - if self_key < other_key: - return -1 - elif self_key > other_key: - return 1 - return 0 - - def unprefixed_title(title, prefixes): """Remove the first prefix of the list in the beginning of title (if any). """ @@ -87,55 +49,3 @@ def unprefixed_title(title, prefixes): return title -class SongbookContent(object): - """Manipulation et traitement de liste de chansons""" - - def __init__(self, datadirs): - self.songdirs = [os.path.join(d, 'songs') - for d in datadirs] - self.content = [] # Sorted list of the content - - def append_song(self, filename): - """Ajout d'une chanson à la liste - - Effets de bord : analyse syntaxique plus ou moins sommaire du fichier - pour en extraire et traiter certaines information (titre, langue, - album, etc.). - """ - LOGGER.debug('Parsing file "{}"…'.format(filename)) - # Data extraction from the song with plastex - data = parsetex(filename) - song = Song(filename, data['languages'], data['titles'], data['args']) - self.content.append(("song", song)) - - def append(self, type, value): - """ Append a generic element to the content list""" - self.content.append((type, value)) - - def append_list(self, contentlist): - """Ajoute une liste de chansons à la liste - - L'argument est une liste de chaînes, représentant des noms de fichiers - sous la forme d'expressions régulières destinées à être analysées avec - le module glob. - """ - for type, elem in contentlist: - if type == "song": - # Add all the songs matching the regex - before = len(self.content) - for songdir in self.songdirs: - for filename in glob.iglob(os.path.join(songdir, elem)): - self.append_song(filename) - if len(self.content) > before: - break - if len(self.content) == before: - # No songs were added - LOGGER.warning( - "Expression '{}' did not match any file".format(elem) - ) - else: - self.append(type, elem) - - def languages(self): - """Renvoie la liste des langues utilisées par les chansons""" - return set().union(*[set(song.languages) for type, song in self.content if type=="song"]) diff --git a/songbook_core/templates.py b/songbook_core/templates.py index 05f2ba3d..18acb64c 100644 --- a/songbook_core/templates.py +++ b/songbook_core/templates.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- """Template for .tex generation settings and utilities""" -from jinja2 import Environment, FileSystemLoader, ChoiceLoader, PackageLoader, \ +from jinja2 import Environment, FileSystemLoader, ChoiceLoader, \ TemplateNotFound, nodes from jinja2.ext import Extension from jinja2.meta import find_referenced_templates as find_templates @@ -54,7 +54,7 @@ class VariablesExtension(Extension): end_tokens=['name:endvariables'], drop_needle=True, ) - return nodes.Const("") + return nodes.Const("") # pylint: disable=no-value-for-parameter def _escape_tex(value):