Added lilypond edition

This commit is contained in:
marc
2025-04-04 15:27:23 +02:00
parent 6962d70468
commit 379a6653ce
30 changed files with 2078 additions and 42 deletions

View File

@@ -6,6 +6,8 @@
** TODO Usuaris i permisos granulars
** TODO Lilypond support (o similar)
*** TODO Fer cançoners "en directe"
** TODO Arreglar estadístiques de temes (dos temes tocats a la mateixa sessió compten un cop)
** TODO Arreglar visualitzador de pdf, suportar més d'un visualitzador per pàgina
* Idees
** Jams
*** Properes jams

View File

@@ -32,6 +32,8 @@
python-multipart
jinja2
uvicorn
# Async
aiofiles
# Files
magic
# Auth
@@ -55,6 +57,7 @@
black
# Project dependencies
nodePackages_latest.tailwindcss
lilypond-with-fonts
# Project tools
sqlite
];

View File

@@ -1 +1 @@
from . import editor, index, links, lyrics, properties
from . import editor, index, links, lyrics, properties, scores

View File

@@ -11,6 +11,7 @@ from folkugat_web.services.temes import links as links_service
from folkugat_web.services.temes import lyrics as lyrics_service
from folkugat_web.services.temes import properties as properties_service
from folkugat_web.services.temes import query as temes_q
from folkugat_web.services.temes import scores as scores_service
from folkugat_web.services.temes import write as temes_w
from folkugat_web.templates import templates
from folkugat_web.utils import FnChain
@@ -39,6 +40,7 @@ def contingut(request: Request, logged_in: auth.LoggedIn, tema_id: int):
temes_q.tema_compute_stats |
links_service.add_links_to_tema |
lyrics_service.add_lyrics_to_tema |
scores_service.add_scores_to_tema |
properties_service.add_properties_to_tema
).result()
return temes.tema(request, logged_in, tema)

View File

@@ -0,0 +1,147 @@
import dataclasses
from typing import Annotated
from fastapi import HTTPException, Request
from fastapi.params import Form
from fastapi.responses import HTMLResponse
from folkugat_web.api import router
from folkugat_web.fragments import temes
from folkugat_web.fragments.tema import scores as scores_fragments
from folkugat_web.services import auth, files, lilypond
from folkugat_web.services.temes import properties as properties_service
from folkugat_web.services.temes import query as temes_q
from folkugat_web.services.temes import scores as scores_service
from folkugat_web.utils import FnChain
@router.get("/api/tema/{tema_id}/score/{score_id}")
def score(request: Request, logged_in: auth.LoggedIn, tema_id: int, score_id: int):
score = scores_service.get_score_by_id(score_id=score_id, tema_id=tema_id)
if not score:
raise HTTPException(status_code=404, detail="Could not find lyric!")
return scores_fragments.score(request=request, logged_in=logged_in, score=score)
@router.put("/api/tema/{tema_id}/score/{score_id}")
async def set_score(
request: Request,
logged_in: auth.RequireLogin,
tema_id: int,
score_id: int,
title: Annotated[str, Form()],
source: Annotated[str, Form()],
):
source = source.strip()
score = scores_service.get_score_by_id(score_id=score_id, tema_id=tema_id)
if not score:
raise HTTPException(status_code=404, detail="Could not find lyric!")
full_source = scores_service.build_single_tune_full_source(tema_id=tema_id, source=source)
pdf_filename = files.create_tema_filename(tema_id=tema_id)
pdf_filename, errors = await lilypond.render(source=full_source, fmt="pdf", output_filename=pdf_filename)
if errors:
new_score = dataclasses.replace(
score,
source=source,
title=title,
errors=errors,
)
else:
png_filename = files.create_tema_filename(tema_id=tema_id)
png_filename, errors = await lilypond.render(source=full_source, fmt="png", output_filename=png_filename)
new_score = dataclasses.replace(
score,
source=source,
title=title,
pdf_url=files.get_db_file_path(pdf_filename) if pdf_filename else None,
img_url=files.get_db_file_path(png_filename) if png_filename else None,
errors=errors,
)
scores_service.update_score(score=new_score)
files.clean_orphan_files()
return scores_fragments.score(request=request, logged_in=logged_in, score=new_score)
@router.post("/api/tema/{tema_id}/score")
def add_score(
request: Request,
logged_in: auth.RequireLogin,
tema_id: int,
):
score = scores_service.create_score(tema_id=tema_id)
return scores_fragments.score_editor(
request=request,
logged_in=logged_in,
score=score,
)
@router.delete("/api/tema/{tema_id}/score/{score_id}")
def delete_score(request: Request, logged_in: auth.RequireLogin, tema_id: int, score_id: int):
scores_service.delete_score(score_id=score_id, tema_id=tema_id)
files.clean_orphan_files()
return HTMLResponse()
@router.get("/api/tema/{tema_id}/editor/score/{score_id}")
def score_editor(
request: Request,
logged_in: auth.RequireLogin,
tema_id: int,
score_id: int,
):
score = scores_service.get_score_by_id(score_id=score_id, tema_id=tema_id)
if not score:
raise HTTPException(status_code=404, detail="Could not find lyric!")
return scores_fragments.score_editor(
request=request,
logged_in=logged_in,
score=score,
)
@router.post("/api/tema/{tema_id}/editor/score/{score_id}/render")
async def render(
request: Request,
_: auth.RequireLogin,
tema_id: int,
score_id: int,
source: Annotated[str, Form()],
):
full_source = scores_service.build_single_tune_full_source(tema_id=tema_id, source=source)
output_filename, errors = await lilypond.render(source=full_source, fmt="png")
if output_filename:
score_render_url = files.get_db_file_path(output_filename)
return temes.score_render(request=request, score_id=score_id, score_render_url=score_render_url)
else:
return temes.score_render(request=request, score_id=score_id, errors=errors)
@router.put("/api/tema/{tema_id}/score/{score_id}/show")
async def show_score(
request: Request,
logged_in: auth.RequireLogin,
tema_id: int,
score_id: int,
):
score = scores_service.get_score_by_id(score_id=score_id, tema_id=tema_id)
if not score:
raise HTTPException(status_code=404, detail="Could not find lyric!")
new_score = dataclasses.replace(score, hidden=False)
scores_service.update_score(score=new_score)
return scores_fragments.score(request=request, logged_in=logged_in, score=new_score)
@router.put("/api/tema/{tema_id}/score/{score_id}/hide")
async def hide_score(
request: Request,
logged_in: auth.RequireLogin,
tema_id: int,
score_id: int,
):
score = scores_service.get_score_by_id(score_id=score_id, tema_id=tema_id)
if not score:
raise HTTPException(status_code=404, detail="Could not find lyric!")
new_score = dataclasses.replace(score, hidden=True)
scores_service.update_score(score=new_score)
return scores_fragments.score(request=request, logged_in=logged_in, score=new_score)

View File

@@ -0,0 +1,35 @@
/* Override background and text color for the entire editor */
.CodeMirror {
background: #1e1e1e; /* Dark background */
color: #f8f8f2; /* Light text */
}
/* Optional: Style the gutter (line numbers) */
.CodeMirror-gutters {
background: #1e1e1e;
border-right: 1px solid #444;
}
/* Optional: Style active line */
.CodeMirror-activeline-background {
background: #2a2a2a !important;
}
/* Make the cursor white */
.CodeMirror-cursor {
border-left: 1px solid white !important;
}
/* Optional: Change the cursor color when focused/unfocused */
.CodeMirror-focused .CodeMirror-cursor {
border-left: 1px solid white !important;
}
.cm-keyword {
/* Commands like \relative */
color: #569cd6;
}
.cm-comment {
/* Notes like c4, d'8 */
color: #5c5c5c;
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,17 @@
CodeMirror.defineMode("lilypond", function() {
return {
token: function(stream) {
// Highlight Comments
if (stream.match(/%.*$/)) {
return "comment";
}
// Highlight LilyPond commands (e.g., \relative, \time, \key)
if (stream.match(/\\[a-zA-Z]+/)) {
return "keyword";
}
// Move to the next character
stream.next();
return null;
}
};
});

View File

@@ -7,7 +7,8 @@
</h3>
<div class="mx-12 text-left">
{% if tema.score() is not none %}
{% if tema.main_score() is not none %}
{% set score = tema.main_score() %}
{% include "fragments/tema/score.html" %}
{% endif %}

View File

@@ -0,0 +1,73 @@
<div id="tema-score-{{ score.id }}">
<h4 class="mp-4 text-xl text-beige">
<i class="fa fa-code mr-2" aria-hidden="true"></i> Editor de partitura
</h4>
<h5 class="text-sm text-beige text-right">
<input name="title"
id="score-{{ score.id }}-editor-title"
placeholder="Nom de la partitura"
value="{{ score.title }}"
class="border border-beige focus:outline-none
rounded
bg-brown px-2"
/>
<button title="Desa els canvis"
class="mx-1"
hx-put="/api/tema/{{ score.tema_id }}/score/{{ score.id }}"
hx-vals="js:{title: document.getElementById('score-{{ score.id }}-editor-title').value, source: editor_{{ score.id }}.getValue()}"
hx-target="#tema-score-{{ score.id }}"
hx-swap="outerHTML">
<i class="fa fa-check" aria-hidden="true"></i>
</button>
<button title="Descarta els canvis"
class="mx-1"
hx-get="/api/tema/{{ score.tema_id }}/score/{{ score.id }}"
hx-target="#tema-score-{{ score.id }}"
hx-swap="outerHTML">
<i class="fa fa-times" aria-hidden="true"></i>
</button>
</h5>
<h5 class="mp-4 text text-beige">
Codi Lilypond
<a href="https://lilypond.org/doc/v2.24/Documentation/learning/simple-notation"
target="_blank">
<i class="fa fa-info-circle" aria-hidden="true"></i>
</a>
</h5>
<hr class="h-px mt-1 mb-3 bg-beige border-0">
<div class="flex flex-col">
<form class="flex flex-col"
hx-post="/api/tema/{{ score.tema_id }}/editor/score/{{ score.id }}/render"
hx-target="#score-{{ score.id }}-render-target"
hx-swap="outerHTML"
onsubmit="document.getElementById('lilypond-editor-{{ score.id }}').value = editor_{{ score.id }}.getValue()"
>
{% if score.source %}
<textarea id="lilypond-editor-{{ score.id }}"
name="source">{{ score.source | safe }}</textarea>
{% else %}
<textarea id="lilypond-editor-{{ score.id }}"
name="source">{% include "lilypond/score_template.ly" %}</textarea>
{% endif %}
<script>
var editor_{{ score.id }} = CodeMirror.fromTextArea(
document.getElementById("lilypond-editor-{{ score.id }}"), {
mode: "lilypond",
lineNumbers: true,
}
);
</script>
<button title="Previsualitza els canvis"
type="submit"
class="flex flex-col items-center">
<div class="m-2 mt-3 p-1 text-beige
border border-beige rounded">
Previsualitza els canvis
</div>
</button>
</form>
<div id="score-{{ score.id }}-render-target">
</div>
</div>
</div>

View File

@@ -0,0 +1,13 @@
<div id="score-{{ score_id }}-render-target">
<h5 class="text text-beige"> Previsualització </h5>
<hr class="h-px mt-1 mb-3 bg-beige border-0">
{% if score_render_url %}
<img class="my-2" src="{{ score_render_url }}" />
{% else %}
<ol class="text-red-400">
{% for error in errors %}
<li class="text-red-500">Línia {{ error.line }} posició {{ error.pos }}: {{ error.error }}</p>
{% endfor %}
</ol>
{% endif %}
</div>

View File

@@ -4,12 +4,13 @@
{% include "fragments/tema/title.html" %}
</div>
<div class="p-12 text-left">
<div id="tema-{{ tema.id }}-score"
hx-get="/api/tema/{{ tema.id }}/score"
hx-trigger="load, reload-tema-{{ tema.id }}-score from:body"
hx-swap="innerHTML"
>
</div>
<!-- <div id="tema-{{ tema.id }}-score" -->
<!-- hx-get="/api/tema/{{ tema.id }}/score" -->
<!-- hx-trigger="load, reload-tema-{{ tema.id }}-score from:body" -->
<!-- hx-swap="innerHTML" -->
<!-- > -->
<!-- </div> -->
{% include "fragments/tema/scores.html" %}
{% include "fragments/tema/lyrics.html" %}
{% include "fragments/tema/links.html" %}
{% include "fragments/tema/properties.html" %}

View File

@@ -1,15 +1,63 @@
{% set score = tema.score() %}
{% if score %}
<h4 class="mp-4 text-xl text-beige">Partitura</h4>
<hr class="h-px mt-1 mb-3 bg-beige border-0">
{% if score.link_type == LinkType.PDF %}
{% set pdf_url = score.url %}
{% include "fragments/pdf_viewer.html" %}
{% elif score.link_type == LinkType.IMAGE %}
<div class="flex justify-center">
<a href="{{ score.url }}" target="_blank">
<img class="m-2" src="{{ score.url }}" />
</a>
{% if logged_in or not score.hidden %}
<div id="tema-score-{{ score.id }}">
<h5 class="text-sm text-beige text-right">
{{ score.title }}
{% if logged_in and score.id is not none %}
{% if score.source == "" %}
<i>(Partitura buida)</i>
{% elif score.errors %}
<i>(Partitura amb errors)</i>
{% endif %}
{% if score.hidden %}
<i>(Privada)</i>
{% endif %}
{% endif %}
{% if logged_in and score.id is not none %}
{% if score.hidden %}
<button title="Mostra la partitura"
class="mx-1"
hx-put="/api/tema/{{ score.tema_id }}/score/{{ score.id }}/show"
hx-target="#tema-score-{{ score.id }}"
hx-swap="outerHTML">
<i class="fa fa-eye-slash" aria-hidden="true"></i>
</button>
{% else %}
<button title="Amaga la partitura"
class="mx-1"
hx-put="/api/tema/{{ score.tema_id }}/score/{{ score.id }}/hide"
hx-target="#tema-score-{{ score.id }}"
hx-swap="outerHTML">
<i class="fa fa-eye" aria-hidden="true"></i>
</button>
{% endif %}
<button title="Modifica la partitura"
class="mx-1"
hx-get="/api/tema/{{ score.tema_id }}/editor/score/{{ score.id }}"
hx-target="#tema-score-{{ score.id }}"
hx-swap="outerHTML">
<i class="fa fa-pencil" aria-hidden="true"></i>
</button>
<button title="Esborra la partitura"
class="mx-1"
hx-delete="/api/tema/{{ score.tema_id }}/score/{{ score.id }}"
hx-target="#tema-score-{{ score.id }}"
hx-swap="outerHTML">
<i class="fa fa-times" aria-hidden="true"></i>
</button>
{% endif %}
</h5>
<hr class="h-px mt-1 mb-3 bg-beige border-0">
<div>
{% if score.img_url is not none %}
<div class="flex justify-center">
<a href="{{ score.pdf_url or score.img_url }}" target="_blank">
<img class="m-2" src="{{ score.img_url }}" />
</a>
</div>
{% elif score.pdf_url is not none %}
{% set pdf_url = score.pdf_url %}
{% include "fragments/pdf_viewer.html" %}
{% endif %}
</div>
{% endif %}
</div>
{% endif %}

View File

@@ -0,0 +1,31 @@
{% set main_score = tema.main_score() %}
{% if logged_in or tema.scores or main_score %}
<h4 class="pt-4 text-xl text-beige">Partitures</h4>
{% endif %}
{% if main_score is not none %}
{% set score = main_score %}
{% include "fragments/tema/score.html" %}
{% endif %}
{% if tema.scores %}
{% for score in tema.scores %}
{% if score.id != main_score.id %}
{% include "fragments/tema/score.html" %}
{% endif %}
{% endfor %}
{% endif %}
{% if logged_in %}
<div id="new-score-target"></div>
<div class="flex flex-row my-2 justify-end">
<button title="Afegeix una partitura"
class="text-sm text-beige text-right"
hx-post="/api/tema/{{ tema.id }}/score"
hx-target="#new-score-target"
hx-swap="beforebegin">
<i class="fa fa-plus" aria-hidden="true"></i>
Afegeix una partitura
</button>
</div>
{% endif %}

View File

@@ -12,16 +12,5 @@
hx-trigger="revealed, keyup delay:500ms changed"
hx-target="#search-results"
hx-swap="outerHTML">
{% if logged_in %}
<button title="Afegeix un tema"
class="text-beige m-2"
hx-post="/api/tema"
hx-include="[name=query]"
hx-vals="js:{title: document.getElementById('query-input').value}"
>
<i class="fa fa-plus" aria-hidden="true"></i>
Afegeix un tema
</button>
{% endif %}
<div id="search-results" class="flex-col"></div>
</div>

View File

@@ -1,5 +1,17 @@
<div id="search-results"
class="min-w-full w-full">
{% if logged_in and query %}
<button title="Afegeix un tema"
class="border border-beige text-beige rounded
m-1 px-2"
hx-post="/api/tema"
hx-include="[name=query]"
hx-vals="js:{title: document.getElementById('query-input').value}"
>
<i class="fa fa-plus" aria-hidden="true"></i>
<i>{{ query }}</i>
</button>
{% endif %}
<table class="text-left min-w-full w-full">
<tr class="border-b border-beige">
<td class="font-bold py-2 px-4">Nom</td>

View File

@@ -19,6 +19,14 @@
<!-- HTMX -->
<script src="{{ url_for(request, 'static', path='js/htmx.min.js') }}"></script>
<!-- CodeMirror -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/codemirror.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/codemirror.min.js"></script>
<script src="{{ url_for(request, 'static', path='js/lilypond.js') }}"></script>
<link rel="stylesheet" href="{{ url_for(request, 'static', path='css/lilypond.css') }}" type="text/css" />
<!-- Favicon! -->
<link rel="apple-touch-icon" sizes="180x180" href="{{ url_for(request, 'static', path='favicon/apple-touch-icon.png') }}">
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for(request, 'static', path='favicon/favicon-32x32.png') }}">

View File

@@ -0,0 +1,75 @@
\new ChordNames {
\chords {
\set chordChanges = ##t
\set noChordSymbol = ""
\partial 2
r2
% A
\repeat volta 2 {
d1 g a:5 r4 a2.:aug | d1 g a
\alternative {
\volta 1 { a4 a2.:aug }
\volta 2 { d4 d2.:7 }
}
}
% B
g1 d/fs g2 a b:m c/a
g1 d/fs g2 a d a/cs
% C
b1:m d/fs a g2 a
b1:m d/fs a2 g d a/cs
b1:m d/fs a g2 a
b1:m g a2 a:aug a1:aug
}
}
\new Staff {
\relative {
\numericTimeSignature
\time 4/4
\set Timing.beamExceptions = #'()
\set Timing.baseMoment = #(ly:make-moment 1/4)
\set Timing.beatStructure = 2,2
\key d \major
\partial 2
a8 b d e
% A
\mark \default
\repeat volta 2 {
fs4. fs8 e fs e d | g, r r4 e'8 fs e d |
a16 b8( b8.) c16 b a8 b d e | e d( d) a( a) b d e |
fs4. fs8 e fs e d | g, r r4 e'8 fs e d |
a b d e e d( d) e |
\alternative {
\volta 1 { d4 a( a8) b d e }
\volta 2 { d4 fs( fs) e8 d }
}
}
% B
\mark \default
b2 e8 fs e d | a2 e'8 fs e d | b4. b8 a b d e | e d( d) fs( fs) a e d |
b2 e8 fs e d | a2 e'8 fs e d | a b d e e d( d) e | d4 d16 d d8 d fs a cs |
\bar "||" \break
% C
\mark \default
d4. d8 cs d cs a | fs4 e8 d a' b a fs | e2 e8 fs e d | a b d e d fs a cs |
d4. d8 cs d cs b | fs4 e8 d a' b a fs | e2 d4. e8 | d4 d16 d d8 d fs a cs |
d2 cs4 b8 a | fs4 e8 d a b a fs | e'2 e8 fs e d | a b d e d fs a cs |
d2 d8 cs a fs | b2 b8 a fs d | e-> r r fs-> d-> r r4 | r8 c' c c c b a g |
\bar "|."
}
}

View File

@@ -0,0 +1,61 @@
% -----------------------------------------------------
% Comandes últils
% -----------------------------------------------------
% Anacrusa: \partial 2 (2 = blanca)
%
% Repetició amb caselles:
% \repeat volta 2 {
% %%%
% \alternative {
% \volta 1 { %%% }
% \volta 2 { %%% }
% }
% }
%
% -----------------------------------------------------
\new ChordNames {
\chords {
\set chordChanges = ##t
\set noChordSymbol = ""
% -----------------------------------------------------
% Acords
% -----------------------------------------------------
% A
% B
}
}
\new Staff {
\relative {
% -----------------------------------------------------
% Compàs
% -----------------------------------------------------
\numericTimeSignature
\time 4/4
\set Timing.beamExceptions = #'()
\set Timing.baseMoment = #(ly:make-moment 1/4)
\set Timing.beatStructure = 2,2
% -----------------------------------------------------
% Armadura
% -----------------------------------------------------
\key c \major
% -----------------------------------------------------
% Melodia
% -----------------------------------------------------
% A
\mark \default
\bar "||" \break
% B
\mark \default
\bar "|."
}
}

View File

@@ -0,0 +1,27 @@
\paper {
top-margin = 10
left-margin = 15
right-margin = 15
}
\header {
title = \markup { "{{ tema.title }}" }
{% if tema.composer() %}
composer = "{{ tema.composer() }}"
{% elif tema.origin() %}
composer = "{{ tema.origin() }}"
{% endif %}
tagline = "Partitura generada amb LilyPond"
copyright = "Folkugat"
}
\markup \vspace #2
\score {
\language "english"
<<
{{ score_beginning }}
{{ score_source | safe }}
>>
}

View File

@@ -7,6 +7,7 @@ def create_db(con: Connection | None = None):
create_links_table(con)
create_lyrics_table(con)
create_properties_table(con)
create_scores_table(con)
def create_temes_table(con: Connection):
@@ -66,3 +67,21 @@ def create_properties_table(con: Connection):
"""
cur = con.cursor()
_ = cur.execute(query)
def create_scores_table(con: Connection):
query = """
CREATE TABLE IF NOT EXISTS tema_scores (
id INTEGER PRIMARY KEY,
tema_id INTEGER,
title TEXT,
source TEXT NOT NULL,
errors TEXT NOT NULL,
img_url TEXT,
pdf_url TEXT,
hidden BOOLEAN,
FOREIGN KEY(tema_id) REFERENCES temes(id) ON DELETE CASCADE
)
"""
cur = con.cursor()
_ = cur.execute(query)

View File

@@ -0,0 +1,133 @@
import json
from collections.abc import Iterable
from typing import TypedDict
from folkugat_web.dal.sql import Connection, get_connection
from folkugat_web.model import lilypond as lilypond_model
from folkugat_web.model import temes as model
ScoreRowTuple = tuple[int, int, str, str, str, str | None, str | None, bool]
class ScoreRowDict(TypedDict):
id: int | None
tema_id: int
title: str
errors: str
source: str
img_url: str | None
pdf_url: str | None
hidden: bool
def score_to_row(score: model.Score) -> ScoreRowDict:
return {
"id": score.id,
"tema_id": score.tema_id,
"title": score.title,
"errors": json.dumps(list(error.to_dict() for error in score.errors)),
"source": score.source,
"img_url": score.img_url,
"pdf_url": score.pdf_url,
"hidden": score.hidden,
}
def row_to_score(row: ScoreRowTuple) -> model.Score:
errors_dicts: list[dict[str, str]] = json.loads(row[4])
return model.Score(
id=row[0],
tema_id=row[1],
title=row[2],
source=row[3],
errors=list(map(lilypond_model.RenderError.from_dict, errors_dicts)),
img_url=row[5],
pdf_url=row[6],
hidden=bool(row[7]),
)
class QueryData(TypedDict, total=False):
id: int
tema_id: int
def _filter_clause(
score_id: int | None,
tema_id: int | None,
) -> tuple[str, QueryData]:
filter_clauses: list[str] = []
filter_data: QueryData = {}
if score_id is not None:
filter_clauses.append("id = :id")
filter_data["id"] = score_id
if tema_id is not None:
filter_clauses.append("tema_id = :tema_id")
filter_data["tema_id"] = tema_id
filter_clause = " AND ".join(filter_clauses)
return filter_clause, filter_data
def get_scores(score_id: int | None = None, tema_id: int | None = None, con: Connection | None = None) -> Iterable[model.Score]:
filter_clause, data = _filter_clause(score_id=score_id, tema_id=tema_id)
if filter_clause:
filter_clause = f"WHERE {filter_clause}"
query = f"""
SELECT
id, tema_id, title, source, errors, img_url, pdf_url, hidden
FROM tema_scores
{filter_clause}
"""
with get_connection(con) as con:
cur = con.cursor()
_ = cur.execute(query, data)
return map(row_to_score, cur.fetchall())
def insert_score(score: model.Score, con: Connection | None = None) -> model.Score:
data = score_to_row(score)
query = f"""
INSERT INTO tema_scores
(id, tema_id, title, source, errors, img_url, pdf_url, hidden)
VALUES
(:id, :tema_id, :title, :source, :errors, :img_url, :pdf_url, :hidden)
RETURNING *
"""
with get_connection(con) as con:
cur = con.cursor()
_ = cur.execute(query, data)
row: ScoreRowTuple = cur.fetchone()
return row_to_score(row)
def update_score(score: model.Score, con: Connection | None = None):
data = score_to_row(score)
query = """
UPDATE tema_scores
SET
tema_id = :tema_id, title = :title, source = :source, errors = :errors,
img_url = :img_url, pdf_url = :pdf_url, hidden = :hidden
WHERE
id = :id
"""
with get_connection(con) as con:
cur = con.cursor()
_ = cur.execute(query, data)
def delete_score(score_id: int, tema_id: int | None = None, con: Connection | None = None):
filter_clause, data = _filter_clause(score_id=score_id, tema_id=tema_id)
if filter_clause:
filter_clause = f"WHERE {filter_clause}"
query = f"""
DELETE FROM tema_scores
{filter_clause}
"""
with get_connection(con) as con:
cur = con.cursor()
_ = cur.execute(query, data)

View File

@@ -0,0 +1,36 @@
from fastapi import Request
from folkugat_web.model import temes as model
from folkugat_web.templates import templates
def score(request: Request, logged_in: bool, score: model.Score):
return templates.TemplateResponse(
"fragments/tema/score.html",
{
"request": request,
"logged_in": logged_in,
"score": score,
}
)
# def score_code(request: Request, logged_in: bool, tema: model.Tema):
# return templates.TemplateResponse(
# "fragments/tema/score.html",
# {
# "request": request,
# "logged_in": logged_in,
# "tema": tema,
# }
# )
def score_editor(request: Request, logged_in: bool, score: model.Score):
return templates.TemplateResponse(
"fragments/tema/editor/score.html",
{
"request": request,
"logged_in": logged_in,
"score": score,
}
)

View File

@@ -1,5 +1,6 @@
from fastapi import Request
from folkugat_web.model import temes as model
from folkugat_web.model.lilypond import RenderError
from folkugat_web.model.pagines import Pages
from folkugat_web.services import sessions as sessions_service
from folkugat_web.services.temes import links as links_service
@@ -72,3 +73,20 @@ def tema(request: Request, logged_in: bool, tema: model.Tema):
"date_names": sessions_service.get_date_names,
}
)
def score_render(
request: Request,
score_id: int,
score_render_url: str = "",
errors: list[RenderError] | None = None,
):
return templates.TemplateResponse(
"fragments/tema/editor/score_render.html",
{
"request": request,
"score_id": score_id,
"score_render_url": score_render_url,
"errors": errors,
}
)

View File

@@ -0,0 +1,24 @@
import dataclasses
from typing import Self
@dataclasses.dataclass
class RenderError:
line: int
pos: int
error: str
@classmethod
def from_dict(cls, error_match: dict[str, str]) -> Self:
return cls(
line=int(error_match["line"]),
pos=int(error_match["pos"]),
error=error_match["error"],
)
def to_dict(self) -> dict[str, str]:
return dict(
line=str(self.line),
pos=str(self.pos),
error=self.error,
)

View File

@@ -1,7 +1,10 @@
import dataclasses
import datetime
import enum
import itertools
from typing import Self
from folkugat_web.model.lilypond import RenderError
from folkugat_web.model.search import NGrams
from folkugat_web.model.sessions import Session
from folkugat_web.services import ngrams
@@ -55,6 +58,33 @@ class Lyrics:
content: str
@dataclasses.dataclass
class Score:
id: int | None
tema_id: int
title: str
errors: list[RenderError]
source: str
img_url: str | None
pdf_url: str | None
hidden: bool
@classmethod
def from_link(cls, link: Link) -> Self:
if link.link_type not in {LinkType.IMAGE, LinkType.PDF}:
raise ValueError("Link not supported to build a score!")
return cls(
id=None,
tema_id=link.tema_id,
title=link.title,
source="",
errors=[],
img_url=link.url if link.link_type is LinkType.IMAGE else None,
pdf_url=link.url if link.link_type is LinkType.PDF else None,
hidden=False,
)
@dataclasses.dataclass
class Stats:
times_played: int
@@ -69,6 +99,7 @@ class Tema:
properties: list[Property] = dataclasses.field(default_factory=list)
links: list[Link] = dataclasses.field(default_factory=list)
lyrics: list[Lyrics] = dataclasses.field(default_factory=list)
scores: list[Score] = dataclasses.field(default_factory=list)
# Search related
alternatives: list[str] = dataclasses.field(default_factory=list)
hidden: bool = True
@@ -89,9 +120,18 @@ class Tema:
return link.url.startswith("/")
return link.link_type is LinkType.IMAGE
def score(self) -> Link | None:
result = next(filter(self._is_score, self.links), None)
return result
def main_score(self) -> Score | None:
candidate_scores = filter(lambda s: s.hidden is False, self.scores)
candidate_links = map(Score.from_link, filter(self._is_score, self.links))
return next(itertools.chain(candidate_scores, candidate_links), None)
def composer(self) -> str | None:
result = next(filter(lambda prop: prop.field is PropertyField.AUTOR, self.properties), None)
return result.value if result else None
def origin(self) -> str | None:
result = next(filter(lambda prop: prop.field is PropertyField.ORIGEN, self.properties), None)
return result.value if result else None
class TemaCols(enum.Enum):

View File

@@ -9,6 +9,7 @@ import magic
from fastapi import HTTPException, UploadFile
from folkugat_web.config import db
from folkugat_web.dal.sql.temes import links as links_dal
from folkugat_web.dal.sql.temes import scores as scores_dal
from folkugat_web.log import logger
@@ -46,10 +47,7 @@ async def store_file(tema_id: int, upload_file: UploadFile) -> str:
check_mimetype(mimetype)
extension = mimetypes.guess_extension(mimetype) or ""
filename = str(uuid.uuid4().hex) + extension
filedir = db.DB_FILES_DIR / str(tema_id)
filedir.mkdir(exist_ok=True)
filepath = filedir / filename
filepath = create_tema_filename(tema_id=tema_id, extension=extension)
with open(filepath, "wb") as f:
_ = f.write(await upload_file.read())
@@ -57,15 +55,34 @@ async def store_file(tema_id: int, upload_file: UploadFile) -> str:
return get_db_file_path(filepath)
def create_tema_filename(tema_id: int, extension: str = "") -> Path:
filename = str(uuid.uuid4().hex) + extension
filedir = db.DB_FILES_DIR / str(tema_id)
filedir.mkdir(exist_ok=True)
filepath = filedir / filename
return filepath
def create_tmp_filename(extension: str = "") -> Path:
filename = str(uuid.uuid4().hex) + extension
filedir = db.DB_FILES_DIR / "tmp"
filedir.mkdir(exist_ok=True)
filepath = filedir / filename
return filepath
def list_files(tema_id: str) -> list[str]:
filedir = db.DB_FILES_DIR / str(tema_id)
return [get_db_file_path(f) for f in filedir.iterdir()]
def get_orphan_files() -> Iterator[Path]:
alive_files = {link.url for link in links_dal.get_links()}
link_urls = {link.url for link in links_dal.get_links()}
score_pdf_urls = {score.pdf_url for score in scores_dal.get_scores() if score.pdf_url is not None}
score_img_urls = {score.img_url for score in scores_dal.get_scores() if score.img_url is not None}
alive_urls = link_urls | score_pdf_urls | score_img_urls
return filter(
lambda p: p.is_file() and get_db_file_path(p) not in alive_files,
lambda p: p.is_file() and get_db_file_path(p) not in alive_urls,
db.DB_FILES_DIR.rglob("*"),
)

View File

@@ -0,0 +1,70 @@
import asyncio
import dataclasses
import os
import re
from pathlib import Path
from typing import Literal
import aiofiles
from folkugat_web.model.lilypond import RenderError
from folkugat_web.model.temes import Tema
from folkugat_web.services import files as files_service
from folkugat_web.templates import templates
SCORE_BEGINNING = "% --- SCORE BEGINNING --- %"
RenderFormat = Literal["png"] | Literal["pdf"]
def source_to_single_score(source: str, tema: Tema) -> str:
return templates.get_template("lilypond/single_score.ly").render(
score_beginning=SCORE_BEGINNING,
score_source=source,
tema=tema,
)
async def render(source: str, fmt: RenderFormat, output_filename: Path | None = None) -> tuple[Path | None, list[RenderError]]:
input_filename = files_service.create_tmp_filename(extension=".ly")
async with aiofiles.open(input_filename, "w") as f:
_ = await f.write(source)
output_filename = output_filename or files_service.create_tmp_filename()
if fmt == "png":
proc = await asyncio.create_subprocess_exec(
"lilypond", "--png", "-o", output_filename, input_filename,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
else:
proc = await asyncio.create_subprocess_exec(
"lilypond", "-o", output_filename, input_filename,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
_stdout, stderr = await proc.communicate()
if proc.returncode:
esc_input_filename = re.escape(str(input_filename))
error_re = re.compile(rf"{esc_input_filename}:(?P<line>\d+):(?P<pos>\d+): error: (?P<error>.*)$")
error_matches = filter(None, map(error_re.match, stderr.decode("utf-8").splitlines()))
errors = map(lambda m: RenderError.from_dict(m.groupdict()), error_matches)
line_offset = source.splitlines().index(SCORE_BEGINNING) + 1
errors_offset = [dataclasses.replace(e, line=e.line - line_offset) for e in errors if e.line > line_offset]
return None, errors_offset
if fmt == "png":
# Check if the output generated multiple pages
pages = sorted(output_filename.parent.rglob(f"{output_filename.name}*.png"))
if len(pages) > 1:
output_filename = pages[0]
for page in pages[1:]:
os.remove(page)
else:
output_filename = output_filename.with_suffix(".png")
elif fmt == "pdf":
output_filename = output_filename.with_suffix(".pdf")
return output_filename, []

View File

@@ -5,6 +5,7 @@ from folkugat_web.log import logger
from folkugat_web.model import playlists
from folkugat_web.services.temes import links as links_service
from folkugat_web.services.temes import query as temes_query
from folkugat_web.services.temes import scores as scores_service
def add_temes_to_playlist(playlist: playlists.Playlist) -> playlists.Playlist:
@@ -26,6 +27,7 @@ def add_tema_to_tema_in_set(tema_in_set: playlists.TemaInSet) -> playlists.TemaI
logger.error("fCould not load tune in set: {tema_in_set}")
else:
_ = links_service.add_links_to_tema(tema_in_set.tema)
_ = scores_service.add_scores_to_tema(tema_in_set.tema)
return tema_in_set

View File

@@ -0,0 +1,56 @@
from collections.abc import Iterable, Iterator
from fastapi import HTTPException
from folkugat_web.dal.sql.temes import scores as scores_dal
from folkugat_web.model import temes as model
from folkugat_web.services import lilypond
from folkugat_web.services.temes import properties as properties_service
from folkugat_web.services.temes import query as temes_q
from folkugat_web.utils import FnChain
def add_scores_to_tema(tema: model.Tema) -> model.Tema:
if tema.id is not None:
tema.scores = list(scores_dal.get_scores(tema_id=tema.id))
return tema
def add_scores_to_temes(temes: Iterable[model.Tema]) -> Iterator[model.Tema]:
return map(add_scores_to_tema, temes)
def get_score_by_id(score_id: int, tema_id: int | None = None) -> model.Score | None:
return next(iter(scores_dal.get_scores(score_id=score_id, tema_id=tema_id)), None)
def update_score(score: model.Score):
scores_dal.update_score(score=score)
def delete_score(score_id: int, tema_id: int | None = None):
scores_dal.delete_score(score_id=score_id, tema_id=tema_id)
def create_score(tema_id: int) -> model.Score:
new_score = model.Score(
id=None,
tema_id=tema_id,
title="",
source="",
errors=[],
img_url=None,
pdf_url=None,
hidden=True,
)
return scores_dal.insert_score(score=new_score)
def build_single_tune_full_source(tema_id: int, source: str) -> str:
tema = temes_q.get_tema_by_id(tema_id)
if not tema:
raise HTTPException(status_code=404, detail="Could not find tema!")
tema = (
FnChain.transform(tema) |
properties_service.add_properties_to_tema
).result()
return lilypond.source_to_single_score(source=source, tema=tema)