diff --git a/README.org b/README.org index 8d3e896..41136ce 100644 --- a/README.org +++ b/README.org @@ -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 diff --git a/flake.nix b/flake.nix index 25139e9..c899865 100644 --- a/flake.nix +++ b/flake.nix @@ -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 ]; diff --git a/folkugat_web/api/tema/__init__.py b/folkugat_web/api/tema/__init__.py index 962bbf4..3caca0a 100644 --- a/folkugat_web/api/tema/__init__.py +++ b/folkugat_web/api/tema/__init__.py @@ -1 +1 @@ -from . import editor, index, links, lyrics, properties +from . import editor, index, links, lyrics, properties, scores diff --git a/folkugat_web/api/tema/index.py b/folkugat_web/api/tema/index.py index a8bcae7..8d15f7e 100644 --- a/folkugat_web/api/tema/index.py +++ b/folkugat_web/api/tema/index.py @@ -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) diff --git a/folkugat_web/api/tema/scores.py b/folkugat_web/api/tema/scores.py new file mode 100644 index 0000000..37a9b23 --- /dev/null +++ b/folkugat_web/api/tema/scores.py @@ -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) diff --git a/folkugat_web/assets/static/css/lilypond.css b/folkugat_web/assets/static/css/lilypond.css new file mode 100644 index 0000000..abfd282 --- /dev/null +++ b/folkugat_web/assets/static/css/lilypond.css @@ -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; +} diff --git a/folkugat_web/assets/static/css/main.css b/folkugat_web/assets/static/css/main.css index b713ca4..1b4afde 100644 --- a/folkugat_web/assets/static/css/main.css +++ b/folkugat_web/assets/static/css/main.css @@ -1 +1,1077 @@ -*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]:where(:not([hidden=until-found])){display:none}.visible{visibility:visible}.invisible{visibility:hidden}.static{position:static}.absolute{position:absolute}.relative{position:relative}.-left-6{left:-1.5rem}.-top-5{top:-1.25rem}.m-0{margin:0}.m-1{margin:.25rem}.m-12{margin:3rem}.m-2{margin:.5rem}.m-3{margin:.75rem}.m-6{margin:1.5rem}.m-8{margin:2rem}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-12{margin-left:3rem;margin-right:3rem}.mx-2{margin-left:.5rem;margin-right:.5rem}.mx-4{margin-left:1rem;margin-right:1rem}.my-1{margin-bottom:.25rem;margin-top:.25rem}.my-2{margin-bottom:.5rem;margin-top:.5rem}.my-3{margin-top:.75rem}.mb-3,.my-3{margin-bottom:.75rem}.ml-2{margin-left:.5rem}.ml-auto{margin-left:auto}.mr-2{margin-right:.5rem}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-8{margin-top:2rem}.inline-block{display:inline-block}.flex{display:flex}.table{display:table}.hidden{display:none}.h-4\/5{height:80%}.h-px{height:1px}.min-h-\[400px\]{min-height:400px}.w-1\/2{width:50%}.w-full{width:100%}.min-w-full{min-width:100%}.max-w-3xl{max-width:48rem}.max-w-4xl{max-width:56rem}.max-w-xl{max-width:36rem}.max-w-xs{max-width:20rem}.flex-1{flex:1 1 0%}.flex-auto{flex:1 1 auto}.flex-none{flex:none}.grow{flex-grow:1}.animate-fade-in-one{animation:fadeIn 1s ease-in .3s 1 normal forwards}.animate-fade-in-three{animation:fadeIn 1s ease-in .9s 1 normal forwards}@keyframes fadeIn{0%{opacity:0}to{opacity:1}}.animate-fade-in-two{animation:fadeIn 1s ease-in .6s 1 normal forwards}@keyframes grow{0%{width:0}to{width:100%}}.animate-grow{animation:grow .3s ease-in 0s 1 normal forwards}@keyframes marquee{0%{transform:translateX(0)}to{transform:translateX(-100%)}}.animate-marquee{animation:marquee 25s linear infinite}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.flex-nowrap{flex-wrap:nowrap}.items-start{align-items:flex-start}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.gap-2{gap:.5rem}.overflow-hidden{overflow:hidden}.whitespace-nowrap{white-space:nowrap}.rounded{border-radius:.25rem}.rounded-md{border-radius:.375rem}.border{border-width:1px}.border-0{border-width:0}.border-b{border-bottom-width:1px}.border-none{border-style:none}.border-beige{--tw-border-opacity:1;border-color:rgb(178 124 9/var(--tw-border-opacity,1))}.border-yellow-50{--tw-border-opacity:1;border-color:rgb(254 252 232/var(--tw-border-opacity,1))}.bg-beige{--tw-bg-opacity:1;background-color:rgb(178 124 9/var(--tw-bg-opacity,1))}.bg-brown{--tw-bg-opacity:1;background-color:rgb(62 56 52/var(--tw-bg-opacity,1))}.p-0{padding:0}.p-1{padding:.25rem}.p-12{padding:3rem}.p-2{padding:.5rem}.p-4{padding:1rem}.px-10{padding-left:2.5rem;padding-right:2.5rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-4{padding-left:1rem;padding-right:1rem}.py-1{padding-bottom:.25rem;padding-top:.25rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.pl-5{padding-left:1.25rem}.pt-4{padding-top:1rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.capitalize{text-transform:capitalize}.text-beige{--tw-text-opacity:1;color:rgb(178 124 9/var(--tw-text-opacity,1))}.text-brown{--tw-text-opacity:1;color:rgb(62 56 52/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.text-yellow-50{--tw-text-opacity:1;color:rgb(254 252 232/var(--tw-text-opacity,1))}.opacity-0{opacity:0}.transition-opacity{transition-duration:.15s;transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1)}.duration-200{transition-duration:.2s}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}@media (min-width:450px){.min-\[450px\]\:flex-row{flex-direction:row}}@media (min-width:640px){.sm\:text-3xl{font-size:1.875rem;line-height:2.25rem}.sm\:text-8xl{font-size:6rem;line-height:1}} \ No newline at end of file +*, ::before, ::after { + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-gradient-from-position: ; + --tw-gradient-via-position: ; + --tw-gradient-to-position: ; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgb(59 130 246 / 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; + --tw-contain-size: ; + --tw-contain-layout: ; + --tw-contain-paint: ; + --tw-contain-style: ; +} + +::backdrop { + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-gradient-from-position: ; + --tw-gradient-via-position: ; + --tw-gradient-to-position: ; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgb(59 130 246 / 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; + --tw-contain-size: ; + --tw-contain-layout: ; + --tw-contain-paint: ; + --tw-contain-style: ; +} + +/* +! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com +*/ + +/* +1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4) +2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116) +*/ + +*, +::before, +::after { + box-sizing: border-box; + /* 1 */ + border-width: 0; + /* 2 */ + border-style: solid; + /* 2 */ + border-color: #e5e7eb; + /* 2 */ +} + +::before, +::after { + --tw-content: ''; +} + +/* +1. Use a consistent sensible line-height in all browsers. +2. Prevent adjustments of font size after orientation changes in iOS. +3. Use a more readable tab size. +4. Use the user's configured `sans` font-family by default. +5. Use the user's configured `sans` font-feature-settings by default. +6. Use the user's configured `sans` font-variation-settings by default. +7. Disable tap highlights on iOS +*/ + +html, +:host { + line-height: 1.5; + /* 1 */ + -webkit-text-size-adjust: 100%; + /* 2 */ + -moz-tab-size: 4; + /* 3 */ + -o-tab-size: 4; + tab-size: 4; + /* 3 */ + font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + /* 4 */ + font-feature-settings: normal; + /* 5 */ + font-variation-settings: normal; + /* 6 */ + -webkit-tap-highlight-color: transparent; + /* 7 */ +} + +/* +1. Remove the margin in all browsers. +2. Inherit line-height from `html` so users can set them as a class directly on the `html` element. +*/ + +body { + margin: 0; + /* 1 */ + line-height: inherit; + /* 2 */ +} + +/* +1. Add the correct height in Firefox. +2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655) +3. Ensure horizontal rules are visible by default. +*/ + +hr { + height: 0; + /* 1 */ + color: inherit; + /* 2 */ + border-top-width: 1px; + /* 3 */ +} + +/* +Add the correct text decoration in Chrome, Edge, and Safari. +*/ + +abbr:where([title]) { + -webkit-text-decoration: underline dotted; + text-decoration: underline dotted; +} + +/* +Remove the default font size and weight for headings. +*/ + +h1, +h2, +h3, +h4, +h5, +h6 { + font-size: inherit; + font-weight: inherit; +} + +/* +Reset links to optimize for opt-in styling instead of opt-out. +*/ + +a { + color: inherit; + text-decoration: inherit; +} + +/* +Add the correct font weight in Edge and Safari. +*/ + +b, +strong { + font-weight: bolder; +} + +/* +1. Use the user's configured `mono` font-family by default. +2. Use the user's configured `mono` font-feature-settings by default. +3. Use the user's configured `mono` font-variation-settings by default. +4. Correct the odd `em` font sizing in all browsers. +*/ + +code, +kbd, +samp, +pre { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + /* 1 */ + font-feature-settings: normal; + /* 2 */ + font-variation-settings: normal; + /* 3 */ + font-size: 1em; + /* 4 */ +} + +/* +Add the correct font size in all browsers. +*/ + +small { + font-size: 80%; +} + +/* +Prevent `sub` and `sup` elements from affecting the line height in all browsers. +*/ + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sub { + bottom: -0.25em; +} + +sup { + top: -0.5em; +} + +/* +1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297) +2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016) +3. Remove gaps between table borders by default. +*/ + +table { + text-indent: 0; + /* 1 */ + border-color: inherit; + /* 2 */ + border-collapse: collapse; + /* 3 */ +} + +/* +1. Change the font styles in all browsers. +2. Remove the margin in Firefox and Safari. +3. Remove default padding in all browsers. +*/ + +button, +input, +optgroup, +select, +textarea { + font-family: inherit; + /* 1 */ + font-feature-settings: inherit; + /* 1 */ + font-variation-settings: inherit; + /* 1 */ + font-size: 100%; + /* 1 */ + font-weight: inherit; + /* 1 */ + line-height: inherit; + /* 1 */ + letter-spacing: inherit; + /* 1 */ + color: inherit; + /* 1 */ + margin: 0; + /* 2 */ + padding: 0; + /* 3 */ +} + +/* +Remove the inheritance of text transform in Edge and Firefox. +*/ + +button, +select { + text-transform: none; +} + +/* +1. Correct the inability to style clickable types in iOS and Safari. +2. Remove default button styles. +*/ + +button, +input:where([type='button']), +input:where([type='reset']), +input:where([type='submit']) { + -webkit-appearance: button; + /* 1 */ + background-color: transparent; + /* 2 */ + background-image: none; + /* 2 */ +} + +/* +Use the modern Firefox focus style for all focusable elements. +*/ + +:-moz-focusring { + outline: auto; +} + +/* +Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737) +*/ + +:-moz-ui-invalid { + box-shadow: none; +} + +/* +Add the correct vertical alignment in Chrome and Firefox. +*/ + +progress { + vertical-align: baseline; +} + +/* +Correct the cursor style of increment and decrement buttons in Safari. +*/ + +::-webkit-inner-spin-button, +::-webkit-outer-spin-button { + height: auto; +} + +/* +1. Correct the odd appearance in Chrome and Safari. +2. Correct the outline style in Safari. +*/ + +[type='search'] { + -webkit-appearance: textfield; + /* 1 */ + outline-offset: -2px; + /* 2 */ +} + +/* +Remove the inner padding in Chrome and Safari on macOS. +*/ + +::-webkit-search-decoration { + -webkit-appearance: none; +} + +/* +1. Correct the inability to style clickable types in iOS and Safari. +2. Change font properties to `inherit` in Safari. +*/ + +::-webkit-file-upload-button { + -webkit-appearance: button; + /* 1 */ + font: inherit; + /* 2 */ +} + +/* +Add the correct display in Chrome and Safari. +*/ + +summary { + display: list-item; +} + +/* +Removes the default spacing and border for appropriate elements. +*/ + +blockquote, +dl, +dd, +h1, +h2, +h3, +h4, +h5, +h6, +hr, +figure, +p, +pre { + margin: 0; +} + +fieldset { + margin: 0; + padding: 0; +} + +legend { + padding: 0; +} + +ol, +ul, +menu { + list-style: none; + margin: 0; + padding: 0; +} + +/* +Reset default styling for dialogs. +*/ + +dialog { + padding: 0; +} + +/* +Prevent resizing textareas horizontally by default. +*/ + +textarea { + resize: vertical; +} + +/* +1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300) +2. Set the default placeholder color to the user's configured gray 400 color. +*/ + +input::-moz-placeholder, textarea::-moz-placeholder { + opacity: 1; + /* 1 */ + color: #9ca3af; + /* 2 */ +} + +input::placeholder, +textarea::placeholder { + opacity: 1; + /* 1 */ + color: #9ca3af; + /* 2 */ +} + +/* +Set the default cursor for buttons. +*/ + +button, +[role="button"] { + cursor: pointer; +} + +/* +Make sure disabled buttons don't get the pointer cursor. +*/ + +:disabled { + cursor: default; +} + +/* +1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14) +2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210) + This can trigger a poorly considered lint error in some tools but is included by design. +*/ + +img, +svg, +video, +canvas, +audio, +iframe, +embed, +object { + display: block; + /* 1 */ + vertical-align: middle; + /* 2 */ +} + +/* +Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14) +*/ + +img, +video { + max-width: 100%; + height: auto; +} + +/* Make elements with the HTML hidden attribute stay hidden by default */ + +[hidden]:where(:not([hidden="until-found"])) { + display: none; +} + +.visible { + visibility: visible; +} + +.invisible { + visibility: hidden; +} + +.static { + position: static; +} + +.absolute { + position: absolute; +} + +.relative { + position: relative; +} + +.-left-6 { + left: -1.5rem; +} + +.-top-5 { + top: -1.25rem; +} + +.m-0 { + margin: 0px; +} + +.m-1 { + margin: 0.25rem; +} + +.m-12 { + margin: 3rem; +} + +.m-2 { + margin: 0.5rem; +} + +.m-3 { + margin: 0.75rem; +} + +.m-6 { + margin: 1.5rem; +} + +.m-8 { + margin: 2rem; +} + +.mx-1 { + margin-left: 0.25rem; + margin-right: 0.25rem; +} + +.mx-12 { + margin-left: 3rem; + margin-right: 3rem; +} + +.mx-2 { + margin-left: 0.5rem; + margin-right: 0.5rem; +} + +.mx-4 { + margin-left: 1rem; + margin-right: 1rem; +} + +.my-1 { + margin-top: 0.25rem; + margin-bottom: 0.25rem; +} + +.my-2 { + margin-top: 0.5rem; + margin-bottom: 0.5rem; +} + +.my-3 { + margin-top: 0.75rem; + margin-bottom: 0.75rem; +} + +.mb-3 { + margin-bottom: 0.75rem; +} + +.ml-2 { + margin-left: 0.5rem; +} + +.ml-auto { + margin-left: auto; +} + +.mr-2 { + margin-right: 0.5rem; +} + +.mt-1 { + margin-top: 0.25rem; +} + +.mt-2 { + margin-top: 0.5rem; +} + +.mt-3 { + margin-top: 0.75rem; +} + +.mt-4 { + margin-top: 1rem; +} + +.mt-8 { + margin-top: 2rem; +} + +.inline-block { + display: inline-block; +} + +.flex { + display: flex; +} + +.table { + display: table; +} + +.hidden { + display: none; +} + +.h-4\/5 { + height: 80%; +} + +.h-px { + height: 1px; +} + +.min-h-\[400px\] { + min-height: 400px; +} + +.w-1\/2 { + width: 50%; +} + +.w-full { + width: 100%; +} + +.min-w-full { + min-width: 100%; +} + +.max-w-3xl { + max-width: 48rem; +} + +.max-w-4xl { + max-width: 56rem; +} + +.max-w-xl { + max-width: 36rem; +} + +.max-w-xs { + max-width: 20rem; +} + +.flex-1 { + flex: 1 1 0%; +} + +.flex-auto { + flex: 1 1 auto; +} + +.flex-none { + flex: none; +} + +.grow { + flex-grow: 1; +} + +@keyframes fadeIn { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} + +.animate-fade-in-one { + animation: fadeIn 1s ease-in 0.3s 1 normal forwards; +} + +@keyframes fadeIn { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} + +.animate-fade-in-three { + animation: fadeIn 1s ease-in 0.9s 1 normal forwards; +} + +@keyframes fadeIn { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} + +.animate-fade-in-two { + animation: fadeIn 1s ease-in 0.6s 1 normal forwards; +} + +@keyframes grow { + 0% { + width: 0%; + } + + 100% { + width: 100%; + } +} + +.animate-grow { + animation: grow 0.3s ease-in 0s 1 normal forwards; +} + +@keyframes marquee { + 0% { + transform: translateX(0%); + } + + 100% { + transform: translateX(-100%); + } +} + +.animate-marquee { + animation: marquee 25s linear infinite; +} + +.flex-row { + flex-direction: row; +} + +.flex-col { + flex-direction: column; +} + +.flex-wrap { + flex-wrap: wrap; +} + +.flex-nowrap { + flex-wrap: nowrap; +} + +.items-start { + align-items: flex-start; +} + +.items-center { + align-items: center; +} + +.justify-end { + justify-content: flex-end; +} + +.justify-center { + justify-content: center; +} + +.gap-2 { + gap: 0.5rem; +} + +.overflow-hidden { + overflow: hidden; +} + +.whitespace-nowrap { + white-space: nowrap; +} + +.rounded { + border-radius: 0.25rem; +} + +.rounded-md { + border-radius: 0.375rem; +} + +.border { + border-width: 1px; +} + +.border-0 { + border-width: 0px; +} + +.border-b { + border-bottom-width: 1px; +} + +.border-none { + border-style: none; +} + +.border-beige { + --tw-border-opacity: 1; + border-color: rgb(178 124 9 / var(--tw-border-opacity, 1)); +} + +.border-yellow-50 { + --tw-border-opacity: 1; + border-color: rgb(254 252 232 / var(--tw-border-opacity, 1)); +} + +.bg-beige { + --tw-bg-opacity: 1; + background-color: rgb(178 124 9 / var(--tw-bg-opacity, 1)); +} + +.bg-brown { + --tw-bg-opacity: 1; + background-color: rgb(62 56 52 / var(--tw-bg-opacity, 1)); +} + +.p-0 { + padding: 0px; +} + +.p-1 { + padding: 0.25rem; +} + +.p-12 { + padding: 3rem; +} + +.p-2 { + padding: 0.5rem; +} + +.p-4 { + padding: 1rem; +} + +.px-10 { + padding-left: 2.5rem; + padding-right: 2.5rem; +} + +.px-2 { + padding-left: 0.5rem; + padding-right: 0.5rem; +} + +.px-4 { + padding-left: 1rem; + padding-right: 1rem; +} + +.py-1 { + padding-top: 0.25rem; + padding-bottom: 0.25rem; +} + +.py-2 { + padding-top: 0.5rem; + padding-bottom: 0.5rem; +} + +.pl-5 { + padding-left: 1.25rem; +} + +.pt-4 { + padding-top: 1rem; +} + +.text-left { + text-align: left; +} + +.text-center { + text-align: center; +} + +.text-right { + text-align: right; +} + +.text-2xl { + font-size: 1.5rem; + line-height: 2rem; +} + +.text-3xl { + font-size: 1.875rem; + line-height: 2.25rem; +} + +.text-sm { + font-size: 0.875rem; + line-height: 1.25rem; +} + +.text-xl { + font-size: 1.25rem; + line-height: 1.75rem; +} + +.text-xs { + font-size: 0.75rem; + line-height: 1rem; +} + +.font-bold { + font-weight: 700; +} + +.capitalize { + text-transform: capitalize; +} + +.text-beige { + --tw-text-opacity: 1; + color: rgb(178 124 9 / var(--tw-text-opacity, 1)); +} + +.text-brown { + --tw-text-opacity: 1; + color: rgb(62 56 52 / var(--tw-text-opacity, 1)); +} + +.text-red-400 { + --tw-text-opacity: 1; + color: rgb(248 113 113 / var(--tw-text-opacity, 1)); +} + +.text-red-500 { + --tw-text-opacity: 1; + color: rgb(239 68 68 / var(--tw-text-opacity, 1)); +} + +.text-white { + --tw-text-opacity: 1; + color: rgb(255 255 255 / var(--tw-text-opacity, 1)); +} + +.text-yellow-50 { + --tw-text-opacity: 1; + color: rgb(254 252 232 / var(--tw-text-opacity, 1)); +} + +.opacity-0 { + opacity: 0; +} + +.transition-opacity { + transition-property: opacity; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} + +.duration-200 { + transition-duration: 200ms; +} + +.focus\:outline-none:focus { + outline: 2px solid transparent; + outline-offset: 2px; +} + +@media (min-width: 450px) { + .min-\[450px\]\:flex-row { + flex-direction: row; + } +} + +@media (min-width: 640px) { + .sm\:text-3xl { + font-size: 1.875rem; + line-height: 2.25rem; + } + + .sm\:text-8xl { + font-size: 6rem; + line-height: 1; + } +} diff --git a/folkugat_web/assets/static/js/lilypond.js b/folkugat_web/assets/static/js/lilypond.js new file mode 100644 index 0000000..7cbfa91 --- /dev/null +++ b/folkugat_web/assets/static/js/lilypond.js @@ -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; + } + }; +}); diff --git a/folkugat_web/assets/templates/fragments/sessio/set/tema.html b/folkugat_web/assets/templates/fragments/sessio/set/tema.html index 83c1c8a..8af4bbc 100644 --- a/folkugat_web/assets/templates/fragments/sessio/set/tema.html +++ b/folkugat_web/assets/templates/fragments/sessio/set/tema.html @@ -7,7 +7,8 @@
- {% if tema.score() is not none %} + {% if tema.main_score() is not none %} + {% set score = tema.main_score() %} {% include "fragments/tema/score.html" %} {% endif %} diff --git a/folkugat_web/assets/templates/fragments/tema/editor/score.html b/folkugat_web/assets/templates/fragments/tema/editor/score.html new file mode 100644 index 0000000..dfc40ed --- /dev/null +++ b/folkugat_web/assets/templates/fragments/tema/editor/score.html @@ -0,0 +1,73 @@ +
+

+ Editor de partitura +

+
+ + + +
+
+ Codi Lilypond + + + +
+
+ +
+
+ {% if score.source %} + + {% else %} + + {% endif %} + + +
+
+
+
+
diff --git a/folkugat_web/assets/templates/fragments/tema/editor/score_render.html b/folkugat_web/assets/templates/fragments/tema/editor/score_render.html new file mode 100644 index 0000000..2af1936 --- /dev/null +++ b/folkugat_web/assets/templates/fragments/tema/editor/score_render.html @@ -0,0 +1,13 @@ +
+
Previsualització
+
+ {% if score_render_url %} + + {% else %} +
    + {% for error in errors %} +
  1. Línia {{ error.line }} posició {{ error.pos }}: {{ error.error }}

    + {% endfor %} +
+ {% endif %} +
diff --git a/folkugat_web/assets/templates/fragments/tema/pagina.html b/folkugat_web/assets/templates/fragments/tema/pagina.html index 105c8f0..e98fee2 100644 --- a/folkugat_web/assets/templates/fragments/tema/pagina.html +++ b/folkugat_web/assets/templates/fragments/tema/pagina.html @@ -4,12 +4,13 @@ {% include "fragments/tema/title.html" %}
-
-
+ + + + + + + {% include "fragments/tema/scores.html" %} {% include "fragments/tema/lyrics.html" %} {% include "fragments/tema/links.html" %} {% include "fragments/tema/properties.html" %} diff --git a/folkugat_web/assets/templates/fragments/tema/score.html b/folkugat_web/assets/templates/fragments/tema/score.html index e6748aa..549623a 100644 --- a/folkugat_web/assets/templates/fragments/tema/score.html +++ b/folkugat_web/assets/templates/fragments/tema/score.html @@ -1,15 +1,63 @@ -{% set score = tema.score() %} -{% if score %} -

Partitura

-
- {% if score.link_type == LinkType.PDF %} - {% set pdf_url = score.url %} - {% include "fragments/pdf_viewer.html" %} - {% elif score.link_type == LinkType.IMAGE %} -
- - - +{% if logged_in or not score.hidden %} +
+
+ {{ score.title }} + {% if logged_in and score.id is not none %} + {% if score.source == "" %} + (Partitura buida) + {% elif score.errors %} + (Partitura amb errors) + {% endif %} + {% if score.hidden %} + (Privada) + {% endif %} + {% endif %} + {% if logged_in and score.id is not none %} + {% if score.hidden %} + + {% else %} + + {% endif %} + + + {% endif %} +
+
+
+ {% if score.img_url is not none %} +
+ + + +
+ {% elif score.pdf_url is not none %} + {% set pdf_url = score.pdf_url %} + {% include "fragments/pdf_viewer.html" %} + {% endif %}
- {% endif %} +
{% endif %} diff --git a/folkugat_web/assets/templates/fragments/tema/scores.html b/folkugat_web/assets/templates/fragments/tema/scores.html new file mode 100644 index 0000000..8e619eb --- /dev/null +++ b/folkugat_web/assets/templates/fragments/tema/scores.html @@ -0,0 +1,31 @@ +{% set main_score = tema.main_score() %} +{% if logged_in or tema.scores or main_score %} +

Partitures

+{% 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 %} +
+
+ +
+{% endif %} diff --git a/folkugat_web/assets/templates/fragments/temes/pagina.html b/folkugat_web/assets/templates/fragments/temes/pagina.html index 065e908..6859b51 100644 --- a/folkugat_web/assets/templates/fragments/temes/pagina.html +++ b/folkugat_web/assets/templates/fragments/temes/pagina.html @@ -12,16 +12,5 @@ hx-trigger="revealed, keyup delay:500ms changed" hx-target="#search-results" hx-swap="outerHTML"> - {% if logged_in %} - - {% endif %}
diff --git a/folkugat_web/assets/templates/fragments/temes/results.html b/folkugat_web/assets/templates/fragments/temes/results.html index 14c92b5..c699170 100644 --- a/folkugat_web/assets/templates/fragments/temes/results.html +++ b/folkugat_web/assets/templates/fragments/temes/results.html @@ -1,5 +1,17 @@
+ {% if logged_in and query %} + + {% endif %} diff --git a/folkugat_web/assets/templates/index.html b/folkugat_web/assets/templates/index.html index caafe52..acf6299 100644 --- a/folkugat_web/assets/templates/index.html +++ b/folkugat_web/assets/templates/index.html @@ -19,6 +19,14 @@ + + + + + + + + diff --git a/folkugat_web/assets/templates/lilypond/score.ly b/folkugat_web/assets/templates/lilypond/score.ly new file mode 100644 index 0000000..74a076f --- /dev/null +++ b/folkugat_web/assets/templates/lilypond/score.ly @@ -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 "|." + } +} diff --git a/folkugat_web/assets/templates/lilypond/score_template.ly b/folkugat_web/assets/templates/lilypond/score_template.ly new file mode 100644 index 0000000..e5729f2 --- /dev/null +++ b/folkugat_web/assets/templates/lilypond/score_template.ly @@ -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 "|." + } +} diff --git a/folkugat_web/assets/templates/lilypond/single_score.ly b/folkugat_web/assets/templates/lilypond/single_score.ly new file mode 100644 index 0000000..ddac1e6 --- /dev/null +++ b/folkugat_web/assets/templates/lilypond/single_score.ly @@ -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 }} + >> +} diff --git a/folkugat_web/dal/sql/temes/ddl.py b/folkugat_web/dal/sql/temes/ddl.py index ae9e126..68d409e 100644 --- a/folkugat_web/dal/sql/temes/ddl.py +++ b/folkugat_web/dal/sql/temes/ddl.py @@ -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) diff --git a/folkugat_web/dal/sql/temes/scores.py b/folkugat_web/dal/sql/temes/scores.py new file mode 100644 index 0000000..2918525 --- /dev/null +++ b/folkugat_web/dal/sql/temes/scores.py @@ -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) diff --git a/folkugat_web/fragments/tema/scores.py b/folkugat_web/fragments/tema/scores.py new file mode 100644 index 0000000..5666cc5 --- /dev/null +++ b/folkugat_web/fragments/tema/scores.py @@ -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, + } + ) diff --git a/folkugat_web/fragments/temes.py b/folkugat_web/fragments/temes.py index 3030623..b062427 100644 --- a/folkugat_web/fragments/temes.py +++ b/folkugat_web/fragments/temes.py @@ -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, + } + ) diff --git a/folkugat_web/model/lilypond.py b/folkugat_web/model/lilypond.py new file mode 100644 index 0000000..0bdeeea --- /dev/null +++ b/folkugat_web/model/lilypond.py @@ -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, + ) diff --git a/folkugat_web/model/temes.py b/folkugat_web/model/temes.py index bc91bfa..52317e8 100644 --- a/folkugat_web/model/temes.py +++ b/folkugat_web/model/temes.py @@ -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): diff --git a/folkugat_web/services/files.py b/folkugat_web/services/files.py index ba5239d..f5a1180 100644 --- a/folkugat_web/services/files.py +++ b/folkugat_web/services/files.py @@ -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("*"), ) diff --git a/folkugat_web/services/lilypond.py b/folkugat_web/services/lilypond.py new file mode 100644 index 0000000..2265c33 --- /dev/null +++ b/folkugat_web/services/lilypond.py @@ -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\d+):(?P\d+): error: (?P.*)$") + 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, [] diff --git a/folkugat_web/services/playlists.py b/folkugat_web/services/playlists.py index 408f098..f100152 100644 --- a/folkugat_web/services/playlists.py +++ b/folkugat_web/services/playlists.py @@ -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 diff --git a/folkugat_web/services/temes/scores.py b/folkugat_web/services/temes/scores.py new file mode 100644 index 0000000..f75b2d7 --- /dev/null +++ b/folkugat_web/services/temes/scores.py @@ -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)
Nom