diff --git a/freetar/backend.py b/freetar/backend.py index d7a81f0..0b8819b 100644 --- a/freetar/backend.py +++ b/freetar/backend.py @@ -1,10 +1,11 @@ import waitress -from flask import Flask, render_template, request +import io +from flask import Flask, render_template, request, send_file from flask_minify import Minify -from freetar.ug import ug_search, ug_tab +from freetar.ug import ug_search, ug_tab, SongDetail from freetar.utils import get_version, FreetarError - +from freetar.chordpro import song_to_chordpro app = Flask(__name__) Minify(app=app, html=True, js=True, cssless=True) @@ -50,6 +51,17 @@ def show_tab2(tabid: int): tab=tab, title=f"{tab.artist_name} - {tab.song_name}") +@app.route("/download//") +def download_tab(artist: str, song: str): + tab = ug_tab(f"{artist}/{song}") + format = request.args.get('format') + return tab_to_dl_file(tab, format) + +@app.route("/download/") +def download_tab2(tabid: int): + tab = ug_tab(tabid) + format = request.args.get('format') + return tab_to_dl_file(tab, format) @app.route("/favs") def show_favs(): @@ -57,6 +69,23 @@ def show_favs(): title="Freetar - Favorites", favs=True) +def tab_to_dl_file(tab: SongDetail, format: str): + if format == 'ug_txt': + ext = 'ug.txt' + content = tab.raw_tab + elif format == 'txt': + ext = 'txt' + content = tab.plain_text() + elif format == 'chordpro': + ext = 'cho' + content = song_to_chordpro(tab) + else: + return f'no such format: {format}', 400 + + filename = f'{tab.artist_name} - {tab.song_name}.{ext}' + data = io.BytesIO(content.encode('utf-8')) + return send_file(data, as_attachment=True, download_name=filename) + @app.route("/about") def show_about(): diff --git a/freetar/chordpro.py b/freetar/chordpro.py new file mode 100644 index 0000000..4dd906e --- /dev/null +++ b/freetar/chordpro.py @@ -0,0 +1,170 @@ +import json +import re +from dataclasses import dataclass, field + +from freetar.ug import SongDetail + +def song_to_chordpro(song: SongDetail): + tab_lines = untokenise_tab(intersperse_chords(tokenise_tab(song.raw_tab))) + header_lines = [ + chordpro_directive('title', song.song_name), + chordpro_directive('artist', song.artist_name), + chordpro_meta('capo', song.capo), + chordpro_meta('key', song.key), + chordpro_meta('tuning', song.tuning), + chordpro_meta('version', song.version), + chordpro_meta('difficulty', song.difficulty), + ] + return ''.join((line + '\n' for line in (header_lines + tab_lines + ['']) if line is not None)) + +def chordpro_meta(key: str, value: str): + if not value: + return None + if type(value) is not str: + value = str(value) + return chordpro_directive('meta', key + ' ' + value) + +def chordpro_directive(name: str, argstr: str = None): + if argstr: + return '{' + name + ': ' + argstr + '}' + else: + return '{' + name + '}' + +@dataclass +class Chord(): + text: str + pos: int + + def __str__(self): + return '[' + self.text + ']' + +@dataclass +class Section(): + text: str + + def id(self): + text = re.sub(r'\s+$', '_', self.text.lower()) + text = re.sub(r'[^a-z_]*', '', text) + text = re.sub(r'^verse_[0-9]*$', 'verse', text) + text = re.sub(r'_*$', '', text) + return text + + def label(self): + return self.text + +@dataclass +class SectionStart(): + sec: Section + + def __str__(self): + return chordpro_directive('start_of_' + self.sec.id(), self.sec.label()) + +@dataclass +class SectionEnd(): + sec: Section + + def __str__(self): + return chordpro_directive('end_of_' + self.sec.id()) + +@dataclass +class Instrumental(): + line: list + + def __str__(self): + return chordpro_directive('c', untokenise_line(self.line)) + +def tokenise_line(line: str): + section_match = re.match(r'^\s*\[([^\[\]]*)\]\s*$', line) + if section_match: + return SectionStart(Section(section_match.group(1))) + return list(tokenise_symbols(line)) + +def tokenise_symbols(line: str): + pos = 0 + while len(line) > 0: + chord_match = re.match(r'^\[ch\]([^[]*)\[\/ch\](.*)$', line) + if chord_match: + line = chord_match.group(2) + yield Chord(text=chord_match.group(1), pos=pos) + pos += len(chord_match.group(1)) + else: + c, line = line[0], line[1:] + pos += 1 + yield c.replace('[', '(').replace(']', ')') + + +def insert_chords_between_tokens(chords: list, line: list): + for i, x in enumerate(line): + while chords and chords[0].pos <= i: + yield chords[0] + chords = chords[1:] + yield x + + yield from chords + +def only_whitespace(line): + return type(line) is list and all((type(x) is str and x.isspace() for x in line)) + +def only_chords(line): + return type(line) is list and all((type(x) is Chord or (type(x) is str and x.isspace()) for x in line)) + +def has_chords(line): + return type(line) is list and any((type(x) is Chord for x in line)) + +def has_lyrics_and_nothing_else(line): + return type(line) is list and (not has_chords(line)) and (not only_whitespace(line)) + +def intersperse_chords(tlines): + skip = True + for this, next in zip([None] + tlines, tlines + [None]): + if skip: + skip = False + continue + elif has_chords(this) and only_chords(this) and (has_lyrics_and_nothing_else(next)): + yield list(insert_chords_between_tokens([x for x in this if type(x) is Chord], next)) + skip = True + elif has_chords(this): + yield Instrumental(this) + else: + yield this + +def untokenise_line(line): + if type(line) is list: + return ''.join((str(x) for x in line)) + return str(line) + +def insert_section_ends(tlines): + cur_sec = None + for line in tlines: + if type(line) is SectionStart: + if cur_sec: + yield SectionEnd(cur_sec) + cur_sec = line.sec + yield line + + if cur_sec: + yield SectionEnd(cur_sec) + +def move_section_borders(tlines): + i = 0 + while i < len(tlines) - 1: + i = 0 + while i < len(tlines) - 1: + if only_whitespace(tlines[i]) and type(tlines[i + 1]) is SectionEnd: + tlines[i], tlines[i + 1] = tlines[i + 1], tlines[i] + break + if only_whitespace(tlines[i + 1]) and type(tlines[i]) is SectionStart: + tlines[i], tlines[i + 1] = tlines[i + 1], tlines[i] + break + i += 1 + return tlines + + +def untokenise_tab(tlines): + tlines = move_section_borders(list(insert_section_ends(tlines))) + return [untokenise_line(line) for line in tlines] + +def tokenise_tab(tab): + tab = tab.replace("[tab]", "") + tab = tab.replace("[/tab]", "") + return [tokenise_line(line) for line in tab.split('\n')] diff --git a/freetar/static/custom.js b/freetar/static/custom.js index cb7f25b..1d65e75 100644 --- a/freetar/static/custom.js +++ b/freetar/static/custom.js @@ -97,6 +97,14 @@ $('#checkbox_view_chords').click(function(){ } }); +$('#download').click(function(){ + $("#download-options").show(); +}); + +$('#download-options').click(function(){ + $("#download-options").hide(); +}); + $('#dark_mode').click(function(){ if (document.documentElement.getAttribute('data-bs-theme') == 'dark') { document.documentElement.setAttribute('data-bs-theme', 'light'); diff --git a/freetar/templates/tab.html b/freetar/templates/tab.html index 6a0b841..8e36217 100644 --- a/freetar/templates/tab.html +++ b/freetar/templates/tab.html @@ -7,6 +7,12 @@
{{ tab.artist_name }} - {{ tab.song_name }} (ver {{tab.version }}) + 📥
+
diff --git a/freetar/ug.py b/freetar/ug.py index f711bf2..0681ee2 100644 --- a/freetar/ug.py +++ b/freetar/ug.py @@ -44,13 +44,16 @@ class SongDetail(): version: int difficulty: str capo: str + key: str tuning: str tab_url: str + tab_url_path: str alternatives: list[SearchResult] = field(default_factory=list) def __init__(self, data: dict): - self.tab = data["store"]["page"]["data"]["tab_view"]["wiki_tab"]["content"] + self.raw_tab = data["store"]["page"]["data"]["tab_view"]["wiki_tab"]["content"].replace('\r\n', '\n') self.artist_name = data["store"]["page"]["data"]["tab"]['artist_name'] + self.key = data["store"]["page"]["data"]["tab"].get('tonality_name') self.song_name = data["store"]["page"]["data"]["tab"]["song_name"] self.version = int(data["store"]["page"]["data"]["tab"]["version"]) self._type = data["store"]["page"]["data"]["tab"]["type"] @@ -63,7 +66,11 @@ def __init__(self, data: dict): self.capo = data["store"]["page"]["data"]["tab_view"]["meta"].get("capo") _tuning = data["store"]["page"]["data"]["tab_view"]["meta"].get("tuning") self.tuning = f"{_tuning['value']} ({_tuning['name']})" if _tuning else None + else: + self.capo = None + self.tuning = None self.tab_url = data["store"]["page"]["data"]["tab"]["tab_url"] + self.tab_url_path = urlparse(self.tab_url).path self.alternatives = [] for alternative in data["store"]["page"]["data"]["tab_view"]["versions"]: if alternative.get("type", "") != "Official": @@ -74,8 +81,7 @@ def __repr__(self): return f"{self.artist_name} - {self.song_name}" def fix_tab(self): - tab = self.tab - tab = tab.replace("\r\n", "
") + tab = self.raw_tab tab = tab.replace("\n", "
") tab = tab.replace(" ", " ") tab = tab.replace("[tab]", "") @@ -88,6 +94,13 @@ def fix_tab(self): tab = re.sub(r'\[ch\](?P[A-Ga-g](#|b)?)(?P[^[/]+)?(?P/[A-Ga-g](#|b)?)?\[\/ch\]', self.parse_chord, tab) self.tab = tab + def plain_text(self): + tab = self.raw_tab + tab = tab.replace("[tab]", "") + tab = tab.replace("[/tab]", "") + tab = re.sub(r'\[ch\]([^\[]*)\[\/ch\]', lambda match: match.group(1), tab) + return tab + def parse_chord(self, chord): root = '%s' % chord.group('root') quality = '' @@ -98,6 +111,9 @@ def parse_chord(self, chord): bass = '/%s' % chord.group('bass')[1:] return '%s' % (root + quality + bass) + def download_url(self): + return '/download/' + self.tab_url_path.split('/', 2)[2] + def ug_search(value: str): try: