Added lilypond edition
This commit is contained in:
@@ -6,6 +6,8 @@
|
|||||||
** TODO Usuaris i permisos granulars
|
** TODO Usuaris i permisos granulars
|
||||||
** TODO Lilypond support (o similar)
|
** TODO Lilypond support (o similar)
|
||||||
*** TODO Fer cançoners "en directe"
|
*** 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
|
* Idees
|
||||||
** Jams
|
** Jams
|
||||||
*** Properes jams
|
*** Properes jams
|
||||||
|
|||||||
@@ -32,6 +32,8 @@
|
|||||||
python-multipart
|
python-multipart
|
||||||
jinja2
|
jinja2
|
||||||
uvicorn
|
uvicorn
|
||||||
|
# Async
|
||||||
|
aiofiles
|
||||||
# Files
|
# Files
|
||||||
magic
|
magic
|
||||||
# Auth
|
# Auth
|
||||||
@@ -55,6 +57,7 @@
|
|||||||
black
|
black
|
||||||
# Project dependencies
|
# Project dependencies
|
||||||
nodePackages_latest.tailwindcss
|
nodePackages_latest.tailwindcss
|
||||||
|
lilypond-with-fonts
|
||||||
# Project tools
|
# Project tools
|
||||||
sqlite
|
sqlite
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
from . import editor, index, links, lyrics, properties
|
from . import editor, index, links, lyrics, properties, scores
|
||||||
|
|||||||
@@ -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 lyrics as lyrics_service
|
||||||
from folkugat_web.services.temes import properties as properties_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 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.services.temes import write as temes_w
|
||||||
from folkugat_web.templates import templates
|
from folkugat_web.templates import templates
|
||||||
from folkugat_web.utils import FnChain
|
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 |
|
temes_q.tema_compute_stats |
|
||||||
links_service.add_links_to_tema |
|
links_service.add_links_to_tema |
|
||||||
lyrics_service.add_lyrics_to_tema |
|
lyrics_service.add_lyrics_to_tema |
|
||||||
|
scores_service.add_scores_to_tema |
|
||||||
properties_service.add_properties_to_tema
|
properties_service.add_properties_to_tema
|
||||||
).result()
|
).result()
|
||||||
return temes.tema(request, logged_in, tema)
|
return temes.tema(request, logged_in, tema)
|
||||||
|
|||||||
147
folkugat_web/api/tema/scores.py
Normal file
147
folkugat_web/api/tema/scores.py
Normal 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)
|
||||||
35
folkugat_web/assets/static/css/lilypond.css
Normal file
35
folkugat_web/assets/static/css/lilypond.css
Normal 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
17
folkugat_web/assets/static/js/lilypond.js
Normal file
17
folkugat_web/assets/static/js/lilypond.js
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -7,7 +7,8 @@
|
|||||||
</h3>
|
</h3>
|
||||||
<div class="mx-12 text-left">
|
<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" %}
|
{% include "fragments/tema/score.html" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -4,12 +4,13 @@
|
|||||||
{% include "fragments/tema/title.html" %}
|
{% include "fragments/tema/title.html" %}
|
||||||
</div>
|
</div>
|
||||||
<div class="p-12 text-left">
|
<div class="p-12 text-left">
|
||||||
<div id="tema-{{ tema.id }}-score"
|
<!-- <div id="tema-{{ tema.id }}-score" -->
|
||||||
hx-get="/api/tema/{{ tema.id }}/score"
|
<!-- hx-get="/api/tema/{{ tema.id }}/score" -->
|
||||||
hx-trigger="load, reload-tema-{{ tema.id }}-score from:body"
|
<!-- hx-trigger="load, reload-tema-{{ tema.id }}-score from:body" -->
|
||||||
hx-swap="innerHTML"
|
<!-- hx-swap="innerHTML" -->
|
||||||
>
|
<!-- > -->
|
||||||
</div>
|
<!-- </div> -->
|
||||||
|
{% include "fragments/tema/scores.html" %}
|
||||||
{% include "fragments/tema/lyrics.html" %}
|
{% include "fragments/tema/lyrics.html" %}
|
||||||
{% include "fragments/tema/links.html" %}
|
{% include "fragments/tema/links.html" %}
|
||||||
{% include "fragments/tema/properties.html" %}
|
{% include "fragments/tema/properties.html" %}
|
||||||
|
|||||||
@@ -1,15 +1,63 @@
|
|||||||
{% set score = tema.score() %}
|
{% if logged_in or not score.hidden %}
|
||||||
{% if score %}
|
<div id="tema-score-{{ score.id }}">
|
||||||
<h4 class="mp-4 text-xl text-beige">Partitura</h4>
|
<h5 class="text-sm text-beige text-right">
|
||||||
<hr class="h-px mt-1 mb-3 bg-beige border-0">
|
{{ score.title }}
|
||||||
{% if score.link_type == LinkType.PDF %}
|
{% if logged_in and score.id is not none %}
|
||||||
{% set pdf_url = score.url %}
|
{% if score.source == "" %}
|
||||||
{% include "fragments/pdf_viewer.html" %}
|
<i>(Partitura buida)</i>
|
||||||
{% elif score.link_type == LinkType.IMAGE %}
|
{% elif score.errors %}
|
||||||
<div class="flex justify-center">
|
<i>(Partitura amb errors)</i>
|
||||||
<a href="{{ score.url }}" target="_blank">
|
{% endif %}
|
||||||
<img class="m-2" src="{{ score.url }}" />
|
{% if score.hidden %}
|
||||||
</a>
|
<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>
|
</div>
|
||||||
{% endif %}
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
31
folkugat_web/assets/templates/fragments/tema/scores.html
Normal file
31
folkugat_web/assets/templates/fragments/tema/scores.html
Normal 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 %}
|
||||||
@@ -12,16 +12,5 @@
|
|||||||
hx-trigger="revealed, keyup delay:500ms changed"
|
hx-trigger="revealed, keyup delay:500ms changed"
|
||||||
hx-target="#search-results"
|
hx-target="#search-results"
|
||||||
hx-swap="outerHTML">
|
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 id="search-results" class="flex-col"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
<div id="search-results"
|
<div id="search-results"
|
||||||
class="min-w-full w-full">
|
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">
|
<table class="text-left min-w-full w-full">
|
||||||
<tr class="border-b border-beige">
|
<tr class="border-b border-beige">
|
||||||
<td class="font-bold py-2 px-4">Nom</td>
|
<td class="font-bold py-2 px-4">Nom</td>
|
||||||
|
|||||||
@@ -19,6 +19,14 @@
|
|||||||
<!-- HTMX -->
|
<!-- HTMX -->
|
||||||
<script src="{{ url_for(request, 'static', path='js/htmx.min.js') }}"></script>
|
<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! -->
|
<!-- Favicon! -->
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="{{ url_for(request, 'static', path='favicon/apple-touch-icon.png') }}">
|
<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') }}">
|
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for(request, 'static', path='favicon/favicon-32x32.png') }}">
|
||||||
|
|||||||
75
folkugat_web/assets/templates/lilypond/score.ly
Normal file
75
folkugat_web/assets/templates/lilypond/score.ly
Normal 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 "|."
|
||||||
|
}
|
||||||
|
}
|
||||||
61
folkugat_web/assets/templates/lilypond/score_template.ly
Normal file
61
folkugat_web/assets/templates/lilypond/score_template.ly
Normal 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 "|."
|
||||||
|
}
|
||||||
|
}
|
||||||
27
folkugat_web/assets/templates/lilypond/single_score.ly
Normal file
27
folkugat_web/assets/templates/lilypond/single_score.ly
Normal 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 }}
|
||||||
|
>>
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ def create_db(con: Connection | None = None):
|
|||||||
create_links_table(con)
|
create_links_table(con)
|
||||||
create_lyrics_table(con)
|
create_lyrics_table(con)
|
||||||
create_properties_table(con)
|
create_properties_table(con)
|
||||||
|
create_scores_table(con)
|
||||||
|
|
||||||
|
|
||||||
def create_temes_table(con: Connection):
|
def create_temes_table(con: Connection):
|
||||||
@@ -66,3 +67,21 @@ def create_properties_table(con: Connection):
|
|||||||
"""
|
"""
|
||||||
cur = con.cursor()
|
cur = con.cursor()
|
||||||
_ = cur.execute(query)
|
_ = 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)
|
||||||
|
|||||||
133
folkugat_web/dal/sql/temes/scores.py
Normal file
133
folkugat_web/dal/sql/temes/scores.py
Normal 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)
|
||||||
36
folkugat_web/fragments/tema/scores.py
Normal file
36
folkugat_web/fragments/tema/scores.py
Normal 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,
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
from folkugat_web.model import temes as model
|
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.model.pagines import Pages
|
||||||
from folkugat_web.services import sessions as sessions_service
|
from folkugat_web.services import sessions as sessions_service
|
||||||
from folkugat_web.services.temes import links as links_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,
|
"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,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|||||||
24
folkugat_web/model/lilypond.py
Normal file
24
folkugat_web/model/lilypond.py
Normal 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,
|
||||||
|
)
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import dataclasses
|
import dataclasses
|
||||||
import datetime
|
import datetime
|
||||||
import enum
|
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.search import NGrams
|
||||||
from folkugat_web.model.sessions import Session
|
from folkugat_web.model.sessions import Session
|
||||||
from folkugat_web.services import ngrams
|
from folkugat_web.services import ngrams
|
||||||
@@ -55,6 +58,33 @@ class Lyrics:
|
|||||||
content: str
|
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
|
@dataclasses.dataclass
|
||||||
class Stats:
|
class Stats:
|
||||||
times_played: int
|
times_played: int
|
||||||
@@ -69,6 +99,7 @@ class Tema:
|
|||||||
properties: list[Property] = dataclasses.field(default_factory=list)
|
properties: list[Property] = dataclasses.field(default_factory=list)
|
||||||
links: list[Link] = dataclasses.field(default_factory=list)
|
links: list[Link] = dataclasses.field(default_factory=list)
|
||||||
lyrics: list[Lyrics] = dataclasses.field(default_factory=list)
|
lyrics: list[Lyrics] = dataclasses.field(default_factory=list)
|
||||||
|
scores: list[Score] = dataclasses.field(default_factory=list)
|
||||||
# Search related
|
# Search related
|
||||||
alternatives: list[str] = dataclasses.field(default_factory=list)
|
alternatives: list[str] = dataclasses.field(default_factory=list)
|
||||||
hidden: bool = True
|
hidden: bool = True
|
||||||
@@ -89,9 +120,18 @@ class Tema:
|
|||||||
return link.url.startswith("/")
|
return link.url.startswith("/")
|
||||||
return link.link_type is LinkType.IMAGE
|
return link.link_type is LinkType.IMAGE
|
||||||
|
|
||||||
def score(self) -> Link | None:
|
def main_score(self) -> Score | None:
|
||||||
result = next(filter(self._is_score, self.links), None)
|
candidate_scores = filter(lambda s: s.hidden is False, self.scores)
|
||||||
return result
|
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):
|
class TemaCols(enum.Enum):
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import magic
|
|||||||
from fastapi import HTTPException, UploadFile
|
from fastapi import HTTPException, UploadFile
|
||||||
from folkugat_web.config import db
|
from folkugat_web.config import db
|
||||||
from folkugat_web.dal.sql.temes import links as links_dal
|
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
|
from folkugat_web.log import logger
|
||||||
|
|
||||||
|
|
||||||
@@ -46,10 +47,7 @@ async def store_file(tema_id: int, upload_file: UploadFile) -> str:
|
|||||||
check_mimetype(mimetype)
|
check_mimetype(mimetype)
|
||||||
|
|
||||||
extension = mimetypes.guess_extension(mimetype) or ""
|
extension = mimetypes.guess_extension(mimetype) or ""
|
||||||
filename = str(uuid.uuid4().hex) + extension
|
filepath = create_tema_filename(tema_id=tema_id, extension=extension)
|
||||||
filedir = db.DB_FILES_DIR / str(tema_id)
|
|
||||||
filedir.mkdir(exist_ok=True)
|
|
||||||
filepath = filedir / filename
|
|
||||||
|
|
||||||
with open(filepath, "wb") as f:
|
with open(filepath, "wb") as f:
|
||||||
_ = f.write(await upload_file.read())
|
_ = 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)
|
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]:
|
def list_files(tema_id: str) -> list[str]:
|
||||||
filedir = db.DB_FILES_DIR / str(tema_id)
|
filedir = db.DB_FILES_DIR / str(tema_id)
|
||||||
return [get_db_file_path(f) for f in filedir.iterdir()]
|
return [get_db_file_path(f) for f in filedir.iterdir()]
|
||||||
|
|
||||||
|
|
||||||
def get_orphan_files() -> Iterator[Path]:
|
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(
|
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("*"),
|
db.DB_FILES_DIR.rglob("*"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
70
folkugat_web/services/lilypond.py
Normal file
70
folkugat_web/services/lilypond.py
Normal 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, []
|
||||||
@@ -5,6 +5,7 @@ from folkugat_web.log import logger
|
|||||||
from folkugat_web.model import playlists
|
from folkugat_web.model import playlists
|
||||||
from folkugat_web.services.temes import links as links_service
|
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 query as temes_query
|
||||||
|
from folkugat_web.services.temes import scores as scores_service
|
||||||
|
|
||||||
|
|
||||||
def add_temes_to_playlist(playlist: playlists.Playlist) -> playlists.Playlist:
|
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}")
|
logger.error("fCould not load tune in set: {tema_in_set}")
|
||||||
else:
|
else:
|
||||||
_ = links_service.add_links_to_tema(tema_in_set.tema)
|
_ = links_service.add_links_to_tema(tema_in_set.tema)
|
||||||
|
_ = scores_service.add_scores_to_tema(tema_in_set.tema)
|
||||||
return tema_in_set
|
return tema_in_set
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
56
folkugat_web/services/temes/scores.py
Normal file
56
folkugat_web/services/temes/scores.py
Normal 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)
|
||||||
Reference in New Issue
Block a user