mirror of https://github.com/patacrep/patacrep.git
Louis
10 years ago
58 changed files with 853 additions and 119 deletions
@ -1,71 +1,264 @@ |
|||||
# -*- coding: utf-8 -*- |
# -*- coding: utf-8 -*- |
||||
"""Abstract Syntax Tree for ChordPro code.""" |
"""Abstract Syntax Tree for ChordPro code.""" |
||||
|
|
||||
|
import functools |
||||
|
|
||||
|
def _indent(string): |
||||
|
return "\n".join([" {}".format(line) for line in string.split('\n')]) |
||||
|
|
||||
|
INLINE_PROPERTIES = { |
||||
|
"lilypond", |
||||
|
"comment", |
||||
|
"guitar_comment", |
||||
|
"image", |
||||
|
} |
||||
|
|
||||
|
DIRECTIVE_SHORTCUTS = { |
||||
|
"t": "title", |
||||
|
"st": "subtitle", |
||||
|
"a": "album", |
||||
|
"by": "artist", |
||||
|
"c": "comment", |
||||
|
"gc": "guitar_comment", |
||||
|
} |
||||
|
|
||||
|
def directive_name(text): |
||||
|
if text in DIRECTIVE_SHORTCUTS: |
||||
|
return DIRECTIVE_SHORTCUTS[text] |
||||
|
return text |
||||
|
|
||||
|
|
||||
class AST: |
class AST: |
||||
"""Base class for the tree.""" |
inline = False |
||||
# pylint: disable=no-init |
|
||||
metadata = None |
class Line(AST): |
||||
|
"""A line is a sequence of (possibly truncated) words, spaces and chords.""" |
||||
|
|
||||
|
def __init__(self): |
||||
|
super().__init__() |
||||
|
self.line = [] |
||||
|
|
||||
|
def prepend(self, data): |
||||
|
self.line.insert(0, data) |
||||
|
return self |
||||
|
|
||||
|
def __str__(self): |
||||
|
return "".join([str(item) for item in self.line]) |
||||
|
|
||||
|
def strip(self): |
||||
|
while True: |
||||
|
if not self.line: |
||||
|
return self |
||||
|
if isinstance(self.line[0], Space): |
||||
|
del self.line[0] |
||||
|
continue |
||||
|
if isinstance(self.line[-1], Space): |
||||
|
del self.line[-1] |
||||
|
continue |
||||
|
return self |
||||
|
|
||||
|
class LineElement(AST): |
||||
|
"""Something present on a line.""" |
||||
|
pass |
||||
|
|
||||
|
class Word(LineElement): |
||||
|
"""A chunk of word.""" |
||||
|
|
||||
|
def __init__(self, value): |
||||
|
super().__init__() |
||||
|
self.value = value |
||||
|
|
||||
@classmethod |
def __str__(self): |
||||
def init_metadata(cls): |
return self.value |
||||
"""Clear metadata |
|
||||
|
|
||||
As this attribute is a class attribute, it as to be reset at each new |
class Space(LineElement): |
||||
parsing. |
"""A space between words""" |
||||
""" |
|
||||
cls.metadata = { |
|
||||
'@languages': set(), |
|
||||
} |
|
||||
|
|
||||
class Expression(AST): |
def __init__(self): |
||||
"""ChordPro expression""" |
super().__init__() |
||||
|
|
||||
|
def __str__(self): |
||||
|
return " " |
||||
|
|
||||
|
class Chord(LineElement): |
||||
|
"""A chord.""" |
||||
|
|
||||
def __init__(self, value): |
def __init__(self, value): |
||||
super().__init__() |
super().__init__() |
||||
self.content = [value] |
self.value = value |
||||
|
|
||||
|
def __str__(self): |
||||
|
return "[{}]".format(self.value) |
||||
|
|
||||
def prepend(self, value): |
class Verse(AST): |
||||
"""Add a value at the beginning of the content list.""" |
"""A verse (or bridge, or chorus)""" |
||||
if value is not None: |
type = "verse" |
||||
self.content.insert(0, value) |
inline = True |
||||
|
|
||||
|
def __init__(self, block=None): |
||||
|
super().__init__() |
||||
|
self.lines = [] # TODO check block |
||||
|
|
||||
|
def prepend(self, data): |
||||
|
self.lines.insert(0, data) |
||||
return self |
return self |
||||
|
|
||||
def __str__(self): |
def __str__(self): |
||||
return "".join([str(item) for item in self.content]) |
return '{{start_of_{type}}}\n{content}\n{{end_of_{type}}}'.format( |
||||
|
type = self.type, |
||||
class SongPart(AST): |
content = _indent("\n".join([str(line) for line in self.lines])), |
||||
"""ChordPro start_of/end_of command |
) |
||||
|
|
||||
{start_of_chorus}, {end_of_tab}, {eov} ... |
class Chorus(Verse): |
||||
""" |
type = 'chorus' |
||||
|
|
||||
class Type: |
class Bridge(Verse): |
||||
CHORUS = ("chorus", |
type = 'bridge' |
||||
"start_of_chorus", "end_of_chorus", |
|
||||
"soc", "eoc") |
class Song(AST): |
||||
VERSE = ("verse", |
"""A song""" |
||||
"start_of_verse", "end_of_verse", |
|
||||
"sov", "eov") |
METADATA_TYPE = { |
||||
BRIDGE = ("bridge", |
"title": "add_title", |
||||
"start_of_bridge", "end_of_bridge", |
"subtitle": "add_subtitle", |
||||
"sob", "eob") |
"language": "add_language", |
||||
TAB = ("tab", |
"artist": "add_author", |
||||
"start_of_tab", "end_of_tab", |
} |
||||
"sot", "eot") |
|
||||
|
def __init__(self): |
||||
def __init__(self, name): |
super().__init__() |
||||
if "_" in name: |
self.content = [] |
||||
self.init_long_form(name) |
self.meta = [] |
||||
|
self._authors = [] |
||||
|
self._titles = [] |
||||
|
self._subtitles = [] |
||||
|
self._languages = set() |
||||
|
|
||||
|
def add(self, data): |
||||
|
if data is None: |
||||
|
if not (self.content and isinstance(self.content[0], Newline)): |
||||
|
self.content.insert(0, Newline()) |
||||
|
elif isinstance(data, Line): |
||||
|
if not (self.content and isinstance(self.content[0], Verse)): |
||||
|
self.content.insert(0, Verse()) |
||||
|
self.content[0].prepend(data.strip()) |
||||
|
elif data.inline: |
||||
|
self.content.insert(0, data) |
||||
|
elif isinstance(data, Directive): |
||||
|
name = directive_name(data.keyword) |
||||
|
if name in self.METADATA_TYPE: |
||||
|
getattr(self, self.METADATA_TYPE[name])(*data.as_tuple) |
||||
|
else: |
||||
|
self.meta.append(data) |
||||
else: |
else: |
||||
self.init_short_form(name) |
raise Exception() |
||||
|
return self |
||||
|
|
||||
|
def str_meta(self): |
||||
|
for title in self.titles: |
||||
|
yield "{{title: {}}}".format(title) |
||||
|
for language in sorted(self.languages): |
||||
|
yield "{{language: {}}}".format(language) |
||||
|
for author in self.authors: |
||||
|
yield "{{by: {}}}".format(author) |
||||
|
for key in sorted(self.meta): |
||||
|
yield str(key) |
||||
|
|
||||
def __str__(self): |
def __str__(self): |
||||
return self.name |
return ( |
||||
|
"\n".join(self.str_meta()).strip() |
||||
|
+ |
||||
|
"\n========\n" |
||||
|
+ |
||||
|
"\n".join([str(item) for item in self.content]).strip() |
||||
|
) |
||||
|
|
||||
|
|
||||
|
def add_title(self, __ignored, title): |
||||
|
self._titles.insert(0, title) |
||||
|
|
||||
|
def add_subtitle(self, __ignored, title): |
||||
|
self._subtitles.insert(0, title) |
||||
|
|
||||
|
@property |
||||
|
def titles(self): |
||||
|
return self._titles + self._subtitles |
||||
|
|
||||
|
def add_author(self, __ignored, title): |
||||
|
self._authors.insert(0, title) |
||||
|
|
||||
def init_short_form(self, name): |
@property |
||||
self.type = "" |
def authors(self): |
||||
|
return self._authors |
||||
|
|
||||
def init_long_form(self, name): |
def add_language(self, __ignored, language): |
||||
self.type = "" |
self._languages.add(language) |
||||
|
|
||||
|
@property |
||||
|
def languages(self): |
||||
|
return self._languages |
||||
|
|
||||
|
|
||||
|
|
||||
|
class Newline(AST): |
||||
|
def __str__(self): |
||||
|
return "" |
||||
|
|
||||
|
@functools.total_ordering |
||||
|
class Directive(AST): |
||||
|
"""A directive""" |
||||
|
|
||||
|
def __init__(self): |
||||
|
super().__init__() |
||||
|
self.keyword = "" |
||||
|
self.argument = None |
||||
|
|
||||
|
@property |
||||
|
def keyword(self): |
||||
|
return self._keyword |
||||
|
|
||||
|
@property |
||||
|
def inline(self): |
||||
|
return self.keyword in INLINE_PROPERTIES |
||||
|
|
||||
|
@keyword.setter |
||||
|
def keyword(self, value): |
||||
|
self._keyword = value.strip() |
||||
|
|
||||
|
def __str__(self): |
||||
|
if self.argument is not None: |
||||
|
return "{{{}: {}}}".format( |
||||
|
self.keyword, |
||||
|
self.argument, |
||||
|
) |
||||
|
else: |
||||
|
return "{{{}}}".format(self.keyword) |
||||
|
|
||||
|
@property |
||||
|
def as_tuple(self): |
||||
|
return (self.keyword, self.argument) |
||||
|
|
||||
|
def __eq__(self, other): |
||||
|
return self.as_tuple == other.as_tuple |
||||
|
|
||||
|
def __lt__(self, other): |
||||
|
return self.as_tuple < other.as_tuple |
||||
|
|
||||
|
class Tab(AST): |
||||
|
"""Tablature""" |
||||
|
|
||||
|
inline = True |
||||
|
|
||||
|
def __init__(self): |
||||
|
super().__init__() |
||||
|
self.content = [] |
||||
|
|
||||
|
def prepend(self, data): |
||||
|
self.content.insert(0, data) |
||||
|
return self |
||||
|
|
||||
|
def __str__(self): |
||||
|
return '{{start_of_tab}}\n{}\n{{end_of_tab}}'.format( |
||||
|
_indent("\n".join(self.content)), |
||||
|
) |
||||
|
|
||||
|
@ -0,0 +1 @@ |
|||||
|
======== |
@ -0,0 +1 @@ |
|||||
|
A verse line |
@ -0,0 +1,4 @@ |
|||||
|
======== |
||||
|
{start_of_verse} |
||||
|
A verse line |
||||
|
{end_of_verse} |
@ -0,0 +1 @@ |
|||||
|
{title : A directive} |
@ -0,0 +1,2 @@ |
|||||
|
{title: A directive} |
||||
|
======== |
@ -0,0 +1,4 @@ |
|||||
|
|
||||
|
|
||||
|
|
||||
|
|
@ -0,0 +1 @@ |
|||||
|
======== |
@ -0,0 +1,3 @@ |
|||||
|
{soc} |
||||
|
A one line chorus |
||||
|
{eoc} |
@ -0,0 +1,4 @@ |
|||||
|
======== |
||||
|
{start_of_chorus} |
||||
|
A one line chorus |
||||
|
{end_of_chorus} |
@ -0,0 +1,3 @@ |
|||||
|
{sob} |
||||
|
A one line bridge |
||||
|
{eob} |
@ -0,0 +1,4 @@ |
|||||
|
======== |
||||
|
{start_of_bridge} |
||||
|
A one line bridge |
||||
|
{end_of_bridge} |
@ -0,0 +1 @@ |
|||||
|
# A comment |
@ -0,0 +1 @@ |
|||||
|
======== |
@ -0,0 +1,3 @@ |
|||||
|
{sot} |
||||
|
A tab |
||||
|
{eot} |
@ -0,0 +1,4 @@ |
|||||
|
======== |
||||
|
{start_of_tab} |
||||
|
A tab |
||||
|
{end_of_tab} |
@ -0,0 +1,10 @@ |
|||||
|
|
||||
|
|
||||
|
|
||||
|
# comment |
||||
|
# comment |
||||
|
|
||||
|
|
||||
|
A lot of new lines |
||||
|
|
||||
|
# comment |
@ -0,0 +1,4 @@ |
|||||
|
======== |
||||
|
{start_of_verse} |
||||
|
A lot of new lines |
||||
|
{end_of_verse} |
@ -0,0 +1,15 @@ |
|||||
|
|
||||
|
|
||||
|
|
||||
|
# comment |
||||
|
# comment |
||||
|
|
||||
|
|
||||
|
A lot of new lines |
||||
|
|
||||
|
# comment |
||||
|
|
||||
|
{title: and a directive} |
||||
|
|
||||
|
# comment |
||||
|
|
@ -0,0 +1,5 @@ |
|||||
|
{title: and a directive} |
||||
|
======== |
||||
|
{start_of_verse} |
||||
|
A lot of new lines |
||||
|
{end_of_verse} |
@ -0,0 +1 @@ |
|||||
|
A line[A] with a chord |
@ -0,0 +1,4 @@ |
|||||
|
======== |
||||
|
{start_of_verse} |
||||
|
A line[A] with a chord |
||||
|
{end_of_verse} |
@ -0,0 +1 @@ |
|||||
|
A line ending with a chord[A] |
@ -0,0 +1,4 @@ |
|||||
|
======== |
||||
|
{start_of_verse} |
||||
|
A line ending with a chord[A] |
||||
|
{end_of_verse} |
@ -0,0 +1 @@ |
|||||
|
[A]A line starting with a chord |
@ -0,0 +1,4 @@ |
|||||
|
======== |
||||
|
{start_of_verse} |
||||
|
[A]A line starting with a chord |
||||
|
{end_of_verse} |
@ -0,0 +1,5 @@ |
|||||
|
{sot} |
||||
|
A table |
||||
|
wit many # weir [ |
||||
|
[ symbols |
||||
|
{eot} |
@ -0,0 +1,6 @@ |
|||||
|
======== |
||||
|
{start_of_tab} |
||||
|
A table |
||||
|
wit many # weir [ |
||||
|
[ symbols |
||||
|
{end_of_tab} |
@ -0,0 +1 @@ |
|||||
|
A verse line |
@ -0,0 +1,4 @@ |
|||||
|
======== |
||||
|
{start_of_verse} |
||||
|
A verse line |
||||
|
{end_of_verse} |
@ -0,0 +1 @@ |
|||||
|
{title : A directive} |
@ -0,0 +1,2 @@ |
|||||
|
{title: A directive} |
||||
|
======== |
@ -0,0 +1,4 @@ |
|||||
|
|
||||
|
|
||||
|
|
||||
|
|
@ -0,0 +1 @@ |
|||||
|
======== |
@ -0,0 +1,3 @@ |
|||||
|
{soc} |
||||
|
A one line chorus |
||||
|
{eoc} |
@ -0,0 +1,4 @@ |
|||||
|
======== |
||||
|
{start_of_chorus} |
||||
|
A one line chorus |
||||
|
{end_of_chorus} |
@ -0,0 +1,3 @@ |
|||||
|
{sob} |
||||
|
A one line bridge |
||||
|
{eob} |
@ -0,0 +1,4 @@ |
|||||
|
======== |
||||
|
{start_of_bridge} |
||||
|
A one line bridge |
||||
|
{end_of_bridge} |
@ -0,0 +1 @@ |
|||||
|
# A comment |
@ -0,0 +1 @@ |
|||||
|
======== |
@ -0,0 +1,3 @@ |
|||||
|
{sot} |
||||
|
A tab |
||||
|
{eot} |
@ -0,0 +1,4 @@ |
|||||
|
======== |
||||
|
{start_of_tab} |
||||
|
A tab |
||||
|
{end_of_tab} |
@ -0,0 +1,10 @@ |
|||||
|
|
||||
|
|
||||
|
|
||||
|
# comment |
||||
|
# comment |
||||
|
|
||||
|
|
||||
|
A lot of new lines |
||||
|
|
||||
|
# comment |
@ -0,0 +1,4 @@ |
|||||
|
======== |
||||
|
{start_of_verse} |
||||
|
A lot of new lines |
||||
|
{end_of_verse} |
@ -0,0 +1,15 @@ |
|||||
|
|
||||
|
|
||||
|
|
||||
|
# comment |
||||
|
# comment |
||||
|
|
||||
|
|
||||
|
A lot of new lines |
||||
|
|
||||
|
# comment |
||||
|
|
||||
|
{title: and a directive} |
||||
|
|
||||
|
# comment |
||||
|
|
@ -0,0 +1,5 @@ |
|||||
|
{title: and a directive} |
||||
|
======== |
||||
|
{start_of_verse} |
||||
|
A lot of new lines |
||||
|
{end_of_verse} |
@ -0,0 +1 @@ |
|||||
|
[A]A line starting with a chord |
@ -0,0 +1,44 @@ |
|||||
|
{language : english} |
||||
|
{columns : 2} |
||||
|
{subtitle : Un sous titre} |
||||
|
{ title : Greensleeves} |
||||
|
{title : Un autre sous-titre} |
||||
|
{artist: Traditionnel} |
||||
|
{cover : traditionnel } |
||||
|
{album :Angleterre} |
||||
|
|
||||
|
{partition : greensleeves.ly} |
||||
|
|
||||
|
|
||||
|
A[Am]las, my love, ye [G]do me wrong |
||||
|
To [Am]cast me oft dis[E]curteously |
||||
|
And [Am]I have loved [G]you so long |
||||
|
De[Am]lighting [E]in your [Am]companie |
||||
|
|
||||
|
{start_of_chorus} |
||||
|
[C]Green[B]sleeves was [G]all my joy |
||||
|
[Am]Greensleeves was [E]my delight |
||||
|
[C]Greensleeves was my [G]heart of gold |
||||
|
And [Am]who but [E]Ladie [Am]Greensleeves |
||||
|
{end_of_chorus} |
||||
|
|
||||
|
I [Am]have been ready [G]at your hand |
||||
|
To [Am]grant what ever [E]you would crave |
||||
|
I [Am]have both waged [G]life and land |
||||
|
Your [Am]love and [E]good will [Am]for to have |
||||
|
|
||||
|
I [Am]bought thee kerchers [G]to thy head |
||||
|
That [Am]were wrought fine and [E]gallantly |
||||
|
I [Am]kept thee both at [G]boord and bed |
||||
|
Which [Am]cost my [E]purse well [Am]favouredly |
||||
|
|
||||
|
I [Am]bought thee peticotes [G]of the best |
||||
|
The [Am]cloth so fine as [E]fine might be |
||||
|
I [Am]gave thee jewels [G]for thy chest |
||||
|
And [Am]all this [E]cost I [Am]spent on thee |
||||
|
|
||||
|
|
||||
|
Thy [Am]smock of silke, both [G]faire and white |
||||
|
With [Am]gold embrodered [E]gorgeously |
||||
|
Thy [Am]peticote of [G]sendall right |
||||
|
And [Am]this I [E]bought thee [Am]gladly |
@ -0,0 +1,51 @@ |
|||||
|
{title: Greensleeves} |
||||
|
{title: Un autre sous-titre} |
||||
|
{title: Un sous titre} |
||||
|
{language: english} |
||||
|
{by: Traditionnel} |
||||
|
{album: Angleterre} |
||||
|
{columns: 2} |
||||
|
{cover: traditionnel} |
||||
|
{partition: greensleeves.ly} |
||||
|
======== |
||||
|
{start_of_verse} |
||||
|
A[Am]las, my love, ye [G]do me wrong |
||||
|
To [Am]cast me oft dis[E]curteously |
||||
|
And [Am]I have loved [G]you so long |
||||
|
De[Am]lighting [E]in your [Am]companie |
||||
|
{end_of_verse} |
||||
|
|
||||
|
{start_of_chorus} |
||||
|
[C]Green[B]sleeves was [G]all my joy |
||||
|
[Am]Greensleeves was [E]my delight |
||||
|
[C]Greensleeves was my [G]heart of gold |
||||
|
And [Am]who but [E]Ladie [Am]Greensleeves |
||||
|
{end_of_chorus} |
||||
|
|
||||
|
{start_of_verse} |
||||
|
I [Am]have been ready [G]at your hand |
||||
|
To [Am]grant what ever [E]you would crave |
||||
|
I [Am]have both waged [G]life and land |
||||
|
Your [Am]love and [E]good will [Am]for to have |
||||
|
{end_of_verse} |
||||
|
|
||||
|
{start_of_verse} |
||||
|
I [Am]bought thee kerchers [G]to thy head |
||||
|
That [Am]were wrought fine and [E]gallantly |
||||
|
I [Am]kept thee both at [G]boord and bed |
||||
|
Which [Am]cost my [E]purse well [Am]favouredly |
||||
|
{end_of_verse} |
||||
|
|
||||
|
{start_of_verse} |
||||
|
I [Am]bought thee peticotes [G]of the best |
||||
|
The [Am]cloth so fine as [E]fine might be |
||||
|
I [Am]gave thee jewels [G]for thy chest |
||||
|
And [Am]all this [E]cost I [Am]spent on thee |
||||
|
{end_of_verse} |
||||
|
|
||||
|
{start_of_verse} |
||||
|
Thy [Am]smock of silke, both [G]faire and white |
||||
|
With [Am]gold embrodered [E]gorgeously |
||||
|
Thy [Am]peticote of [G]sendall right |
||||
|
And [Am]this I [E]bought thee [Am]gladly |
||||
|
{end_of_verse} |
@ -0,0 +1,20 @@ |
|||||
|
{subtitle: Subtitle3} |
||||
|
{title: Title} |
||||
|
{title: Subtitle1} |
||||
|
{subtitle: Subtitle4} |
||||
|
{t: Subtitle2} |
||||
|
{st: Subtitle5} |
||||
|
{language: french} |
||||
|
{language: english} |
||||
|
{by: Author1} |
||||
|
{artist: Author2} |
||||
|
{album: Albom} |
||||
|
{copyright: Copyright} |
||||
|
{cover: Cover} |
||||
|
{vcover: VCover} |
||||
|
{capo: Capo} |
||||
|
{foo: Foo} |
||||
|
{comment: Comment} |
||||
|
{guitar_comment: GuitarComment} |
||||
|
{image: Image} |
||||
|
{lilypond: Lilypond} |
@ -0,0 +1,21 @@ |
|||||
|
{title: Title} |
||||
|
{title: Subtitle1} |
||||
|
{title: Subtitle2} |
||||
|
{title: Subtitle3} |
||||
|
{title: Subtitle4} |
||||
|
{title: Subtitle5} |
||||
|
{language: english} |
||||
|
{language: french} |
||||
|
{by: Author1} |
||||
|
{by: Author2} |
||||
|
{album: Albom} |
||||
|
{capo: Capo} |
||||
|
{copyright: Copyright} |
||||
|
{cover: Cover} |
||||
|
{foo: Foo} |
||||
|
{vcover: VCover} |
||||
|
======== |
||||
|
{comment: Comment} |
||||
|
{guitar_comment: GuitarComment} |
||||
|
{image: Image} |
||||
|
{lilypond: Lilypond} |
@ -0,0 +1,36 @@ |
|||||
|
import glob |
||||
|
import os |
||||
|
import unittest |
||||
|
|
||||
|
from patacrep.songs.chordpro import syntax as chordpro |
||||
|
|
||||
|
class ParserTestCase(unittest.TestCase): |
||||
|
|
||||
|
def test_txt(self): |
||||
|
for txt in sorted(glob.glob(os.path.join( |
||||
|
os.path.dirname(__file__), |
||||
|
'*.txt', |
||||
|
))): |
||||
|
basename = txt[:-len('.txt')] |
||||
|
with open("{}.sgc".format(basename), 'r', encoding='utf8') as sourcefile: |
||||
|
with open("{}.txt".format(basename), 'r', encoding='utf8') as expectfile: |
||||
|
#print(os.path.basename(sourcefile.name)) |
||||
|
#with open("{}.txt.diff".format(basename), 'w', encoding='utf8') as difffile: |
||||
|
# difffile.write( |
||||
|
# str(chordpro.parse_song( |
||||
|
# sourcefile.read(), |
||||
|
# os.path.basename(sourcefile.name), |
||||
|
# )).strip() |
||||
|
# ) |
||||
|
# sourcefile.seek(0) |
||||
|
self.assertMultiLineEqual( |
||||
|
str(chordpro.parse_song( |
||||
|
sourcefile.read(), |
||||
|
os.path.basename(sourcefile.name), |
||||
|
)).strip(), |
||||
|
expectfile.read().strip(), |
||||
|
) |
||||
|
|
||||
|
def test_tex(self): |
||||
|
# TODO |
||||
|
pass |
Loading…
Reference in new issue