Tune editor

This commit is contained in:
marc
2025-03-16 18:45:08 +01:00
parent a85efd0838
commit 6c83d11e5b
33 changed files with 960 additions and 312 deletions

61
flake.lock generated Normal file
View File

@@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1741865919,
"narHash": "sha256-4thdbnP6dlbdq+qZWTsm4ffAwoS8Tiq1YResB+RP6WE=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "573c650e8a14b2faa0041645ab18aed7e60f0c9a",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

View File

@@ -13,41 +13,51 @@
# custom-python = import ./custom-python.nix { inherit pkgs; }; # custom-python = import ./custom-python.nix { inherit pkgs; };
coreDependencies = with pythonPackages; [
# IDE tools
pylsp-mypy
isort
autopep8
# Development tools
pytest
setuptools
ipython
];
projectDependencies = with pythonPackages; [ projectDependencies = with pythonPackages; [
# API # API
fastapi fastapi
python-multipart
jinja2 jinja2
uvicorn uvicorn
# Files
magic
# Auth # Auth
pyjwt pyjwt
# Search # Search
levenshtein levenshtein
# Test
pytest
]; ];
pythonEnv = python.withPackages (
ps: projectDependencies ++ coreDependencies
);
in { in {
devShells.default = pkgs.mkShell { devShells.default = pkgs.mkShell {
nativeBuildInputs = [ pkgs.bashInteractive ]; # nativeBuildInputs = [ pkgs.bashInteractive ];
buildInputs = with pkgs; [ buildInputs = with pkgs; [
# Core python dependencies # Python dependencies
python python
pythonPackages.pip pythonEnv
pythonPackages.virtualenv pyright
# Other dependencies
nodePackages_latest.tailwindcss
# IDE tools
pythonPackages.pylsp-mypy
pythonPackages.isort
pythonPackages.autopep8
nodePackages.pyright
# Development tools
black black
pythonPackages.ipython # Project dependencies
pythonPackages.pytest nodePackages_latest.tailwindcss
pythonPackages.setuptools ];
] ++ projectDependencies;
shellHook = ''
export PYTHONPATH=${pythonEnv}/${python.sitePackages}:$PYTHONPATH
'';
}; };
}); });
} }

View File

@@ -41,3 +41,48 @@ def link_editor(
tema_id=tema_id, tema_id=tema_id,
link_id=link_id, link_id=link_id,
) )
@router.get("/api/tema/{tema_id}/editor/link/{link_id}/url")
def link_editor_url_input(
request: Request,
logged_in: auth.RequireLogin,
tema_id: int,
link_id: int,
):
return tema.link_editor_url(
request=request,
logged_in=logged_in,
tema_id=tema_id,
link_id=link_id,
)
@router.get("/api/tema/{tema_id}/editor/link/{link_id}/file")
def link_editor_file_input(
request: Request,
logged_in: auth.RequireLogin,
tema_id: int,
link_id: int,
):
return tema.link_editor_file(
request=request,
logged_in=logged_in,
tema_id=tema_id,
link_id=link_id,
)
@router.get("/api/tema/{tema_id}/editor/property/{property_id}")
def property_editor(
request: Request,
logged_in: auth.RequireLogin,
tema_id: int,
property_id: int,
):
return tema.property_editor(
request=request,
logged_in=logged_in,
tema_id=tema_id,
property_id=property_id,
)

View File

@@ -1,12 +1,13 @@
from typing import Annotated from typing import Annotated, Optional
from fastapi import Request from fastapi import Request, UploadFile
from fastapi.params import Form, Param from fastapi.params import File, Form, Param
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from folkugat_web.api import router from folkugat_web.api import router
from folkugat_web.config import db
from folkugat_web.fragments import tema, temes from folkugat_web.fragments import tema, temes
from folkugat_web.model import temes as model from folkugat_web.model import temes as model
from folkugat_web.services import auth from folkugat_web.services import auth, files
from folkugat_web.services.temes import write as temes_w from folkugat_web.services.temes import write as temes_w
from folkugat_web.services.temes.links import guess_link_type from folkugat_web.services.temes.links import guess_link_type
from folkugat_web.templates import templates from folkugat_web.templates import templates
@@ -30,6 +31,22 @@ def contingut(request: Request, logged_in: auth.LoggedIn, tema_id: int):
return temes.tema(request, tema_id, logged_in) return temes.tema(request, tema_id, logged_in)
@router.delete("/api/tema/{tema_id}")
def delete_tema(request: Request, _: auth.RequireLogin, tema_id: int):
temes_w.delete_tema(tema_id=tema_id)
return HTMLResponse(headers={
'HX-Redirect': '/temes'
})
@router.post("/api/tema")
def create_tema(request: Request, _: auth.RequireLogin, title: Annotated[str, Form()] = ""):
new_tema = temes_w.create_tema(title=title)
return HTMLResponse(headers={
'HX-Redirect': f'/tema/{new_tema.id}'
})
@router.get("/api/tema/{tema_id}/title") @router.get("/api/tema/{tema_id}/title")
def title(request: Request, logged_in: auth.LoggedIn, tema_id: int): def title(request: Request, logged_in: auth.LoggedIn, tema_id: int):
return tema.title(request=request, tema_id=tema_id, logged_in=logged_in) return tema.title(request=request, tema_id=tema_id, logged_in=logged_in)
@@ -98,24 +115,28 @@ def link(request: Request, logged_in: auth.LoggedIn, tema_id: int, link_id: int)
@router.put("/api/tema/{tema_id}/link/{link_id}") @router.put("/api/tema/{tema_id}/link/{link_id}")
def set_link( async def set_link(
request: Request, request: Request,
logged_in: auth.RequireLogin, logged_in: auth.RequireLogin,
tema_id: int, tema_id: int,
link_id: int, link_id: int,
content_type: Annotated[model.ContentType, Form()], content_type: Annotated[model.ContentType, Form()],
url: Annotated[str, Form()] = "", title: Annotated[Optional[str], Form()] = None,
title: Annotated[str, Form()] = "", url: Annotated[Optional[str], Form()] = None,
upload_file: Annotated[Optional[UploadFile], File()] = None,
): ):
link_type = guess_link_type(url) if upload_file:
url = await files.store_file(tema_id=tema_id, upload_file=upload_file)
link_type = guess_link_type(url or '')
new_link = model.Link( new_link = model.Link(
id=link_id, id=link_id,
content_type=content_type, content_type=content_type,
link_type=link_type, link_type=link_type,
url=url, url=url or '',
title=title, title=title or '',
) )
temes_w.update_link(tema_id=tema_id, link_id=link_id, link=new_link) new_tema = temes_w.update_link(tema_id=tema_id, link_id=link_id, link=new_link)
return tema.link(request=request, logged_in=logged_in, tema_id=tema_id, link_id=link_id) return tema.link(request=request, logged_in=logged_in, tema_id=tema_id, link_id=link_id)
@@ -144,7 +165,11 @@ def delete_link(
link_id: int, link_id: int,
): ):
temes_w.delete_link(tema_id=tema_id, link_id=link_id) temes_w.delete_link(tema_id=tema_id, link_id=link_id)
return HTMLResponse() return HTMLResponse(
headers={
"HX-Trigger": f"reload-tema-{tema_id}-score"
}
)
@router.get("/api/tema/{tema_id}/link/{link_id}/icon") @router.get("/api/tema/{tema_id}/link/{link_id}/icon")
@@ -164,3 +189,79 @@ def link_icon(
url=url, url=url,
content_type=content_type, content_type=content_type,
) )
@router.get("/api/tema/{tema_id}/score")
def get_score(
request: Request,
logged_in: auth.LoggedIn,
tema_id: int,
):
return tema.score(
request=request,
logged_in=logged_in,
tema_id=tema_id,
)
@router.get("/api/tema/{tema_id}/property/{property_id}")
def property_(request: Request, logged_in: auth.LoggedIn, tema_id: int, property_id: int):
return tema.property_(request=request, logged_in=logged_in, tema_id=tema_id, property_id=property_id)
@router.put("/api/tema/{tema_id}/property/{property_id}")
def set_property(
request: Request,
logged_in: auth.RequireLogin,
tema_id: int,
property_id: int,
field: Annotated[model.PropertyField, Form()],
value: Annotated[str, Form()],
):
new_property = model.Property(id=property_id, field=field, value=value.strip())
temes_w.update_property(tema_id=tema_id, property_id=property_id, prop=new_property)
return tema.property_(request=request, logged_in=logged_in, tema_id=tema_id, property_id=property_id)
@router.post("/api/tema/{tema_id}/property")
def add_property(
request: Request,
logged_in: auth.RequireLogin,
tema_id: int,
):
new_tema = temes_w.add_property(tema_id=tema_id)
property_id = new_tema.properties[-1].id
if property_id is None:
raise RuntimeError("Invalid property_id on newly created property!")
return tema.property_editor(
request=request,
logged_in=logged_in,
tema_id=tema_id,
property_id=property_id,
)
@router.delete("/api/tema/{tema_id}/property/{property_id}")
def delete_property(_: auth.RequireLogin, tema_id: int, property_id: int):
temes_w.delete_property(tema_id=tema_id, property_id=property_id)
return HTMLResponse()
@router.put("/api/tema/{tema_id}/visible")
def set_visible(request: Request, logged_in: auth.RequireLogin, tema_id: int):
new_tema = temes_w.set_visibility(tema_id=tema_id, hidden=False)
return tema.visibility(
request=request,
logged_in=logged_in,
tema=new_tema,
)
@router.put("/api/tema/{tema_id}/invisible")
def set_invisible(request: Request, logged_in: auth.RequireLogin, tema_id: int):
new_tema = temes_w.set_visibility(tema_id=tema_id, hidden=True)
return tema.visibility(
request=request,
logged_in=logged_in,
tema=new_tema,
)

View File

@@ -1,4 +1,7 @@
from typing import Annotated, Optional
from fastapi import Request from fastapi import Request
from fastapi.params import Param
from folkugat_web.api import router from folkugat_web.api import router
from folkugat_web.fragments import temes from folkugat_web.fragments import temes
from folkugat_web.services import auth from folkugat_web.services import auth
@@ -6,13 +9,17 @@ from folkugat_web.templates import templates
@router.get("/temes") @router.get("/temes")
def page(request: Request, logged_in: auth.LoggedIn): def page(
request: Request,
logged_in: auth.LoggedIn,
query: Annotated[str, Param()] = "",
):
return templates.TemplateResponse( return templates.TemplateResponse(
"index.html", "index.html",
{ {
"request": request, "request": request,
"page_title": "Folkugat", "page_title": "Folkugat",
"content": "/api/content/temes", "content": f"/api/content/temes?query={query}",
"logged_in": logged_in, "logged_in": logged_in,
"animate": False, "animate": False,
} }
@@ -20,8 +27,12 @@ def page(request: Request, logged_in: auth.LoggedIn):
@router.get("/api/content/temes") @router.get("/api/content/temes")
def content(request: Request, logged_in: auth.LoggedIn): def content(
return temes.temes_pagina(request, logged_in) request: Request,
logged_in: auth.LoggedIn,
query: Annotated[str, Param()] = "",
):
return temes.temes_pagina(request, logged_in, query)
@router.get("/api/temes/busca") @router.get("/api/temes/busca")

View File

@@ -1,5 +1,113 @@
*, ::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.3.3 | MIT License | https://tailwindcss.com ! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com
*/ */
/* /*
@@ -32,9 +140,11 @@
4. Use the user's configured `sans` font-family by default. 4. Use the user's configured `sans` font-family by default.
5. Use the user's configured `sans` font-feature-settings 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. 6. Use the user's configured `sans` font-variation-settings by default.
7. Disable tap highlights on iOS
*/ */
html { html,
:host {
line-height: 1.5; line-height: 1.5;
/* 1 */ /* 1 */
-webkit-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;
@@ -44,12 +154,14 @@ html {
-o-tab-size: 4; -o-tab-size: 4;
tab-size: 4; tab-size: 4;
/* 3 */ /* 3 */
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
/* 4 */ /* 4 */
font-feature-settings: normal; font-feature-settings: normal;
/* 5 */ /* 5 */
font-variation-settings: normal; font-variation-settings: normal;
/* 6 */ /* 6 */
-webkit-tap-highlight-color: transparent;
/* 7 */
} }
/* /*
@@ -121,8 +233,10 @@ strong {
} }
/* /*
1. Use the user's configured `mono` font family by default. 1. Use the user's configured `mono` font-family by default.
2. Correct the odd `em` font sizing in all browsers. 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, code,
@@ -131,8 +245,12 @@ samp,
pre { pre {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
/* 1 */ /* 1 */
font-size: 1em; font-feature-settings: normal;
/* 2 */ /* 2 */
font-variation-settings: normal;
/* 3 */
font-size: 1em;
/* 4 */
} }
/* /*
@@ -201,6 +319,8 @@ textarea {
/* 1 */ /* 1 */
line-height: inherit; line-height: inherit;
/* 1 */ /* 1 */
letter-spacing: inherit;
/* 1 */
color: inherit; color: inherit;
/* 1 */ /* 1 */
margin: 0; margin: 0;
@@ -224,9 +344,9 @@ select {
*/ */
button, button,
[type='button'], input:where([type='button']),
[type='reset'], input:where([type='reset']),
[type='submit'] { input:where([type='submit']) {
-webkit-appearance: button; -webkit-appearance: button;
/* 1 */ /* 1 */
background-color: transparent; background-color: transparent;
@@ -430,108 +550,16 @@ video {
/* Make elements with the HTML hidden attribute stay hidden by default */ /* Make elements with the HTML hidden attribute stay hidden by default */
[hidden] { [hidden]:where(:not([hidden="until-found"])) {
display: none; display: none;
} }
*, ::before, ::after { .visible {
--tw-border-spacing-x: 0; visibility: visible;
--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: ;
} }
::backdrop { .invisible {
--tw-border-spacing-x: 0; visibility: hidden;
--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: ;
} }
.static { .static {
@@ -574,10 +602,6 @@ video {
margin: 0.75rem; margin: 0.75rem;
} }
.m-4 {
margin: 1rem;
}
.m-6 { .m-6 {
margin: 1.5rem; margin: 1.5rem;
} }
@@ -645,18 +669,18 @@ video {
display: flex; display: flex;
} }
.table {
display: table;
}
.hidden {
display: none;
}
.h-4\/5 { .h-4\/5 {
height: 80%; height: 80%;
} }
.h-80 {
height: 20rem;
}
.h-full {
height: 100%;
}
.h-px { .h-px {
height: 1px; height: 1px;
} }
@@ -669,14 +693,14 @@ video {
width: 50%; width: 50%;
} }
.w-80 {
width: 20rem;
}
.w-full { .w-full {
width: 100%; width: 100%;
} }
.min-w-full {
min-width: 100%;
}
.max-w-3xl { .max-w-3xl {
max-width: 48rem; max-width: 48rem;
} }
@@ -685,10 +709,6 @@ video {
max-width: 56rem; max-width: 56rem;
} }
.max-w-5xl {
max-width: 64rem;
}
.max-w-xl { .max-w-xl {
max-width: 36rem; max-width: 36rem;
} }
@@ -815,6 +835,10 @@ video {
justify-content: center; justify-content: center;
} }
.gap-2 {
gap: 0.5rem;
}
.overflow-hidden { .overflow-hidden {
overflow: hidden; overflow: hidden;
} }
@@ -839,8 +863,8 @@ video {
border-width: 0px; border-width: 0px;
} }
.border-solid { .border-b {
border-style: solid; border-bottom-width: 1px;
} }
.border-none { .border-none {
@@ -849,27 +873,22 @@ video {
.border-beige { .border-beige {
--tw-border-opacity: 1; --tw-border-opacity: 1;
border-color: rgb(178 124 9 / var(--tw-border-opacity)); border-color: rgb(178 124 9 / var(--tw-border-opacity, 1));
} }
.border-yellow-50 { .border-yellow-50 {
--tw-border-opacity: 1; --tw-border-opacity: 1;
border-color: rgb(254 252 232 / var(--tw-border-opacity)); border-color: rgb(254 252 232 / var(--tw-border-opacity, 1));
} }
.bg-beige { .bg-beige {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(178 124 9 / var(--tw-bg-opacity)); background-color: rgb(178 124 9 / var(--tw-bg-opacity, 1));
} }
.bg-brown { .bg-brown {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(62 56 52 / var(--tw-bg-opacity)); background-color: rgb(62 56 52 / var(--tw-bg-opacity, 1));
}
.bg-yellow-50 {
--tw-bg-opacity: 1;
background-color: rgb(254 252 232 / var(--tw-bg-opacity));
} }
.p-0 { .p-0 {
@@ -972,22 +991,17 @@ video {
.text-beige { .text-beige {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(178 124 9 / var(--tw-text-opacity)); 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));
} }
.text-white { .text-white {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity)); color: rgb(255 255 255 / var(--tw-text-opacity, 1));
} }
.text-yellow-50 { .text-yellow-50 {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(254 252 232 / var(--tw-text-opacity)); color: rgb(254 252 232 / var(--tw-text-opacity, 1));
} }
.opacity-0 { .opacity-0 {

View File

@@ -1,10 +1,10 @@
<li id="tema-link-{{ link.id }}"> <li id="tema-link-{{ link.id }}">
<form class="flex flex-row items-start"> <form class="flex flex-row items-start"
hx-encoding='multipart/form-data'>
{% include "fragments/tema/link_icon.html" %} {% include "fragments/tema/link_icon.html" %}
<div class="grow my-2"> <div class="flex flex-col grow my-2">
<p class="text-sm text-beige"> <div class="text-sm text-beige">
<select id="tema-link-{{ link.id }}-content-type" <select name="content_type"
name="content_type"
value="{{ link.content_type.value.capitalize() }}" value="{{ link.content_type.value.capitalize() }}"
class="border border-yellow-50 focus:outline-none class="border border-yellow-50 focus:outline-none
rounded rounded
@@ -21,8 +21,8 @@
{% endif %} {% endif %}
{% endfor %} {% endfor %}
</select> </select>
</p> </div>
<p> <div>
<input name="title" <input name="title"
placeholder="Títol de l'enllaç" placeholder="Títol de l'enllaç"
value="{{ link.title }}" value="{{ link.title }}"
@@ -30,21 +30,8 @@
rounded w-full rounded w-full
bg-brown p-1 my-1" bg-brown p-1 my-1"
/> />
</p> </div>
<p> {% include "fragments/tema/editor/link_url.html" %}
<input name="url"
placeholder="URL de l'enllaç"
value="{{ link.url }}"
class="border border-beige focus:outline-none
rounded w-full
bg-brown p-1 my-1"
hx-get="/api/tema/{{ tema.id }}/link/{{ link_id }}/icon"
hx-trigger="keyup delay:500ms changed"
hx-target="#link-icon-{{ link.id }}"
hx-include="[name='content_type']"
hx-swap="outerHTML"
/>
</p>
</div> </div>
<div class="m-2 text-sm text-beige"> <div class="m-2 text-sm text-beige">
<button title="Desa els canvis" <button title="Desa els canvis"

View File

@@ -0,0 +1,16 @@
<div id="link-editor-{{ link_id }}-file"
class="flex flex-row gap-2">
<input type='file'
class="border border-beige focus:outline-none
rounded grow
bg-brown p-1 my-1"
name='upload_file'>
<button title="Afegeix un enllaç"
class="border border-beige rounded px-2 py-1 my-1"
hx-get="/api/tema/{{ tema.id }}/editor/link/{{ link_id }}/url"
hx-target="#link-editor-{{ link_id }}-file"
hx-swap="outerHTML"
>
<i class="fa fa-link" aria-hidden="true"></i>
</button>
</div>

View File

@@ -0,0 +1,23 @@
<div id="link-editor-{{ link_id }}-url"
class="flex flex-row gap-2">
<input name="url"
placeholder="URL de l'enllaç"
value="{{ link.url }}"
class="border border-beige focus:outline-none
rounded grow
bg-brown p-1 my-1"
hx-get="/api/tema/{{ tema.id }}/link/{{ link_id }}/icon"
hx-trigger="keyup delay:500ms changed"
hx-target="#link-icon-{{ link.id }}"
hx-include="[name='content_type']"
hx-swap="outerHTML"
/>
<button title="Puja un fitxer"
class="border border-beige rounded py-1 px-2 my-1"
hx-get="/api/tema/{{ tema.id }}/editor/link/{{ link_id }}/file"
hx-target="#link-editor-{{ link_id }}-url"
hx-swap="outerHTML"
>
<i class="fa fa-upload" aria-hidden="true"></i>
</button>
</div>

View File

@@ -0,0 +1,45 @@
<form id="tema-property-{{ property.id }}"
class="flex flex-row items-center">
<div class="px-2">
<select name="field"
value="{{ property.field.value.capitalize() }}"
class="border border-yellow-50 focus:outline-none
rounded
text-yellow-50 text-center
bg-brown p-1 m-0">
<option value="{{ property.field.value }}">
{{ property.field.value.capitalize() }}
</option>
{% for pf in PropertyField %}
{% if pf != property.field %}
<option value="{{ pf.value }}">
{{ pf.value.capitalize() }}
</option>
{% endif %}
{% endfor %}
</select>
</div>
<div class="grow mx-2">
<input name="value"
placeholder="Informació"
value="{{ property.value }}"
class="border border-beige focus:outline-none
rounded w-full
bg-brown px-2 py-1"
/>
</div>
<button title="Desa els canvis"
class="text-sm text-beige mx-1"
hx-put="/api/tema/{{ tema.id }}/property/{{ property.id }}"
hx-target="#tema-property-{{ property.id }}"
hx-swap="outerHTML">
<i class="fa fa-check" aria-hidden="true"></i>
</button>
<button title="Descarta els canvis"
class="text-sm text-beige mx-1"
hx-get="/api/tema/{{ tema.id }}/property/{{ property.id }}"
hx-target="#tema-property-{{ property.id }}"
hx-swap="outerHTML">
<i class="fa fa-times" aria-hidden="true"></i>
</button>
</form>

View File

@@ -13,12 +13,14 @@
<div class="grow"></div> <div class="grow"></div>
<div class="m-2 text-sm text-beige"> <div class="m-2 text-sm text-beige">
<button title="Modifica l'enllaç" <button title="Modifica l'enllaç"
class="mx-1"
hx-get="/api/tema/{{ tema.id }}/editor/link/{{ link.id }}" hx-get="/api/tema/{{ tema.id }}/editor/link/{{ link.id }}"
hx-target="#tema-link-{{ link.id }}" hx-target="#tema-link-{{ link.id }}"
hx-swap="outerHTML"> hx-swap="outerHTML">
<i class="fa fa-pencil" aria-hidden="true"></i> <i class="fa fa-pencil" aria-hidden="true"></i>
</button> </button>
<button title="Esborra l'enllaç" <button title="Esborra l'enllaç"
class="mx-1"
hx-delete="/api/tema/{{ tema.id }}/link/{{ link.id }}" hx-delete="/api/tema/{{ tema.id }}/link/{{ link.id }}"
hx-target="#tema-link-{{ link.id }}" hx-target="#tema-link-{{ link.id }}"
hx-swap="outerHTML"> hx-swap="outerHTML">

View File

@@ -1,9 +1,6 @@
{% if logged_in or tema.links %} {% if logged_in or tema.links %}
<h4 class="text-xl text-beige">Enllaços</h4> <h4 class="text-xl text-beige">Enllaços</h4>
<hr class="h-px mt-1 mb-3 bg-beige border-0"> <hr class="h-px mt-1 mb-3 bg-beige border-0">
{% endif %}
{% if tema.links %}
<ul class="flex flex-col justify-center" <ul class="flex flex-col justify-center"
id="new-link-target"> id="new-link-target">
{% for link in tema.links %} {% for link in tema.links %}

View File

@@ -4,19 +4,17 @@
{% include "fragments/tema/title.html" %} {% include "fragments/tema/title.html" %}
<div class="text-left"> <div class="text-left">
<div id="tema-{{ tema.id }}-score"
hx-get="/api/tema/{{ tema.id }}/score"
hx-trigger="load, reload-tema-{{ tema.id }}-score from:body"
hx-swap="innerHTML"
>
</div>
{% 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" %}
<!-- PROPERTIES -->
{% if tema.properties %}
<h4 class="text-xl mt-3 text-beige">Informació</h4>
<hr class="h-px mt-1 mb-3 bg-beige border-0">
{% for property in tema.properties %}
<p>
<i>{{ property.field.value.capitalize() }}</i>: {{ property.value }}
</p>
{% endfor %}
{% endif %}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,24 @@
{% if logged_in or tema.properties %}
<h4 class="text-xl mt-3 text-beige">Informació</h4>
<hr class="h-px mt-1 mb-3 bg-beige border-0">
<ul class="flex flex-col"
id="new-property-target">
{% for property in tema.properties %}
{% include "fragments/tema/property.html" %}
{% endfor %}
</ul>
{% endif %}
{% if logged_in %}
<div class="flex flex-row my-2 justify-end">
<button title="Afegeix informació"
class="text-sm text-beige text-right"
hx-post="/api/tema/{{ tema.id }}/property"
hx-target="#new-property-target"
hx-swap="beforeend transition:true">
<i class="fa fa-plus" aria-hidden="true"></i>
Afegeix informació
</button>
</div>
{% endif %}

View File

@@ -0,0 +1,27 @@
<div id="tema-property-{{ property.id }}"
class="flex flex-row">
<div class="px-2">
<i>{{ property.field.value.capitalize() }}:</i>
</div>
<div>
{{ property.value }}
</div>
{% if logged_in %}
<div class="grow"></div>
<button title="Modifica la informació"
class="text-sm text-beige mx-1"
hx-get="/api/tema/{{ tema.id }}/editor/property/{{ property.id }}"
hx-target="#tema-property-{{ property.id }}"
hx-swap="outerHTML">
<i class="fa fa-pencil" aria-hidden="true"></i>
</button>
<button title="Esborra la informació"
class="text-sm text-beige mx-1"
hx-delete="/api/tema/{{ tema.id }}/property/{{ property.id }}"
hx-target="#tema-property-{{ property.id }}"
hx-swap="outerHTML">
<i class="fa fa-times" aria-hidden="true"></i>
</button>
{% endif %}
</div>

View File

@@ -0,0 +1,15 @@
{% set score = tema.score() %}
{% if score %}
<h4 class="text-xl text-beige">Partitura</h4>
<hr class="h-px mt-1 mb-3 bg-beige border-0">
{% if score.link_type == LinkType.PDF %}
{% set pdf_url = score.url %}
{% include "fragments/pdf_viewer.html" %}
{% elif score.link_type == LinkType.IMAGE %}
<div class="flex justify-center">
<img class="m-2"
src="{{ score.url }}"
/>
</div>
{% endif %}
{% endif %}

View File

@@ -3,11 +3,18 @@
<h3 class="text-3xl p-4">{{ tema.title }}</h3> <h3 class="text-3xl p-4">{{ tema.title }}</h3>
{% if logged_in %} {% if logged_in %}
<button title="Canvia el títol" <button title="Canvia el títol"
class="text-beige text-2xl" class="text-beige text-2xl mx-2"
hx-get="/api/tema/{{ tema.id }}/editor/title" hx-get="/api/tema/{{ tema.id }}/editor/title"
hx-target="#tema-title" hx-target="#tema-title"
hx-swap="outerHTML"> hx-swap="outerHTML">
<i class="fa fa-pencil" aria-hidden="true"></i> <i class="fa fa-pencil" aria-hidden="true"></i>
</button> </button>
{% include "fragments/tema/visibility.html" %}
<button title="Canvia el títol"
class="text-beige text-2xl mx-2"
hx-delete="/api/tema/{{ tema.id }}"
hx-swap="outerHTML">
<i class="fa fa-times" aria-hidden="true"></i>
</button>
{% endif %} {% endif %}
</div> </div>

View File

@@ -0,0 +1,15 @@
{% if not tema.hidden %}
<button title="Tema visible (amaga'l)"
class="text-beige text-2xl mx-2"
hx-put="/api/tema/{{ tema.id }}/invisible"
hx-swap="outerHTML">
<i class="fa fa-eye" aria-hidden="true"></i>
</button>
{% else %}
<button title="Tema invisible (mostra'l)"
class="text-beige text-2xl mx-2"
hx-put="/api/tema/{{ tema.id }}/visible"
hx-swap="outerHTML">
<i class="fa fa-eye-slash" aria-hidden="true"></i>
</button>
{% endif %}

View File

@@ -1,12 +1,8 @@
{% include "fragments/menu.html" %} {% include "fragments/menu.html" %}
<div class="p-12 text-center flex flex-col items-center justify-center"> <div class="p-12 text-center flex flex-col items-center justify-center">
<h3 class="text-3xl text-beige p-4">Temes</h3> <h3 class="text-3xl text-beige p-4">Temes</h3>
<button title="Afegeix un tema" <input id="query-input"
class="text-beige m-2"> type="text" name="query" value="{{ query }}"
<i class="fa fa-plus" aria-hidden="true"></i>
Afegeix un tema
</button>
<input type="text" name="query" value=""
placeholder="Busca una tema..." placeholder="Busca una tema..."
class="rounded class="rounded
text-yellow-50 bg-brown text-yellow-50 bg-brown
@@ -15,10 +11,16 @@
hx-get="/api/temes/busca" hx-get="/api/temes/busca"
hx-trigger="revealed, keyup delay:500ms changed" hx-trigger="revealed, keyup delay:500ms changed"
hx-target="#search-results"> hx-target="#search-results">
<div class="flex justify-center"> {% if logged_in %}
<div id="search-results" <button title="Afegeix un tema"
class="m-4 max-w-5xl class="text-beige m-2"
flex flex-wrap items-center justify-center"> hx-post="/api/tema"
</div> hx-include="[name=query]"
</div> 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> </div>

View File

@@ -1,44 +0,0 @@
<div class="flex-none px-4 py-2 w-80 h-80 m-2 border-solid border rounded-md bg-yellow-50 text-brown">
<div class="flex flex-col h-full">
<div class="text-xl text-beige">
<a href="/tema/{{ tema.id }}">
{{ tema.title }}
</a>
</div>
<div class="grow"></div>
<div class="flex flex-row">
</div>
<ul class="flex flex-row items-center justify-center">
{% if tema.links %}
{% for link in tema.links %}
<li class="p-2 m-2 text-beige border border-beige rounded-md">
<a href="{{ link.url }}" target="_blank">
{% if link.content_type == ContentType.AUDIO %}
{% if link.link_type == LinkType.SPOTIFY %}
{% include "icons/spotify.svg" %}
{% elif link.link_type == LinkType.YOUTUBE %}
{% include "icons/youtube.svg" %}
{% else %}
{% include "icons/notes.svg" %}
{% endif %}
{% elif link.content_type == ContentType.PARTITURA %}
{% if link.link_type == LinkType.PDF %}
{% include "icons/pdf.svg" %}
{% else %}
{% include "icons/link.svg" %}
{% endif %}
{% else %}
{% include "icons/link.svg" %}
{% endif %}
</a>
</li>
{% endfor %}
{% endif %}
</ul>
<!-- {% if logged_in %} -->
<!-- <div> -->
<!-- Edita -->
<!-- </div> -->
<!-- {% endif %} -->
</div>
</div>

View File

@@ -0,0 +1,27 @@
<ul class="flex flex-row items-center">
{% if tema.links %}
{% for link in tema.links %}
<li class="px-2 text-beige">
<a href="{{ link.url }}" target="_blank">
{% if link.content_type == ContentType.AUDIO %}
{% if link.link_type == LinkType.SPOTIFY %}
<i class="fa fa-music" aria-hidden="true"></i>
{% elif link.link_type == LinkType.YOUTUBE %}
<i class="fa fa-play" aria-hidden="true"></i>
{% else %}
<i class="fa fa-music" aria-hidden="true"></i>
{% endif %}
{% elif link.content_type == ContentType.PARTITURA %}
{% if link.link_type == LinkType.PDF %}
<i class="fa fa-file" aria-hidden="true"></i>
{% else %}
<i class="fa fa-link" aria-hidden="true"></i>
{% endif %}
{% else %}
<i class="fa fa-link" aria-hidden="true"></i>
{% endif %}
</a>
</li>
{% endfor %}
{% endif %}
</ul>

View File

@@ -0,0 +1,19 @@
<table id="search-results"
class="text-left min-w-full w-full">
<tr class="border-b border-beige">
<td class="font-bold py-2 px-4">Nom</td>
<td class="font-bold py-2 px-4">Enllaços</td>
</tr>
{% for tema in temes %}
<tr class="border-b border-beige">
<td class="py-2 px-4">
<a href="/tema/{{ tema.id }}">
{{ tema.title }}
</a>
</td>
<td class="py-2 px-4">
{% include "fragments/temes/result_links.html" %}
</td>
</tr>
{% endfor %}
</table>

View File

@@ -5,6 +5,15 @@ from folkugat_web.log import logger
DB_DIR = Path(os.getenv("DB_DIR", "/home/marc/tmp/folkugat")).resolve() DB_DIR = Path(os.getenv("DB_DIR", "/home/marc/tmp/folkugat")).resolve()
DB_FILE = DB_DIR / "folkugat.db" DB_FILE = DB_DIR / "folkugat.db"
DB_TEMES_DIR = DB_DIR / "temes"
logger.info(f"Using DB_DIR: {DB_DIR}") logger.info(f"Using DB_DIR: {DB_DIR}")
# Files db
DB_FILES_DIR = DB_DIR / "fitxer"
DB_FILES_DIR.mkdir(exist_ok=True)
DB_FILES_URL = "/db/fitxer"
_MB = 1024 * 1024
FILE_MAX_SIZE = 10 * _MB

View File

@@ -8,11 +8,10 @@ from .write import insert_tema
def create_db(con: Optional[Connection] = None): def create_db(con: Optional[Connection] = None):
with get_connection(con) as con: with get_connection(con) as con:
drop_temes_table(con)
create_temes_table(con) create_temes_table(con)
for tema in data.TEMES: # for tema in data.TEMES:
insert_tema(tema, con) # insert_tema(tema, con)
def drop_temes_table(con: Connection): def drop_temes_table(con: Connection):

View File

@@ -40,7 +40,6 @@ def _get_tema_id_to_ngrams(con: Optional[Connection] = None) -> dict[int, model.
query = """ query = """
SELECT id, ngrams SELECT id, ngrams
FROM temes FROM temes
WHERE hidden = 0
""" """
with get_connection(con) as con: with get_connection(con) as con:
cur = con.cursor() cur = con.cursor()

View File

@@ -42,3 +42,16 @@ def update_tema(tema: model.Tema, con: Optional[Connection] = None):
cur.execute(query, data) cur.execute(query, data)
evict_tema_id_to_ngrams_cache() evict_tema_id_to_ngrams_cache()
return return
def delete_tema(tema_id: int, con: Optional[Connection] = None):
query = """
DELETE FROM temes
WHERE id = :id
"""
data = dict(id=tema_id)
with get_connection(con) as con:
cur = con.cursor()
cur.execute(query, data)
evict_tema_id_to_ngrams_cache()
return

View File

@@ -86,6 +86,9 @@ def link(request: Request, logged_in: bool, tema_id: int, link_id: int):
"link": link, "link": link,
"LinkType": model.LinkType, "LinkType": model.LinkType,
"ContentType": model.ContentType, "ContentType": model.ContentType,
},
headers={
"HX-Trigger": f"reload-tema-{tema_id}-score"
} }
) )
@@ -110,14 +113,46 @@ def link_editor(request: Request, logged_in: bool, tema_id: int, link_id: int):
) )
def link_icon( def link_editor_url(request: Request, logged_in: bool, tema_id: int, link_id: int):
request: Request, tema = temes_q.get_tema_by_id(tema_id)
logged_in: bool, if tema is None:
tema_id: int, raise ValueError(f"No tune exists for tema_id: {tema_id}")
link_id: int, link = tema.links.get(link_id)
url: str, return templates.TemplateResponse(
content_type: model.ContentType, "fragments/tema/editor/link_url.html",
): {
"request": request,
"logged_in": logged_in,
"tema": tema,
"link_id": link_id,
"link": link,
"LinkType": model.LinkType,
"ContentType": model.ContentType,
}
)
def link_editor_file(request: Request, logged_in: bool, tema_id: int, link_id: int):
tema = temes_q.get_tema_by_id(tema_id)
if tema is None:
raise ValueError(f"No tune exists for tema_id: {tema_id}")
link = tema.links.get(link_id)
return templates.TemplateResponse(
"fragments/tema/editor/link_file.html",
{
"request": request,
"logged_in": logged_in,
"tema": tema,
"link_id": link_id,
"link": link,
"LinkType": model.LinkType,
"ContentType": model.ContentType,
}
)
def link_icon(request: Request, logged_in: bool, tema_id: int, link_id: int, url: str,
content_type: model.ContentType):
link = model.Link( link = model.Link(
id=link_id, id=link_id,
content_type=content_type, content_type=content_type,
@@ -136,3 +171,67 @@ def link_icon(
"ContentType": model.ContentType, "ContentType": model.ContentType,
} }
) )
def score(request: Request, logged_in: bool, tema_id: int):
tema = temes_q.get_tema_by_id(tema_id)
if tema is None:
raise ValueError(f"No tune exists for tema_id: {tema_id}")
return templates.TemplateResponse(
"fragments/tema/score.html",
{
"request": request,
"logged_in": logged_in,
"tema": tema,
"LinkType": model.LinkType,
}
)
def property_(request: Request, logged_in: bool, tema_id: int, property_id: int):
tema = temes_q.get_tema_by_id(tema_id)
if tema is None:
raise ValueError(f"No tune exists for tema_id: {tema_id}")
prop = tema.properties.get(property_id)
return templates.TemplateResponse(
"fragments/tema/property.html",
{
"request": request,
"logged_in": logged_in,
"tema": tema,
"property_id": property_id,
"property": prop,
"PropertyField": model.PropertyField,
}
)
def property_editor(request: Request, logged_in: bool, tema_id: int, property_id: int):
tema = temes_q.get_tema_by_id(tema_id)
if tema is None:
raise ValueError(f"No tune exists for tema_id: {tema_id}")
prop = tema.properties.get(property_id)
return templates.TemplateResponse(
"fragments/tema/editor/property.html",
{
"request": request,
"logged_in": logged_in,
"tema": tema,
"property_id": property_id,
"property": prop,
"PropertyField": model.PropertyField,
}
)
def visibility(request: Request, logged_in: bool, tema: model.Tema):
return templates.TemplateResponse(
"fragments/tema/visibility.html",
{
"request": request,
"logged_in": logged_in,
"tema": tema,
}
)

View File

@@ -6,36 +6,30 @@ from folkugat_web.services.temes import search as temes_s
from folkugat_web.templates import templates from folkugat_web.templates import templates
def temes_pagina(request: Request, logged_in: bool): def temes_pagina(request: Request, logged_in: bool, query: str):
return templates.TemplateResponse( return templates.TemplateResponse(
"fragments/temes/pagina.html", "fragments/temes/pagina.html",
{ {
"request": request, "request": request,
"logged_in": logged_in, "logged_in": logged_in,
"query": query,
"Pages": Pages, "Pages": Pages,
"menu_selected_id": Pages.Temes, "menu_selected_id": Pages.Temes,
} }
) )
def temes_busca_result(request: Request, tema: model.Tema, logged_in: bool): def temes_busca(request: Request, query: str, logged_in: bool):
temes = temes_s.busca_temes(query=query, hidden=logged_in)
return templates.TemplateResponse( return templates.TemplateResponse(
"fragments/temes/result.html", "fragments/temes/results.html",
{ {
"request": request, "request": request,
"logged_in": logged_in, "logged_in": logged_in,
"tema": tema, "temes": temes,
"LinkType": model.LinkType, "LinkType": model.LinkType,
"ContentType": model.ContentType, "ContentType": model.ContentType,
} }
).body.decode('utf-8')
def temes_busca(request: Request, query: str, logged_in: bool):
temes = temes_s.busca_temes(query)
return '\n'.join(
[temes_busca_result(request, tema, logged_in)
for tema in temes]
) )

View File

@@ -5,9 +5,9 @@ from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from folkugat_web import log from folkugat_web import log
from folkugat_web.config import directories, db
from folkugat_web.dal.sql.ddl import create_db
from folkugat_web.api import router from folkugat_web.api import router
from folkugat_web.config import db, directories
from folkugat_web.dal.sql.ddl import create_db
@asynccontextmanager @asynccontextmanager
@@ -43,7 +43,7 @@ def get_app() -> FastAPI:
app.include_router(router) app.include_router(router)
app.mount("/static", StaticFiles(directory=directories.STATIC_DIR), name="static") app.mount("/static", StaticFiles(directory=directories.STATIC_DIR), name="static")
app.mount("/db/temes", StaticFiles(directory=db.DB_TEMES_DIR), name="temes") app.mount(db.DB_FILES_URL, StaticFiles(directory=db.DB_FILES_DIR), name="fitxers")
return app return app

View File

@@ -123,3 +123,6 @@ class Tema:
def with_ngrams(self): def with_ngrams(self):
self.compute_ngrams() self.compute_ngrams()
return self return self
def score(self) -> Optional[Link]:
return next(filter(lambda l: l.content_type is ContentType.PARTITURA, self.links), None)

View File

@@ -0,0 +1,58 @@
import mimetypes
import re
import uuid
from pathlib import Path
import magic
from fastapi import HTTPException, UploadFile
from folkugat_web.config import db
async def get_mimetype(upload_file: UploadFile) -> str:
info = magic.detect_from_content(await upload_file.read(2048))
await upload_file.seek(0)
return info.mime_type
ACCEPTED_MIMETYPES = [
re.compile(r"image/.+"),
re.compile(r".+/pdf"),
]
def check_mimetype(mimetype: str) -> None:
if not any(regex.match(mimetype) for regex in ACCEPTED_MIMETYPES):
raise HTTPException(status_code=400, detail=f"Unsupported file type: {mimetype}")
def get_db_file_path(filepath: Path) -> str:
return f"{db.DB_FILES_URL}/{filepath.relative_to(db.DB_FILES_DIR)}"
async def store_file(tema_id: int, upload_file: UploadFile) -> str:
if not upload_file.size:
raise HTTPException(status_code=400, detail="Couldn't find out the size of the file")
if upload_file.size > db.FILE_MAX_SIZE:
raise HTTPException(
status_code=400,
detail=f"The uploaded file is too big (max size = {db.FILE_MAX_SIZE} bytes)",
)
mimetype = await get_mimetype(upload_file)
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
with open(filepath, "wb") as f:
f.write(await upload_file.read())
return get_db_file_path(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()]

View File

@@ -1,4 +1,7 @@
import functools
import time import time
import typing
from collections.abc import Callable, Iterable, Iterator
import Levenshtein import Levenshtein
from folkugat_web.config import search as config from folkugat_web.config import search as config
@@ -18,9 +21,10 @@ def get_query_word_similarity(query_word: str, text_ngrams: model.NGrams) -> sea
candidate_ngrams = ((m, ngram) candidate_ngrams = ((m, ngram)
for m, ngrams in map(lambda i: (i, text_ngrams.get(i, [])), ns) for m, ngrams in map(lambda i: (i, text_ngrams.get(i, [])), ns)
for ngram in ngrams) for ngram in ngrams)
return min(search_model.SearchMatch(distance=Levenshtein.distance(query_word, ngram)/m, return min((search_model.SearchMatch(distance=Levenshtein.distance(query_word, ngram)/m,
ngram=ngram) ngram=ngram)
for m, ngram in candidate_ngrams) for m, ngram in candidate_ngrams),
default=search_model.SearchMatch(distance=float("inf"), ngram=""))
def get_query_similarity(query: str, ngrams: model.NGrams) -> search_model.SearchMatch: def get_query_similarity(query: str, ngrams: model.NGrams) -> search_model.SearchMatch:
@@ -44,14 +48,24 @@ def build_result(query: str, entry: tuple[int, model.NGrams]) -> search_model.Qu
) )
def busca_temes(query: str) -> list[model.Tema]: T = typing.TypeVar("T")
def _thread(it: Iterable[T], *funcs: Callable[[Iterable], Iterable]) -> Iterable:
return functools.reduce(lambda i, fn: fn(i), funcs, it)
def busca_temes(query: str, hidden: bool = False, limit: int = 20, offset: int = 0) -> list[model.Tema]:
t0 = time.time() t0 = time.time()
with get_connection() as con: with get_connection() as con:
tema_id_to_ngrams = temes_q.get_tema_id_to_ngrams(con) result = _thread(
search_results = (build_result(query, entry) for entry in tema_id_to_ngrams.items()) temes_q.get_tema_id_to_ngrams(con).items(),
filtered_results = filter(lambda qr: qr.distance <= config.SEARCH_DISTANCE_THRESHOLD, search_results) lambda tema_id_to_ngrams: (build_result(query, entry) for entry in tema_id_to_ngrams),
# filtered_results = filter(lambda qr: True, search_results) lambda results: filter(lambda qr: qr.distance <= config.SEARCH_DISTANCE_THRESHOLD, results),
sorted_results = sorted(filtered_results, key=lambda qr: qr.distance) lambda results: sorted(results, key=lambda qr: qr.distance),
sorted_temes = list(filter(None, map(lambda qr: temes_q.get_tema_by_id(qr.id, con), sorted_results))) lambda results: filter(None, map(lambda qr: temes_q.get_tema_by_id(qr.id, con), results)),
lambda results: filter(lambda t: hidden or not t.hidden, results),
)
result = list(result)[offset:offset + limit]
logger.info(f"Search time: { int((time.time() - t0) * 1000) } ms") logger.info(f"Search time: { int((time.time() - t0) * 1000) } ms")
return sorted_temes return result

View File

@@ -6,6 +6,14 @@ from folkugat_web.dal.sql.temes import write as temes_w
from folkugat_web.model import temes as model from folkugat_web.model import temes as model
def create_tema(title: str = "") -> model.Tema:
return temes_w.insert_tema(tema=model.Tema(title=title))
def delete_tema(tema_id: int) -> None:
tema = temes_w.delete_tema(tema_id=tema_id)
def update_title(tema_id: int, title: str) -> model.Tema: def update_title(tema_id: int, title: str) -> model.Tema:
with get_connection() as con: with get_connection() as con:
tema = temes_q.get_tema_by_id(tema_id=tema_id, con=con) tema = temes_q.get_tema_by_id(tema_id=tema_id, con=con)
@@ -98,3 +106,53 @@ def add_link(tema_id: int) -> model.Tema:
temes_w.update_tema(tema=tema, con=con) temes_w.update_tema(tema=tema, con=con)
return tema return tema
def update_property(tema_id: int, property_id: int, prop: model.Property) -> model.Tema:
with get_connection() as con:
tema = temes_q.get_tema_by_id(tema_id=tema_id, con=con)
if tema is None:
raise ValueError(f"No tune found with tema_id = {tema_id}!")
tema.properties.replace(property_id, prop)
temes_w.update_tema(tema=tema, con=con)
return tema
def delete_property(tema_id: int, property_id: int) -> model.Tema:
with get_connection() as con:
tema = temes_q.get_tema_by_id(tema_id=tema_id, con=con)
if tema is None:
raise ValueError(f"No tune found with tema_id = {tema_id}!")
tema.properties.delete(property_id)
temes_w.update_tema(tema=tema, con=con)
return tema
def add_property(tema_id: int) -> model.Tema:
with get_connection() as con:
tema = temes_q.get_tema_by_id(tema_id=tema_id, con=con)
if tema is None:
raise ValueError(f"No tune found with tema_id = {tema_id}!")
tema.properties.append(model.Property(
id=None,
field=model.PropertyField.AUTOR,
value="",
))
temes_w.update_tema(tema=tema, con=con)
return tema
def set_visibility(tema_id: int, hidden: bool) -> model.Tema:
with get_connection() as con:
tema = temes_q.get_tema_by_id(tema_id=tema_id, con=con)
if tema is None:
raise ValueError(f"No tune found with tema_id = {tema_id}!")
tema.hidden = hidden
temes_w.update_tema(tema=tema, con=con)
return tema