modificato deploy verso contabo
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
FTP_PASSWORD=cantiDavid@72
|
||||
FTP_THREADS=30
|
||||
FTP_THREADS=100
|
||||
BASE_HREF=/ionic/
|
||||
CONTACT_EMAIL=info@canticristiani.it
|
||||
API_AUTH_USER=canti
|
||||
API_AUTH_PASS=antani2026
|
||||
VPS_HOST=185.193.67.105
|
||||
VPS_USER=root
|
||||
VPS_PATH=/var/docker/canticristiani-pwa/html
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
#!/bin/bash
|
||||
echo "Creazione del backup del repository Git locale..."
|
||||
zip -r git_backup.zip . -x "node_modules/*" ".angular/*" "www/*" "platforms/*" "plugins/*" ".ionic/*" "dist/*" ".nx/*" "*/.DS_Store" "git_backup.zip" "backup.sh"
|
||||
echo "Backup completato! Creato il file git_backup.zip."
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"id_comunita": "123456",
|
||||
"nome_comunita": "Comunità Test 123456",
|
||||
"canti": [378, 379, 380]
|
||||
}
|
||||
+40
-12
@@ -8,19 +8,41 @@ else
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Configurazione Parallelismo ---
|
||||
THREADS=${1:-${FTP_THREADS:-100}}
|
||||
# --- Configurazione Target e Parallelismo ---
|
||||
TARGET="tophost"
|
||||
THREADS=""
|
||||
|
||||
if [ "$1" = "contabo" ] || [ "$1" = "tophost" ]; then
|
||||
TARGET="$1"
|
||||
THREADS="$2"
|
||||
else
|
||||
# Se il primo argomento è un numero, indica il numero di thread per tophost
|
||||
if [[ "$1" =~ ^[0-9]+$ ]]; then
|
||||
THREADS="$1"
|
||||
fi
|
||||
fi
|
||||
|
||||
THREADS=${THREADS:-${FTP_THREADS:-100}}
|
||||
export FTP_THREADS=$THREADS
|
||||
|
||||
# --- Configurazione FTP per ROOT www.canticristiani.it ---
|
||||
FTP_HOST="ftp.canticristiani.it"
|
||||
FTP_USER="canticristiani.it"
|
||||
FTP_PASS="$FTP_PASSWORD"
|
||||
REMOTE_DIR="" # Carica nella root della cartella FTP
|
||||
echo "🎯 Target di deploy: $TARGET"
|
||||
|
||||
if [ -z "$FTP_PASS" ]; then
|
||||
echo "❌ Errore: FTP_PASSWORD non definita nel file .env"
|
||||
exit 1
|
||||
# --- Validazione configurazione basata sul target ---
|
||||
if [ "$TARGET" = "tophost" ]; then
|
||||
FTP_HOST="ftp.canticristiani.it"
|
||||
FTP_USER="canticristiani.it"
|
||||
FTP_PASS="$FTP_PASSWORD"
|
||||
REMOTE_DIR=""
|
||||
|
||||
if [ -z "$FTP_PASS" ]; then
|
||||
echo "❌ Errore: FTP_PASSWORD non definita nel file .env"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
if [ -z "$VPS_HOST" ] || [ -z "$VPS_USER" ] || [ -z "$VPS_PATH" ]; then
|
||||
echo "❌ Errore: VPS_HOST, VPS_USER o VPS_PATH non definiti nel file .env"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Aggiornamento Versione ---
|
||||
@@ -61,5 +83,11 @@ BUILD_TIMESTAMP=$(date +%s)000
|
||||
echo "{\"version\":\"$VERSION\",\"buildTime\":$BUILD_TIMESTAMP}" > www/version.json
|
||||
echo "📄 Generato version.json: v$VERSION (timestamp: $BUILD_TIMESTAMP)"
|
||||
|
||||
# --- Upload via FTP ---
|
||||
python3 scratch/deploy_ftp.py
|
||||
# --- Upload ---
|
||||
if [ "$TARGET" = "tophost" ]; then
|
||||
echo "🚀 Upload via FTP su Tophost in corso..."
|
||||
python3 scratch/deploy_ftp.py
|
||||
else
|
||||
echo "🚀 Upload via SSH/rsync su Contabo ($VPS_HOST) in corso..."
|
||||
rsync -avz --delete www/ "$VPS_USER@$VPS_HOST:$VPS_PATH"
|
||||
fi
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,84 @@
|
||||
# Specifiche Tecniche: Integrazione Servizio `/miei` con PWA Canti
|
||||
|
||||
Questo documento descrive le specifiche dell'endpoint `/miei` implementato sul server Node.js e fornisce le linee guida per implementare la sincronizzazione e l'invio delle playlist locali e dei canti personalizzati ("canti miei") dalla PWA.
|
||||
|
||||
---
|
||||
|
||||
## 🌐 Dettagli dell'Endpoint `/miei`
|
||||
|
||||
- **Metodo HTTP**: `POST`
|
||||
- **URL di Sviluppo**: `http://localhost:3000/miei` (o l'IP/porta del tuo server in produzione)
|
||||
- **Content-Type**: `application/json`
|
||||
- **Limite Dimensione Body**: `10MB` (per supportare liste e testi completi di canti)
|
||||
|
||||
### 🔑 Identificazione Utente (`uid`)
|
||||
L'endpoint richiede l'identificativo unico dell'utente (`uid`). Può essere trasmesso in tre modi (in ordine di priorità):
|
||||
1. **Header HTTP**: `x-user-uid` (scelta consigliata) o `uid`
|
||||
2. **Query Parameter**: `?uid=<user_uid>`
|
||||
3. **Wrapper nel Body**: Se il corpo è un oggetto del tipo `{ "uid": "...", "canti": [...] }`
|
||||
|
||||
> [!WARNING]
|
||||
> L'UID dell'utente deve essere una stringa valida e sicura. Sono accettati solo caratteri alfanumerici, trattini e trattini bassi (`^[a-zA-Z0-9_\-]+$`). Qualsiasi tentativo di path traversal (es. contenente `.` o `/`) restituirà un errore `400 Bad Request`.
|
||||
|
||||
---
|
||||
|
||||
## 📦 Formato dei Dati Richiesto (Payload)
|
||||
|
||||
L'endpoint si aspetta di ricevere un **array JSON** contenente la lista dei canti dell'utente (formato analogo a `canti.json`).
|
||||
|
||||
### Esempio di Payload (Array di Canti)
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id_canti": 9001,
|
||||
"titolo": "Mio Canto Personalizzato 1",
|
||||
"momenti": ["Ingresso"],
|
||||
"periodi": ["Lode"],
|
||||
"testo": "Testo del mio canto personalizzato...\nRit: Alleluia!"
|
||||
},
|
||||
{
|
||||
"id_canti": 9002,
|
||||
"titolo": "Mio Canto Personalizzato 2",
|
||||
"momenti": ["Comunione"],
|
||||
"periodi": [],
|
||||
"testo": "Testo del secondo canto..."
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Comportamento del Server
|
||||
|
||||
1. **Ricezione e Validazione**:
|
||||
- Estrae l'UID ed il payload dei canti.
|
||||
- Valida l'UID (sicurezza path traversal) e verifica che il payload sia un array JSON. In caso contrario, risponde con `400 Bad Request`.
|
||||
2. **Logica di Backup**:
|
||||
- Se nella cartella `data/` del server esiste già un file associato all'utente (`data/<uid>.json`), ne crea automaticamente una copia di backup rinominandola o copiandola in `data/<uid>.bak.json`.
|
||||
3. **Salvataggio**:
|
||||
- Scrive il nuovo JSON in `data/<uid>.json`.
|
||||
- Risponde con `200 OK` e un JSON di conferma:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "File salvato con successo.",
|
||||
"uid": "utente_test_123",
|
||||
"backupCreated": true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Requisiti per l'Implementazione nella PWA (Client Locale Mac M1)
|
||||
|
||||
L'istanza di Antigravity che lavora sul codice della PWA locale dovrà implementare una funzione di sincronizzazione (es. `syncLocalDataToServer`) che esegua i seguenti passi:
|
||||
|
||||
1. **Recupero dei dati locali**:
|
||||
- Estrarre le playlist locali e i canti personalizzati creati dall'utente (solitamente salvati in `localStorage`, `IndexedDB`, o altro database locale).
|
||||
2. **Normalizzazione e Formattazione**:
|
||||
- Formattare e unire queste informazioni in un unico array JSON strutturato con i campi chiave per ciascun canto (`id_canti`, `titolo`, `momenti`, `periodi`, `testo`).
|
||||
3. **Invio al Server**:
|
||||
- Effettuare una chiamata `fetch` (POST) verso l'endpoint `/miei`.
|
||||
- Passare l'UID dell'utente autenticato (es. da Firebase Auth o altro sistema di sessione) tramite l'header `x-user-uid`.
|
||||
4. **Gestione del Feedback**:
|
||||
- Mostrare all'utente una notifica di successo o gestire eventuali errori di rete o validazione.
|
||||
@@ -0,0 +1,54 @@
|
||||
# Piano di Implementazione: Importazione Playlist e Canti della Comunità
|
||||
|
||||
Questo piano descrive i dettagli tecnici per l'aggiunta di una funzione che consente di importare localmente tutte le playlist e i canti personalizzati di una comunità attiva.
|
||||
|
||||
## User Review Required
|
||||
|
||||
> [!IMPORTANT]
|
||||
> L'importazione comporterà il salvataggio dei canti personalizzati della comunità direttamente nei "Miei Canti" dell'utente (evitando duplicati) e la creazione di playlist locali. Le playlist locali create includeranno:
|
||||
> 1. Una playlist principale con il nome della comunità stessa contenente tutti i canti della comunità con le rispettive tonalità, velocità di scorrimento e testi personalizzati/aggiuntivi.
|
||||
> 2. Tutte le singole scalette/playlist della comunità importate come playlist locali indipendenti e modificabili.
|
||||
|
||||
## Proposed Changes
|
||||
|
||||
### Servizi e Logica di Business
|
||||
|
||||
#### [MODIFY] [playlist.service.ts](file:///Users/davidfrassi/SRC/agenti/canti/src/app/services/playlist.service.ts)
|
||||
Aggiungeremo un metodo helper o esporremo le funzioni di salvataggio di massa per le playlist importate, garantendo che le impostazioni dei brani (`songSettings` contenenti tonalità e velocità di scorrimento) siano preservate correttamente.
|
||||
|
||||
### Pagina Impostazioni (Settings Page)
|
||||
|
||||
#### [MODIFY] [settings.page.html](file:///Users/davidfrassi/SRC/agenti/canti/src/app/pages/settings/settings.page.html)
|
||||
Nel gruppo "Comunità", quando una comunità è attiva, aggiungeremo un nuovo pulsante sotto i dettagli della comunità:
|
||||
- **Pulsante**: "Importa Playlist e Canti" con icona `cloud-download-outline`.
|
||||
|
||||
#### [MODIFY] [settings.page.ts](file:///Users/davidfrassi/SRC/agenti/canti/src/app/pages/settings/settings.page.ts)
|
||||
Implementeremo il metodo `importComunitaPlaylists()` che svolgerà i seguenti passaggi:
|
||||
1. **Verifica dello Stato**: Si assicura che una comunità sia attiva e che i dati siano caricati.
|
||||
2. **Importazione dei Canti Personalizzati**:
|
||||
- Scorrerà i canti di `comunitaService.comunitaCantiPersonali()`.
|
||||
- Per ciascun canto, verificherà se è già presente nei "Miei Canti" (tramite `myCantiService.myCanti()`).
|
||||
- Se assente, lo aggiungerà con prefisso ID `my_` ed effettuerà il salvataggio in locale su `MyCantiService`.
|
||||
3. **Creazione della Playlist della Comunità**:
|
||||
- Creerà una playlist avente come nome il nome della comunità stessa.
|
||||
- Popolerà i canti associando sia i canti standard sia i canti personali importati.
|
||||
- Assocerà le impostazioni di tonalità e velocità di scorrimento da `comunitaCantiSettings()`. Per la velocità, si utilizzerà la formula di conversione standard (es. `Math.round(speed / 100)`).
|
||||
4. **Importazione delle Scalette (Playlist) Aggiuntive**:
|
||||
- Per ciascuna scaletta in `comunitaService.comunitaScalette()`, creerà una playlist locale corrispondente.
|
||||
- Per ciascun canto della scaletta, cercherà se esiste un'impostazione in `comunitaCantiSettings()` per impostare tonalità e velocità specifiche all'interno della playlist.
|
||||
5. **Salvataggio e Feedback**:
|
||||
- Salverà tutte le nuove playlist tramite `playlistService.savePlaylist()`.
|
||||
- Mostrerà un alert di conferma iniziale e un toast di successo finale.
|
||||
|
||||
## Verification Plan
|
||||
|
||||
### Manual Verification
|
||||
- Attivare una comunità di prova (es. con codice noto o inserendone uno valido).
|
||||
- Andare nelle impostazioni e cliccare sul nuovo pulsante "Importa Playlist e Canti".
|
||||
- Verificare la comparsa dell'alert di conferma.
|
||||
- Confermare l'importazione.
|
||||
- Verificare tramite Toast e navigando nella home che:
|
||||
- Sia presente una playlist locale con il nome della comunità contenente tutti i canti ordinati e configurati.
|
||||
- Siano presenti le playlist secondarie della comunità convertite in playlist locali.
|
||||
- Le tonalità modificate e le velocità di scorrimento siano state importate correttamente per ciascun canto all'interno delle playlist.
|
||||
- I canti personali/aggiuntivi siano stati importati nella sezione "Miei Canti" e siano visualizzabili.
|
||||
@@ -0,0 +1,13 @@
|
||||
import urllib.request
|
||||
import json
|
||||
|
||||
url = "https://libretto.mmcinet.eu/canti/api/v3/get_all_app_tables?uuid=pwa-cc-uuid&email=&platform=browser&version=1.0&gruppo=123456&all_song=false"
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
|
||||
print("Fetching...")
|
||||
with urllib.request.urlopen(req) as res:
|
||||
data = json.loads(res.read().decode("utf-8"))
|
||||
|
||||
settings = data.get("canti_settings", {}).get("data", [])
|
||||
print(f"Total settings: {len(settings)}")
|
||||
for s in settings[:10]:
|
||||
print(s)
|
||||
@@ -0,0 +1,154 @@
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
from ftplib import FTP, error_perm
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
thread_local = threading.local()
|
||||
|
||||
def load_env():
|
||||
env = {}
|
||||
if os.path.exists('.env'):
|
||||
with open('.env', 'r', encoding='utf-8') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line and not line.startswith('#'):
|
||||
if '=' in line:
|
||||
key, val = line.split('=', 1)
|
||||
env[key.strip()] = val.strip()
|
||||
return env
|
||||
|
||||
def get_ftp_connection(ftp_host, ftp_user, ftp_pass):
|
||||
if not hasattr(thread_local, "ftp") or thread_local.ftp is None:
|
||||
ftp = FTP(ftp_host, timeout=30)
|
||||
ftp.login(ftp_user, ftp_pass)
|
||||
ftp.passive = True
|
||||
thread_local.ftp = ftp
|
||||
return thread_local.ftp
|
||||
|
||||
def close_thread_connection():
|
||||
if hasattr(thread_local, "ftp") and thread_local.ftp is not None:
|
||||
try:
|
||||
thread_local.ftp.quit()
|
||||
except:
|
||||
try:
|
||||
thread_local.ftp.close()
|
||||
except:
|
||||
pass
|
||||
thread_local.ftp = None
|
||||
|
||||
def ensure_remote_dir(ftp, path):
|
||||
parts = path.replace('\\', '/').strip('/').split('/')
|
||||
current = ""
|
||||
for part in parts:
|
||||
if not part:
|
||||
continue
|
||||
current += "/" + part
|
||||
try:
|
||||
ftp.mkd(current)
|
||||
print(f"\nCreated remote dir: {current}")
|
||||
except error_perm:
|
||||
# Directory already exists or permission error (which is normal if it exists)
|
||||
pass
|
||||
|
||||
def upload_file_task(local_path, rel_path, ftp_host, ftp_user, ftp_pass, created_dirs, created_dirs_lock):
|
||||
ftp_path = rel_path.replace('\\', '/')
|
||||
remote_dir = os.path.dirname(ftp_path)
|
||||
|
||||
# Ensure remote directory exists
|
||||
if remote_dir:
|
||||
with created_dirs_lock:
|
||||
if remote_dir not in created_dirs:
|
||||
try:
|
||||
ftp = get_ftp_connection(ftp_host, ftp_user, ftp_pass)
|
||||
ensure_remote_dir(ftp, remote_dir)
|
||||
created_dirs.add(remote_dir)
|
||||
except Exception as e:
|
||||
thread_local.ftp = None
|
||||
try:
|
||||
ftp = get_ftp_connection(ftp_host, ftp_user, ftp_pass)
|
||||
ensure_remote_dir(ftp, remote_dir)
|
||||
created_dirs.add(remote_dir)
|
||||
except Exception as err2:
|
||||
return False, ftp_path, f"Failed directory creation: {err2}"
|
||||
|
||||
# Upload the file
|
||||
for attempt in range(3):
|
||||
try:
|
||||
ftp = get_ftp_connection(ftp_host, ftp_user, ftp_pass)
|
||||
with open(local_path, 'rb') as f:
|
||||
ftp.storbinary(f"STOR {ftp_path}", f)
|
||||
return True, ftp_path, None
|
||||
except Exception as e:
|
||||
thread_local.ftp = None # Force reconnect on next attempt
|
||||
if attempt == 2:
|
||||
return False, ftp_path, str(e)
|
||||
return False, ftp_path, "Unknown error"
|
||||
|
||||
def main():
|
||||
env = load_env()
|
||||
ftp_host = "ftp.canticristiani.it"
|
||||
ftp_user = "canticristiani.it"
|
||||
ftp_pass = env.get("FTP_PASSWORD")
|
||||
|
||||
if not ftp_pass:
|
||||
print("❌ Error: FTP_PASSWORD not found in .env")
|
||||
sys.exit(1)
|
||||
|
||||
local_dir = "www"
|
||||
if not os.path.isdir(local_dir):
|
||||
print(f"❌ Error: Local dir '{local_dir}' not found.")
|
||||
sys.exit(1)
|
||||
|
||||
files_to_upload = []
|
||||
for root, dirs, files in os.walk(local_dir):
|
||||
for file in files:
|
||||
local_path = os.path.join(root, file)
|
||||
rel_path = os.path.relpath(local_path, local_dir)
|
||||
files_to_upload.append((local_path, rel_path))
|
||||
|
||||
total = len(files_to_upload)
|
||||
uploaded = 0
|
||||
errors = []
|
||||
|
||||
created_dirs = set()
|
||||
created_dirs_lock = threading.Lock()
|
||||
progress_lock = threading.Lock()
|
||||
|
||||
# Thread limit: default to 100 as requested
|
||||
num_threads = int(os.environ.get("FTP_THREADS", env.get("FTP_THREADS", "100")))
|
||||
print(f"🚀 Starting parallel upload of {total} files using {num_threads} threads...")
|
||||
|
||||
def run_task(item):
|
||||
nonlocal uploaded
|
||||
local_path, rel_path = item
|
||||
success, ftp_path, err_msg = upload_file_task(
|
||||
local_path, rel_path, ftp_host, ftp_user, ftp_pass, created_dirs, created_dirs_lock
|
||||
)
|
||||
with progress_lock:
|
||||
uploaded += 1
|
||||
print(f"\rUploading [{uploaded}/{total}] {ftp_path}...", end="", flush=True)
|
||||
if not success:
|
||||
errors.append((ftp_path, err_msg))
|
||||
|
||||
with ThreadPoolExecutor(max_workers=num_threads) as executor:
|
||||
futures = [executor.submit(run_task, item) for item in files_to_upload]
|
||||
for future in as_completed(futures):
|
||||
pass
|
||||
|
||||
# Cleanup connections
|
||||
with ThreadPoolExecutor(max_workers=num_threads) as cleanup_executor:
|
||||
cleanups = [cleanup_executor.submit(close_thread_connection) for _ in range(num_threads)]
|
||||
for future in as_completed(cleanups):
|
||||
pass
|
||||
|
||||
if errors:
|
||||
print(f"\n⚠️ Deploy completed with {len(errors)} errors:")
|
||||
for path, err in errors:
|
||||
print(f" {path}: {err}")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("\n\n✅ Deploy completed successfully with 100% files uploaded without errors!")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,34 @@
|
||||
import urllib.request
|
||||
import json
|
||||
|
||||
url = "https://libretto.mmcinet.eu/canti/api/v3/get_all_app_tables?uuid=pwa-cc-uuid&email=&platform=browser&version=1.0&gruppo=123456&all_song=false"
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
|
||||
print("Fetching...")
|
||||
with urllib.request.urlopen(req) as res:
|
||||
data = json.loads(res.read().decode("utf-8"))
|
||||
|
||||
canti_pers = data.get("canti_personali", {}).get("data", [])
|
||||
states = {}
|
||||
for cp in canti_pers:
|
||||
state = cp.get("stato")
|
||||
states[state] = states.get(state, 0) + 1
|
||||
|
||||
print("States found in canti_personali:")
|
||||
for state, count in states.items():
|
||||
print(f"- State {state}: {count} songs")
|
||||
|
||||
# Let's also check if there are any duplicate IDs between canti and canti_personali
|
||||
canti_ids = {c["id_canti"] for c in data.get("canti", {}).get("data", [])}
|
||||
canti_pers_ids = {cp["id_canti"] for cp in canti_pers}
|
||||
intersection = canti_ids.intersection(canti_pers_ids)
|
||||
print(f"Number of duplicate song IDs between global catalog and canti_personali: {len(intersection)}")
|
||||
if len(intersection) > 0:
|
||||
print("First 5 duplicate IDs:")
|
||||
print(list(intersection)[:5])
|
||||
# Find one example of duplicate
|
||||
dup_id = list(intersection)[0]
|
||||
global_dup = next(c for c in data["canti"]["data"] if c["id_canti"] == dup_id)
|
||||
pers_dup = next(cp for cp in canti_pers if cp["id_canti"] == dup_id)
|
||||
print(f"\nExample Duplicate (ID {dup_id}):")
|
||||
print(f"Global: Title='{global_dup.get('titolo')}', Stato={global_dup.get('stato')}")
|
||||
print(f"Personal: Title='{pers_dup.get('titolo')}', Stato={pers_dup.get('stato')}")
|
||||
@@ -0,0 +1,11 @@
|
||||
import urllib.request
|
||||
|
||||
url = "https://app.canticristiani.it/js/db.js"
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
|
||||
with urllib.request.urlopen(req) as res:
|
||||
content = res.read().decode("utf-8")
|
||||
lines = content.split("\n")
|
||||
|
||||
print("================== js/db.js lines 1260 to 1310 ==================")
|
||||
for i in range(1259, min(1310, len(lines))):
|
||||
print(f"{i+1}: {lines[i]}")
|
||||
@@ -0,0 +1,33 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
.chord-segment {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
vertical-align: bottom;
|
||||
margin-right: 0.2em;
|
||||
border: 1px solid blue;
|
||||
}
|
||||
.chord {
|
||||
font-size: 0.75em;
|
||||
font-weight: 700;
|
||||
color: red;
|
||||
height: 1.2em;
|
||||
margin-bottom: -0.2em;
|
||||
border: 1px solid red;
|
||||
}
|
||||
.seg-text {
|
||||
white-space: pre;
|
||||
border: 1px solid green;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div>
|
||||
ti amerò come
|
||||
<span class="chord-segment"><span class="chord">LA</span><span class="seg-text"> </span></span>
|
||||
<span class="chord-segment"><span class="chord">MI</span><span class="seg-text"></span></span>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,67 @@
|
||||
const textWords = [
|
||||
{ text: 'come', bbox: { x0: 40, x1: 60 } },
|
||||
{ text: 'sei', bbox: { x0: 70, x1: 90 } }
|
||||
];
|
||||
|
||||
const chordWords = [
|
||||
{ text: 'la', bbox: { x0: 65, x1: 75 } },
|
||||
{ text: 'mi', bbox: { x0: 95, x1: 105 } }
|
||||
];
|
||||
|
||||
function mergeChordsAndLyrics(chordWords: any[], textWords: any[]): string {
|
||||
let result = '';
|
||||
const chordAssignments = new Map<any, any[]>();
|
||||
|
||||
chordWords.forEach(chord => {
|
||||
const chordX = (chord.bbox.x0 + chord.bbox.x1) / 2;
|
||||
let closestWord: any = null;
|
||||
let minDistance = Infinity;
|
||||
|
||||
textWords.forEach(textWord => {
|
||||
const wordXCenter = (textWord.bbox.x0 + textWord.bbox.x1) / 2;
|
||||
const dist = Math.abs(chordX - wordXCenter);
|
||||
if (dist < minDistance) {
|
||||
minDistance = dist;
|
||||
closestWord = textWord;
|
||||
}
|
||||
});
|
||||
|
||||
if (closestWord) {
|
||||
if (!chordAssignments.has(closestWord)) {
|
||||
chordAssignments.set(closestWord, []);
|
||||
}
|
||||
chordAssignments.get(closestWord)!.push(chord);
|
||||
}
|
||||
});
|
||||
|
||||
textWords.forEach((textWord, index) => {
|
||||
const assignedChords = chordAssignments.get(textWord) || [];
|
||||
|
||||
// Split chords into before and after the word
|
||||
const chordsBefore = assignedChords.filter(c => (c.bbox.x0 + c.bbox.x1)/2 <= textWord.bbox.x1);
|
||||
const chordsAfter = assignedChords.filter(c => (c.bbox.x0 + c.bbox.x1)/2 > textWord.bbox.x1);
|
||||
|
||||
chordsBefore.sort((a, b) => a.bbox.x0 - b.bbox.x0);
|
||||
chordsAfter.sort((a, b) => a.bbox.x0 - b.bbox.x0);
|
||||
|
||||
chordsBefore.forEach(chord => {
|
||||
const cleanChord = chord.text.replace(/[\(\)\[\]]/g, '').toUpperCase();
|
||||
result += `[${cleanChord}]`;
|
||||
});
|
||||
|
||||
result += textWord.text;
|
||||
|
||||
chordsAfter.forEach(chord => {
|
||||
const cleanChord = chord.text.replace(/[\(\)\[\]]/g, '').toUpperCase();
|
||||
result += `[${cleanChord}]`;
|
||||
});
|
||||
|
||||
if (index < textWords.length - 1) {
|
||||
result += ' ';
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
console.log(mergeChordsAndLyrics(chordWords, textWords));
|
||||
@@ -309,6 +309,9 @@ export class AppComponent implements OnInit {
|
||||
if (!skipRedirect) {
|
||||
this.showRedirectOverlay.set(true);
|
||||
this.redirectFailed.set(false);
|
||||
if ((window as any).PwaLoader) {
|
||||
(window as any).PwaLoader.update({ isRedirect: true });
|
||||
}
|
||||
// Tentiamo il reindirizzamento automatico
|
||||
setTimeout(() => {
|
||||
window.location.href = this.protocolLink;
|
||||
@@ -318,6 +321,9 @@ export class AppComponent implements OnInit {
|
||||
if (this.showRedirectOverlay()) {
|
||||
this.redirectFailed.set(true);
|
||||
localStorage.setItem('pwa-installed', 'false');
|
||||
if ((window as any).PwaLoader) {
|
||||
(window as any).PwaLoader.hide();
|
||||
}
|
||||
}
|
||||
}, 2000);
|
||||
}, 800);
|
||||
@@ -335,11 +341,18 @@ export class AppComponent implements OnInit {
|
||||
|
||||
openPwaManual() {
|
||||
window.location.href = this.protocolLink;
|
||||
if ((window as any).PwaLoader) {
|
||||
(window as any).PwaLoader.show();
|
||||
(window as any).PwaLoader.update({ isRedirect: true });
|
||||
}
|
||||
// Se dopo 2 secondi l'utente è ancora qui, probabilmente l'app non è installata
|
||||
setTimeout(() => {
|
||||
if (this.showRedirectOverlay()) {
|
||||
this.redirectFailed.set(true);
|
||||
localStorage.setItem('pwa-installed', 'false');
|
||||
if ((window as any).PwaLoader) {
|
||||
(window as any).PwaLoader.hide();
|
||||
}
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
+170
-12
@@ -278,11 +278,12 @@ export class HomePage implements OnDestroy {
|
||||
const comunitaIds = this.comunitaService.comunitaCantiIds();
|
||||
|
||||
if (comunitaCode && this.comunitaService.isFilterActive()) {
|
||||
// Base is standard canti + my canti + community custom canti + remote custom canti
|
||||
// Base is standard canti + my canti + community custom canti + remote custom canti + remote share canti
|
||||
list = [
|
||||
...this.cantiService.canti(),
|
||||
...this.myCantiService.myCanti(),
|
||||
...this.playlistService.remoteCustomSongs(),
|
||||
...this.playlistService.remoteShareCanti(),
|
||||
...this.comunitaService.comunitaCantiPersonali()
|
||||
];
|
||||
|
||||
@@ -316,11 +317,12 @@ export class HomePage implements OnDestroy {
|
||||
return numA - numB;
|
||||
});
|
||||
} else {
|
||||
// General context: only standard canti + my canti + remote custom canti
|
||||
// General context: only standard canti + my canti + remote custom canti + remote share canti
|
||||
list = [
|
||||
...this.cantiService.canti(),
|
||||
...this.myCantiService.myCanti(),
|
||||
...this.playlistService.remoteCustomSongs()
|
||||
...this.playlistService.remoteCustomSongs(),
|
||||
...this.playlistService.remoteShareCanti()
|
||||
];
|
||||
}
|
||||
|
||||
@@ -488,9 +490,21 @@ export class HomePage implements OnDestroy {
|
||||
if (this.settingsService.isVersionCheckComplete() && !queryParamsSubscribed) {
|
||||
queryParamsSubscribed = true;
|
||||
this.queryParamsSubscription = this.route.queryParams.subscribe(params => {
|
||||
if (params['id']) {
|
||||
const idVal = params['id'];
|
||||
this.router.navigate([], { queryParams: { id: null, t: null }, queryParamsHandling: 'merge', replaceUrl: true }).then(() => {
|
||||
this.goToCanto(idVal);
|
||||
});
|
||||
}
|
||||
if (params['import']) {
|
||||
this.handleImport(params['import']);
|
||||
}
|
||||
if (params['import-canto']) {
|
||||
this.handleImportCanto(params['import-canto']);
|
||||
}
|
||||
if (params['song-uid'] && params['song-id']) {
|
||||
this.handleRemoteSongImport(params['song-uid'], params['song-id']);
|
||||
}
|
||||
if (params['playlist-uid']) {
|
||||
this.handleRemotePlaylistImport(params['playlist-uid'], params['playlist-id']);
|
||||
}
|
||||
@@ -508,7 +522,7 @@ export class HomePage implements OnDestroy {
|
||||
|
||||
if (versionReady && lastPl && !hasAutoActivatedPlaylist) {
|
||||
const params = this.route.snapshot.queryParams;
|
||||
if (!params['id'] && !params['import'] && !params['playlist-uid'] && !params['restore-uid']) {
|
||||
if (!params['id'] && !params['import'] && !params['playlist-uid'] && !params['restore-uid'] && !params['song-uid'] && !params['import-canto']) {
|
||||
hasAutoActivatedPlaylist = true;
|
||||
this.selectPlaylist(lastPl);
|
||||
this.activeFilterType.set('playlist');
|
||||
@@ -581,7 +595,8 @@ export class HomePage implements OnDestroy {
|
||||
|
||||
async handleImport(base64: string) {
|
||||
try {
|
||||
const decoded = decodeURIComponent(escape(atob(base64)));
|
||||
const base64Safe = base64.replace(/ /g, '+');
|
||||
const decoded = decodeURIComponent(escape(atob(base64Safe)));
|
||||
const json = JSON.parse(decoded);
|
||||
|
||||
if (json && json.comunitaCode) {
|
||||
@@ -630,11 +645,12 @@ export class HomePage implements OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
const openFirst = this.route.snapshot.queryParams['openFirst'] === '1';
|
||||
if (this.playlistService.processImportJson(json)) {
|
||||
this.limit.set(50); // Mostra più canti inizialmente per le playlist speciali
|
||||
this.activeFilterType.set('playlist');
|
||||
await this.router.navigate([], { queryParams: { import: null }, queryParamsHandling: 'merge', replaceUrl: true });
|
||||
if (json.ids && json.ids.length > 0) {
|
||||
await this.router.navigate([], { queryParams: { import: null, openFirst: null }, queryParamsHandling: 'merge', replaceUrl: true });
|
||||
if (openFirst && json.ids && json.ids.length > 0) {
|
||||
this.goToCanto(json.ids[0]);
|
||||
}
|
||||
}
|
||||
@@ -650,6 +666,117 @@ export class HomePage implements OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
async handleImportCanto(base64: string) {
|
||||
try {
|
||||
const base64Safe = base64.replace(/ /g, '+');
|
||||
const decoded = decodeURIComponent(escape(atob(base64Safe)));
|
||||
const json = JSON.parse(decoded);
|
||||
|
||||
if (json && json.cantoShare && json.titolo) {
|
||||
const id_canti = Date.now();
|
||||
const id = `remote_share_${id_canti}`;
|
||||
|
||||
const canto: any = {
|
||||
id: id,
|
||||
id_canti: id_canti,
|
||||
titolo: json.titolo,
|
||||
autore: json.autore || '',
|
||||
testo: json.testo || '',
|
||||
accordi: json.accordi || '',
|
||||
link_youtube: json.link_youtube || '',
|
||||
id_momenti: json.id_momenti || [],
|
||||
nonValidato: true
|
||||
};
|
||||
|
||||
await this.playlistService.saveRemoteShareCanto(canto);
|
||||
|
||||
const toast = await this.toastCtrl.create({
|
||||
message: `Canto "${canto.titolo}" importato nei brani remoti non validati!`,
|
||||
duration: 3000,
|
||||
color: 'success',
|
||||
position: 'bottom'
|
||||
});
|
||||
await toast.present();
|
||||
|
||||
await this.router.navigate([], { queryParams: { 'import-canto': null }, queryParamsHandling: 'merge', replaceUrl: true });
|
||||
this.goToCanto(canto.id);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to import canto', e);
|
||||
const toast = await this.toastCtrl.create({
|
||||
message: 'Errore durante l\'importazione del canto.',
|
||||
duration: 3000,
|
||||
color: 'danger',
|
||||
position: 'bottom'
|
||||
});
|
||||
await toast.present();
|
||||
}
|
||||
}
|
||||
|
||||
async handleRemoteSongImport(uid: string, songId: string) {
|
||||
const loading = await this.loadingCtrl.create({
|
||||
message: 'Scaricamento canto da remoto...'
|
||||
});
|
||||
await loading.present();
|
||||
|
||||
try {
|
||||
const response = await fetch(`https://api.canticristiani.it/${uid}.json?cb=${Date.now()}`, { cache: 'no-store' });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Risposta del server non valida: ${response.status}`);
|
||||
}
|
||||
const remoteJson = await response.json();
|
||||
if (Array.isArray(remoteJson)) {
|
||||
const item = remoteJson.find((x: any) =>
|
||||
Number(x.id_canti) === Number(songId) &&
|
||||
(!x.momenti || (!x.momenti.includes('Playlist') && !x.momenti.includes('UserMetadata')))
|
||||
);
|
||||
|
||||
if (!item) {
|
||||
throw new Error('Canto non trovato nel profilo remoto.');
|
||||
}
|
||||
|
||||
const id = `remote_share_${item.id_canti}`;
|
||||
const canto: any = {
|
||||
id: id,
|
||||
id_canti: Number(item.id_canti),
|
||||
titolo: item.titolo || 'Senza Titolo',
|
||||
autore: item.autore || '',
|
||||
testo: item.testo || '',
|
||||
accordi: item.accordi || (item.testo?.includes('[') ? item.testo : undefined),
|
||||
link_youtube: item.link_youtube || '',
|
||||
id_momenti: item.momenti?.map((m: any) => Number(m)).filter((m: any) => !isNaN(m)) || [],
|
||||
nonValidato: true
|
||||
};
|
||||
|
||||
await this.playlistService.saveRemoteShareCanto(canto);
|
||||
|
||||
const toast = await this.toastCtrl.create({
|
||||
message: `Canto "${canto.titolo}" importato nei brani remoti non validati!`,
|
||||
duration: 3000,
|
||||
color: 'success',
|
||||
position: 'bottom'
|
||||
});
|
||||
await toast.present();
|
||||
|
||||
await this.router.navigate([], { queryParams: { 'song-uid': null, 'song-id': null }, queryParamsHandling: 'merge', replaceUrl: true });
|
||||
this.goToCanto(canto.id);
|
||||
} else {
|
||||
throw new Error('Formato dati non valido.');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Failed to import remote song:', err);
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Errore Importazione',
|
||||
message: 'Impossibile scaricare il canto da remoto. Controlla la connessione o il link.',
|
||||
buttons: ['OK']
|
||||
});
|
||||
await alert.present();
|
||||
await this.router.navigate([], { queryParams: { 'song-uid': null, 'song-id': null }, queryParamsHandling: 'merge', replaceUrl: true });
|
||||
} finally {
|
||||
await loading.dismiss();
|
||||
}
|
||||
}
|
||||
|
||||
async handleRemotePlaylistImport(uid: string, pid?: string) {
|
||||
const loading = await this.loadingCtrl.create({
|
||||
message: 'Scaricamento playlist da remoto...'
|
||||
@@ -657,6 +784,7 @@ export class HomePage implements OnDestroy {
|
||||
await loading.present();
|
||||
|
||||
let hasNavigatedToCanto = false;
|
||||
const openFirst = this.route.snapshot.queryParams['openFirst'] === '1';
|
||||
try {
|
||||
const response = await fetch(`https://api.canticristiani.it/${uid}.json?cb=${Date.now()}`, { cache: 'no-store' });
|
||||
if (!response.ok) {
|
||||
@@ -672,7 +800,9 @@ export class HomePage implements OnDestroy {
|
||||
id_canti: Number(item.id_canti),
|
||||
titolo: item.titolo,
|
||||
testo: item.testo,
|
||||
accordi: item.testo?.includes('[') ? item.testo : undefined,
|
||||
accordi: item.accordi || (item.testo?.includes('[') ? item.testo : undefined),
|
||||
autore: item.autore || '',
|
||||
link_youtube: item.link_youtube || '',
|
||||
id_momenti: item.momenti?.map((m: any) => Number(m)).filter((m: any) => !isNaN(m)) || []
|
||||
}));
|
||||
|
||||
@@ -731,8 +861,10 @@ export class HomePage implements OnDestroy {
|
||||
|
||||
if (selectedPl.ids && selectedPl.ids.length > 0) {
|
||||
hasNavigatedToCanto = true;
|
||||
await this.router.navigate([], { queryParams: { 'playlist-uid': null, 'playlist-id': null }, queryParamsHandling: 'merge', replaceUrl: true });
|
||||
this.goToCanto(selectedPl.ids[0]);
|
||||
await this.router.navigate([], { queryParams: { 'playlist-uid': null, 'playlist-id': null, 'openFirst': null }, queryParamsHandling: 'merge', replaceUrl: true });
|
||||
if (openFirst) {
|
||||
this.goToCanto(selectedPl.ids[0]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new Error('Formato dati non valido.');
|
||||
@@ -748,7 +880,7 @@ export class HomePage implements OnDestroy {
|
||||
await alert.present();
|
||||
} finally {
|
||||
if (!hasNavigatedToCanto) {
|
||||
this.router.navigate([], { queryParams: { 'playlist-uid': null, 'playlist-id': null }, queryParamsHandling: 'merge' });
|
||||
this.router.navigate([], { queryParams: { 'playlist-uid': null, 'playlist-id': null, 'openFirst': null }, queryParamsHandling: 'merge', replaceUrl: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -787,7 +919,9 @@ export class HomePage implements OnDestroy {
|
||||
id_canti: Number(item.id_canti),
|
||||
titolo: item.titolo || 'Senza Titolo',
|
||||
testo: item.testo || '',
|
||||
accordi: item.testo?.includes('[') ? item.testo : undefined,
|
||||
accordi: item.accordi || (item.testo?.includes('[') ? item.testo : undefined),
|
||||
autore: item.autore || '',
|
||||
link_youtube: item.link_youtube || '',
|
||||
id_momenti: item.momenti?.map((m: any) => Number(m)).filter((m: any) => !isNaN(m)) || []
|
||||
}));
|
||||
|
||||
@@ -898,6 +1032,7 @@ export class HomePage implements OnDestroy {
|
||||
...this.cantiService.canti(),
|
||||
...this.myCantiService.myCanti(),
|
||||
...this.playlistService.remoteCustomSongs(),
|
||||
...this.playlistService.remoteShareCanti(),
|
||||
...this.comunitaService.comunitaCantiPersonali()
|
||||
].find(c => c.id === id || String(c.id_canti) === id);
|
||||
}
|
||||
@@ -1401,6 +1536,29 @@ export class HomePage implements OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
if (data.includes('import-canto=')) {
|
||||
try {
|
||||
let base64 = '';
|
||||
if (data.includes('?import-canto=')) {
|
||||
base64 = data.split('?import-canto=')[1];
|
||||
} else if (data.includes('&import-canto=')) {
|
||||
base64 = data.split('&import-canto=')[1];
|
||||
}
|
||||
|
||||
if (base64.includes('&')) {
|
||||
base64 = base64.split('&')[0];
|
||||
}
|
||||
if (base64.includes('#')) {
|
||||
base64 = base64.split('#')[0];
|
||||
}
|
||||
|
||||
this.handleImportCanto(base64);
|
||||
return;
|
||||
} catch (e) {
|
||||
console.error('Failed to parse scanned canto link', e);
|
||||
}
|
||||
}
|
||||
|
||||
if (data.includes('import=')) {
|
||||
try {
|
||||
let base64 = '';
|
||||
|
||||
@@ -21,6 +21,9 @@
|
||||
<ion-button fill="clear" (click)="editOrCloneCanto()" *ngIf="settingsService.showEditor() && !isLandscapeActive()" style="margin: 0; --padding-start: 6px; --padding-end: 6px;">
|
||||
<ion-icon slot="icon-only" name="create-outline" color="secondary" style="font-size: 1.3rem;"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-button fill="clear" (click)="shareCanto()" *ngIf="!isLandscapeActive()" style="margin: 0; --padding-start: 6px; --padding-end: 6px;">
|
||||
<ion-icon slot="icon-only" name="share-social-outline" color="secondary" style="font-size: 1.3rem;"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-button fill="clear" (click)="toggleChords()" *ngIf="!isLandscapeActive()" style="margin: 0; --padding-start: 6px; --padding-end: 6px;">
|
||||
<ion-icon slot="icon-only"
|
||||
[name]="showChords() ? 'musical-notes-outline' : 'text-outline'"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Component, OnInit, OnDestroy, signal, computed, effect, inject, ElementRef, AfterViewInit, untracked, HostListener } from '@angular/core';
|
||||
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
|
||||
import { DomSanitizer, SafeResourceUrl, Meta } from '@angular/platform-browser';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { AlertController, ToastController, GestureController } from '@ionic/angular';
|
||||
import { CantiService, Canto } from '../../services/canti.service';
|
||||
@@ -150,6 +150,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
||||
private el = inject(ElementRef);
|
||||
private sanitizer = inject(DomSanitizer);
|
||||
public faceDetector = inject(FaceDetectorService);
|
||||
private meta = inject(Meta);
|
||||
|
||||
public enableCameraNavigation = signal<boolean>(false);
|
||||
|
||||
@@ -212,6 +213,9 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
||||
if (!found) {
|
||||
found = this.playlistService.remoteCustomSongs().find(c => c.id === id || String(c.id_canti) === id);
|
||||
}
|
||||
if (!found) {
|
||||
found = this.playlistService.remoteShareCanti().find(c => c.id === id || String(c.id_canti) === id);
|
||||
}
|
||||
if (!found) {
|
||||
found = comunitaCantiPers.find(c => c.id === id);
|
||||
}
|
||||
@@ -237,6 +241,15 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.cantiService.getStorage()?.set('last_song_id', id);
|
||||
this.channel.postMessage({ type: 'SYNC_CANTO', id: found.id });
|
||||
this.channel.postMessage({ type: 'SYNC_INDEX', index: 0 });
|
||||
|
||||
// Update Open Graph Image tag dynamically
|
||||
const thumb = this.cantiService.getYoutubeThumb(found.link_youtube);
|
||||
if (thumb) {
|
||||
this.meta.updateTag({ property: 'og:image', content: thumb });
|
||||
} else {
|
||||
this.meta.updateTag({ property: 'og:image', content: window.location.origin + '/assets/icon/favicon.png' });
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
const scrollEl = this.el.nativeElement.querySelector('.lyrics-container');
|
||||
if (scrollEl) {
|
||||
@@ -1037,6 +1050,84 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
async shareCanto() {
|
||||
const c = this.canto();
|
||||
if (!c) return;
|
||||
|
||||
const isStandard = !c.id.startsWith('my_') && !c.id.startsWith('remote_share_') && !c.nonValidato;
|
||||
|
||||
try {
|
||||
let shareLink = '';
|
||||
if (isStandard) {
|
||||
shareLink = `https://www.canticristiani.it/?id=${c.id}`;
|
||||
} else {
|
||||
await this.playlistService.syncLocalDataToServer();
|
||||
const uid = this.settingsService.userUuid();
|
||||
shareLink = `https://www.canticristiani.it/?song-uid=${uid}&song-id=${c.id_canti}`;
|
||||
}
|
||||
|
||||
let fileToShare: File | null = null;
|
||||
const thumbUrl = this.cantiService.getYoutubeThumb(c.link_youtube);
|
||||
|
||||
// Try to fetch YouTube thumbnail first if available
|
||||
if (thumbUrl) {
|
||||
try {
|
||||
const response = await Promise.race([
|
||||
fetch(thumbUrl),
|
||||
new Promise<Response>((_, reject) => setTimeout(() => reject(new Error('Timeout')), 3000))
|
||||
]);
|
||||
if (response.ok) {
|
||||
const blob = await response.blob();
|
||||
fileToShare = new File([blob], 'song_thumbnail.jpg', { type: 'image/jpeg' });
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Share] Failed to fetch YouTube thumbnail due to CORS or timeout:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// If no YouTube thumb or fetch failed, fallback to local canticristiani logo (favicon)
|
||||
if (!fileToShare) {
|
||||
try {
|
||||
const response = await fetch('assets/icon/favicon.png');
|
||||
if (response.ok) {
|
||||
const blob = await response.blob();
|
||||
fileToShare = new File([blob], 'canticristiani_logo.png', { type: 'image/png' });
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Share] Failed to fetch local logo:', e);
|
||||
}
|
||||
}
|
||||
|
||||
const shareDataObj: any = {
|
||||
title: `Condividi Canto: ${c.titolo}`,
|
||||
text: isStandard
|
||||
? `Ecco il canto "${c.titolo}" dall'app Canti Cristiani. Clicca sul link per aprirlo:\n\n`
|
||||
: `Ecco il canto "${c.titolo}" per l'app Canti Cristiani. Clicca sul link per aggiungerlo:\n\n`,
|
||||
url: shareLink
|
||||
};
|
||||
|
||||
if (fileToShare && navigator.canShare && navigator.canShare({ files: [fileToShare] })) {
|
||||
shareDataObj.files = [fileToShare];
|
||||
}
|
||||
|
||||
if (navigator.share) {
|
||||
await navigator.share(shareDataObj);
|
||||
} else {
|
||||
if (navigator.clipboard) {
|
||||
await navigator.clipboard.writeText(shareLink);
|
||||
const toast = await this.toastCtrl.create({
|
||||
message: 'Link di condivisione copiato negli appunti!',
|
||||
duration: 2500,
|
||||
color: 'success'
|
||||
});
|
||||
await toast.present();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to share song', err);
|
||||
}
|
||||
}
|
||||
|
||||
private initPlayer(id: string) {
|
||||
if (!this.youtubePlayerService.isPlayerSupported()) {
|
||||
return;
|
||||
|
||||
@@ -131,7 +131,8 @@ export class ProposeCantoPage implements OnInit {
|
||||
const song = [
|
||||
...this.cantiService.canti(),
|
||||
...this.myCantiService.myCanti(),
|
||||
...this.playlistService.remoteCustomSongs()
|
||||
...this.playlistService.remoteCustomSongs(),
|
||||
...this.playlistService.remoteShareCanti()
|
||||
].find(c => c.id === editId);
|
||||
|
||||
if (song) {
|
||||
@@ -961,6 +962,10 @@ export class ProposeCantoPage implements OnInit {
|
||||
await this.playlistService.replaceSongIdInPlaylists(this.editId, savedCanto.id);
|
||||
}
|
||||
|
||||
if (this.editId && this.editId.startsWith('remote_share_')) {
|
||||
await this.playlistService.deleteRemoteShareCanto(this.editId);
|
||||
}
|
||||
|
||||
if (this.editId && savedCanto && savedCanto.id && this.editId !== savedCanto.id) {
|
||||
this.router.navigate(['/player'], { queryParams: { id: savedCanto.id }, replaceUrl: true });
|
||||
} else {
|
||||
|
||||
@@ -156,7 +156,9 @@ export class SettingsPage {
|
||||
id_canti: Number(item.id_canti),
|
||||
titolo: item.titolo || 'Senza Titolo',
|
||||
testo: item.testo || '',
|
||||
accordi: item.testo?.includes('[') ? item.testo : undefined,
|
||||
accordi: item.accordi || (item.testo?.includes('[') ? item.testo : undefined),
|
||||
autore: item.autore || '',
|
||||
link_youtube: item.link_youtube || '',
|
||||
id_momenti: item.momenti?.map((m: any) => Number(m)).filter((m: any) => !isNaN(m)) || []
|
||||
}));
|
||||
|
||||
@@ -342,7 +344,9 @@ export class SettingsPage {
|
||||
id_canti: Number(item.id_canti),
|
||||
titolo: item.titolo || 'Senza Titolo',
|
||||
testo: item.testo || '',
|
||||
accordi: item.testo?.includes('[') ? item.testo : undefined,
|
||||
accordi: item.accordi || (item.testo?.includes('[') ? item.testo : undefined),
|
||||
autore: item.autore || '',
|
||||
link_youtube: item.link_youtube || '',
|
||||
id_momenti: item.momenti?.map((m: any) => Number(m)).filter((m: any) => !isNaN(m)) || []
|
||||
}));
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { LyricsParserService } from './lyrics-parser.service';
|
||||
import { SettingsService } from './settings.service';
|
||||
|
||||
describe('LyricsParserService - Hybrid Notation', () => {
|
||||
let service: LyricsParserService;
|
||||
let mockSettingsService: any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockSettingsService = {
|
||||
chordNotationPreference: jasmine.createSpy('chordNotationPreference').and.returnValue('diesis')
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
LyricsParserService,
|
||||
{ provide: SettingsService, useValue: mockSettingsService }
|
||||
]
|
||||
});
|
||||
service = TestBed.inject(LyricsParserService);
|
||||
});
|
||||
|
||||
it('should parse simple text with memorize, replay, copy, paste', () => {
|
||||
const rawSong = `
|
||||
{start_verse}{memorize/m1}
|
||||
Ralle[RE]griamoci, [LA]
|
||||
non [SOL]c’è spazio alla tri[RE]stezza in questo gior[LA]no [SOL]
|
||||
{end_verse}
|
||||
|
||||
{start_chorus}{copy/rit}
|
||||
Gloria a [RE]Te Emma[SOL]nue-[LA]le
|
||||
{end_chorus}
|
||||
|
||||
{start_verse}{replay/m1}
|
||||
Rallegri*amoci,*
|
||||
Egli *viene a libe*rarci da ogni *male.*
|
||||
{end_verse}
|
||||
|
||||
{start_chorus}{paste/rit}
|
||||
{end_chorus}
|
||||
`;
|
||||
|
||||
// 1. Test parsing with chords enabled
|
||||
const sections = service.parseAccordi(rawSong);
|
||||
expect(sections.length).toBe(4);
|
||||
|
||||
// Verse 1 (memorize)
|
||||
expect(sections[0].type).toBe('verse');
|
||||
expect(sections[0].lines[0].text).toBe('Rallegriamoci, ');
|
||||
expect(sections[0].lines[0].segments[0].chord).toBeUndefined();
|
||||
expect(sections[0].lines[0].segments[1].chord).toBe('RE');
|
||||
expect(sections[0].lines[0].segments[2].chord).toBe('LA');
|
||||
|
||||
// Chorus 1 (copy)
|
||||
expect(sections[1].type).toBe('chorus');
|
||||
expect(sections[1].lines[0].text).toBe('Gloria a Te Emmanue-le');
|
||||
expect(sections[1].lines[0].segments[0].chord).toBeUndefined();
|
||||
expect(sections[1].lines[0].segments[1].chord).toBe('RE');
|
||||
expect(sections[1].lines[0].segments[2].chord).toBe('SOL');
|
||||
expect(sections[1].lines[0].segments[3].chord).toBe('LA');
|
||||
|
||||
// Verse 2 (replay)
|
||||
expect(sections[2].type).toBe('verse');
|
||||
expect(sections[2].lines[0].text).toBe('Rallegriamoci,');
|
||||
expect(sections[2].lines[0].segments[0].chord).toBeUndefined();
|
||||
expect(sections[2].lines[0].segments[1].chord).toBe('RE');
|
||||
expect(sections[2].lines[0].segments[2].chord).toBe('LA');
|
||||
|
||||
expect(sections[2].lines[1].text).toBe('Egli viene a liberarci da ogni male.');
|
||||
expect(sections[2].lines[1].segments[0].chord).toBeUndefined();
|
||||
expect(sections[2].lines[1].segments[1].chord).toBe('SOL');
|
||||
expect(sections[2].lines[1].segments[2].chord).toBe('RE');
|
||||
expect(sections[2].lines[1].segments[3].chord).toBe('LA');
|
||||
expect(sections[2].lines[1].segments[4].chord).toBe('SOL');
|
||||
|
||||
// Chorus 2 (paste)
|
||||
expect(sections[3].type).toBe('chorus');
|
||||
expect(sections[3].lines[0].text).toBe('Gloria a Te Emmanue-le');
|
||||
expect(sections[3].lines[0].segments[0].chord).toBeUndefined();
|
||||
expect(sections[3].lines[0].segments[1].chord).toBe('RE');
|
||||
expect(sections[3].lines[0].segments[2].chord).toBe('SOL');
|
||||
expect(sections[3].lines[0].segments[3].chord).toBe('LA');
|
||||
});
|
||||
|
||||
it('should strip chords in text-only mode and remove asterisks', () => {
|
||||
const rawSong = `
|
||||
{start_verse}{memorize/m1}
|
||||
Ralle[RE]griamoci, [LA]
|
||||
{end_verse}
|
||||
|
||||
{start_verse}{replay/m1}
|
||||
Rallegri*amoci,*
|
||||
{end_verse}
|
||||
`;
|
||||
const sections = service.parseText(rawSong);
|
||||
expect(sections.length).toBe(2);
|
||||
|
||||
expect(sections[0].lines[0].text).toBe('Rallegriamoci,');
|
||||
expect(sections[0].lines[0].segments[0].chord).toBeUndefined();
|
||||
|
||||
expect(sections[1].lines[0].text).toBe('Rallegriamoci,');
|
||||
expect(sections[1].lines[0].segments[0].chord).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -45,36 +45,77 @@ export class LyricsParserService {
|
||||
let currentLines: ParsedLine[] = [];
|
||||
let verseNumCounter = 0;
|
||||
|
||||
const memories = new Map<string, string[]>();
|
||||
const copies = new Map<string, string[]>();
|
||||
|
||||
let currentAction: { type: 'memorize' | 'replay' | 'copy' | 'paste'; key: string } | null = null;
|
||||
let currentRawLines: string[] = [];
|
||||
|
||||
const lines = raw.split('\n');
|
||||
const startTagRegex = /^\{(start_verse|start_chorus|start_verse_num|sov|soc)\}(?:\{([a-z]+)\/([a-zA-Z0-9_-]+)\})?$/;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
// Detect section start tags
|
||||
if (trimmed === '{start_verse}' || trimmed === '{sov}') {
|
||||
const startMatch = trimmed.match(startTagRegex);
|
||||
if (startMatch) {
|
||||
this.pushSection(sections, currentType, currentLines);
|
||||
currentType = 'verse';
|
||||
currentLines = [];
|
||||
continue;
|
||||
}
|
||||
if (trimmed === '{start_chorus}' || trimmed === '{soc}') {
|
||||
this.pushSection(sections, currentType, currentLines);
|
||||
currentType = 'chorus';
|
||||
currentLines = [];
|
||||
continue;
|
||||
}
|
||||
if (trimmed === '{start_verse_num}') {
|
||||
this.pushSection(sections, currentType, currentLines);
|
||||
currentType = 'verse_num';
|
||||
verseNumCounter++;
|
||||
|
||||
const tag = startMatch[1];
|
||||
if (tag === 'start_verse' || tag === 'sov') {
|
||||
currentType = 'verse';
|
||||
} else if (tag === 'start_chorus' || tag === 'soc') {
|
||||
currentType = 'chorus';
|
||||
} else if (tag === 'start_verse_num') {
|
||||
currentType = 'verse_num';
|
||||
verseNumCounter++;
|
||||
}
|
||||
|
||||
currentLines = [];
|
||||
currentRawLines = [];
|
||||
|
||||
if (startMatch[2] && startMatch[3]) {
|
||||
currentAction = {
|
||||
type: startMatch[2] as any,
|
||||
key: startMatch[3]
|
||||
};
|
||||
|
||||
if (currentAction.type === 'paste') {
|
||||
const pastedRawLines = copies.get(currentAction.key) || [];
|
||||
for (const pLine of pastedRawLines) {
|
||||
if (withChords) {
|
||||
currentLines.push(this.parseChordLine(pLine));
|
||||
} else {
|
||||
const cleanLine = pLine.replace(/\[[^\]]*\]/g, '').trim();
|
||||
if (cleanLine.length > 0) {
|
||||
currentLines.push({
|
||||
text: cleanLine,
|
||||
segments: [{ text: cleanLine }]
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
currentAction = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Detect section end tags — just skip them
|
||||
// Detect section end tags
|
||||
if (trimmed === '{end_verse}' || trimmed === '{eov}' || trimmed === '{end_chorus}' || trimmed === '{eoc}' || trimmed === '{end_verse_num}') {
|
||||
if (currentAction) {
|
||||
if (currentAction.type === 'memorize') {
|
||||
memories.set(currentAction.key, [...currentRawLines]);
|
||||
} else if (currentAction.type === 'copy') {
|
||||
copies.set(currentAction.key, [...currentRawLines]);
|
||||
}
|
||||
}
|
||||
|
||||
this.pushSection(sections, currentType, currentLines, currentType === 'verse_num' ? verseNumCounter : undefined);
|
||||
currentLines = [];
|
||||
currentRawLines = [];
|
||||
currentAction = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -88,12 +129,33 @@ export class LyricsParserService {
|
||||
continue;
|
||||
}
|
||||
|
||||
let resolvedLine = line;
|
||||
if (currentAction && currentAction.type === 'replay') {
|
||||
const memLines = memories.get(currentAction.key) || [];
|
||||
const lineIndex = currentRawLines.length;
|
||||
if (lineIndex < memLines.length) {
|
||||
const memLine = memLines[lineIndex];
|
||||
const chordMatches = [...memLine.matchAll(/\[([^\]]+)\]/g)].map(m => m[0]);
|
||||
let chordIdx = 0;
|
||||
resolvedLine = line.replace(/\*/g, () => {
|
||||
if (chordIdx < chordMatches.length) {
|
||||
return chordMatches[chordIdx++];
|
||||
}
|
||||
return '';
|
||||
});
|
||||
} else {
|
||||
resolvedLine = line.replace(/\*/g, '');
|
||||
}
|
||||
}
|
||||
|
||||
currentRawLines.push(line);
|
||||
|
||||
// Parse line
|
||||
if (withChords) {
|
||||
currentLines.push(this.parseChordLine(line));
|
||||
currentLines.push(this.parseChordLine(resolvedLine));
|
||||
} else {
|
||||
// CLEAN CHORDS in text-only mode: remove [anything]
|
||||
const cleanLine = line.replace(/\[[^\]]*\]/g, '').trim();
|
||||
const cleanLine = resolvedLine.replace(/\[[^\]]*\]/g, '').trim();
|
||||
if (cleanLine.length > 0) {
|
||||
currentLines.push({
|
||||
text: cleanLine,
|
||||
|
||||
@@ -32,6 +32,7 @@ export class PlaylistService {
|
||||
|
||||
public remotePlaylist = signal<any | null>(null);
|
||||
public remoteCustomSongs = signal<any[]>([]);
|
||||
public remoteShareCanti = signal<Canto[]>([]);
|
||||
|
||||
private _storage: Storage | null = null;
|
||||
private initPromise!: Promise<void>;
|
||||
@@ -114,6 +115,10 @@ export class PlaylistService {
|
||||
if (remoteSongs) {
|
||||
this.remoteCustomSongs.set(remoteSongs);
|
||||
}
|
||||
const remoteShareSongs = await this._storage.get('remote_share_canti');
|
||||
if (remoteShareSongs) {
|
||||
this.remoteShareCanti.set(remoteShareSongs);
|
||||
}
|
||||
|
||||
// Aggiorna in background la playlist remota per sincronizzare eventuali modifiche
|
||||
if (remotePl) {
|
||||
@@ -141,7 +146,9 @@ export class PlaylistService {
|
||||
id_canti: Number(item.id_canti),
|
||||
titolo: item.titolo || 'Senza Titolo',
|
||||
testo: item.testo || '',
|
||||
accordi: item.testo?.includes('[') ? item.testo : undefined,
|
||||
accordi: item.accordi || (item.testo?.includes('[') ? item.testo : undefined),
|
||||
autore: item.autore || '',
|
||||
link_youtube: item.link_youtube || '',
|
||||
id_momenti: item.momenti?.map((m: any) => Number(m)).filter((m: any) => !isNaN(m)) || []
|
||||
}));
|
||||
|
||||
@@ -204,7 +211,7 @@ export class PlaylistService {
|
||||
});
|
||||
}
|
||||
|
||||
async savePlaylist(name: string, ids: string[], songSettings?: any) {
|
||||
async savePlaylist(name: string, ids: string[], songSettings?: any, setAsLast = true) {
|
||||
await this.initPromise;
|
||||
const key = this.getPlaylistsStorageKey();
|
||||
const lastKey = `lastPlaylist_${key}`;
|
||||
@@ -231,11 +238,15 @@ export class PlaylistService {
|
||||
this.playlists.update(p => [newPlaylist, ...p]);
|
||||
}
|
||||
|
||||
this.lastPlaylist.set(newPlaylist);
|
||||
if (setAsLast) {
|
||||
this.lastPlaylist.set(newPlaylist);
|
||||
}
|
||||
this.activeListIds.set(ids);
|
||||
this.activeListName.set(name);
|
||||
await this._storage?.set(key, this.playlists());
|
||||
await this._storage?.set(lastKey, newPlaylist);
|
||||
if (setAsLast) {
|
||||
await this._storage?.set(lastKey, newPlaylist);
|
||||
}
|
||||
|
||||
// Reset selection mode, IDs and editing state after saving
|
||||
this.selectedIds.set(new Set());
|
||||
@@ -345,13 +356,37 @@ export class PlaylistService {
|
||||
}
|
||||
|
||||
try {
|
||||
const customSongs = this.myCantiService.myCanti().map(c => ({
|
||||
id_canti: c.id_canti || Date.now(),
|
||||
titolo: c.titolo || 'Senza Titolo',
|
||||
momenti: c.id_momenti?.map(id => String(id)) || [],
|
||||
periodi: [] as string[],
|
||||
testo: c.testo || ''
|
||||
}));
|
||||
const mergedSongsMap = new Map<number, any>();
|
||||
|
||||
this.remoteShareCanti().forEach(c => {
|
||||
const id_canti = c.id_canti || Date.now();
|
||||
mergedSongsMap.set(id_canti, {
|
||||
id_canti,
|
||||
titolo: c.titolo || 'Senza Titolo',
|
||||
autore: c.autore || '',
|
||||
link_youtube: c.link_youtube || '',
|
||||
accordi: c.accordi || '',
|
||||
momenti: c.id_momenti?.map(id => String(id)) || [],
|
||||
periodi: [] as string[],
|
||||
testo: c.testo || ''
|
||||
});
|
||||
});
|
||||
|
||||
this.myCantiService.myCanti().forEach(c => {
|
||||
const id_canti = c.id_canti || Date.now();
|
||||
mergedSongsMap.set(id_canti, {
|
||||
id_canti,
|
||||
titolo: c.titolo || 'Senza Titolo',
|
||||
autore: c.autore || '',
|
||||
link_youtube: c.link_youtube || '',
|
||||
accordi: c.accordi || '',
|
||||
momenti: c.id_momenti?.map(id => String(id)) || [],
|
||||
periodi: [] as string[],
|
||||
testo: c.testo || ''
|
||||
});
|
||||
});
|
||||
|
||||
const customSongs = Array.from(mergedSongsMap.values());
|
||||
|
||||
const playlistSongs = this.playlists().map(pl => ({
|
||||
id_canti: Number(pl.id) || Date.now(),
|
||||
@@ -451,7 +486,7 @@ export class PlaylistService {
|
||||
|
||||
// Always use the production URL for sharing links as requested
|
||||
const productionUrl = 'https://www.canticristiani.it';
|
||||
return `${productionUrl}/?import=${base64}`;
|
||||
return `${productionUrl}/?import=${base64}&openFirst=1`;
|
||||
}
|
||||
|
||||
processImportJson(json: any): boolean {
|
||||
@@ -460,7 +495,7 @@ export class PlaylistService {
|
||||
this.activeListIds.set(json.ids);
|
||||
this.activeListName.set(json.name);
|
||||
// Automatically save to the device!
|
||||
this.savePlaylist(json.name, json.ids, json.songSettings);
|
||||
this.savePlaylist(json.name, json.ids, json.songSettings, false);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -478,7 +513,7 @@ export class PlaylistService {
|
||||
{
|
||||
text: isRemote ? 'Condividi (Sola Lettura)' : 'Sola Lettura (Consultazione)',
|
||||
handler: () => {
|
||||
let shareLink = `https://www.canticristiani.it/?playlist-uid=${uid}&playlist-id=${activeId}`;
|
||||
let shareLink = `https://www.canticristiani.it/?playlist-uid=${uid}&playlist-id=${activeId}&openFirst=1`;
|
||||
if (isRemote) {
|
||||
let remoteUid = uid;
|
||||
let remotePid = activeId;
|
||||
@@ -487,7 +522,7 @@ export class PlaylistService {
|
||||
remoteUid = parts[1];
|
||||
remotePid = parts[2];
|
||||
}
|
||||
shareLink = `https://www.canticristiani.it/?playlist-uid=${remoteUid}&playlist-id=${remotePid}`;
|
||||
shareLink = `https://www.canticristiani.it/?playlist-uid=${remoteUid}&playlist-id=${remotePid}&openFirst=1`;
|
||||
}
|
||||
this.executeShare(shareLink, name);
|
||||
}
|
||||
@@ -569,4 +604,25 @@ export class PlaylistService {
|
||||
|
||||
async loadPlaylists() {
|
||||
}
|
||||
|
||||
async saveRemoteShareCanto(canto: Canto) {
|
||||
await this.initPromise;
|
||||
const current = this.remoteShareCanti();
|
||||
const index = current.findIndex(c => c.id === canto.id);
|
||||
let updated: Canto[];
|
||||
if (index > -1) {
|
||||
updated = current.map(c => c.id === canto.id ? canto : c);
|
||||
} else {
|
||||
updated = [...current, canto];
|
||||
}
|
||||
this.remoteShareCanti.set(updated);
|
||||
await this._storage?.set('remote_share_canti', updated);
|
||||
}
|
||||
|
||||
async deleteRemoteShareCanto(id: string) {
|
||||
await this.initPromise;
|
||||
const updated = this.remoteShareCanti().filter(c => c.id !== id);
|
||||
this.remoteShareCanti.set(updated);
|
||||
await this._storage?.set('remote_share_canti', updated);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
export const VERSION = '2026.06.16.1611';
|
||||
export const VERSION = '2026.06.17.0049';
|
||||
|
||||
@@ -75,6 +75,19 @@
|
||||
if (bar) bar.style.width = pct + '%';
|
||||
if (pctText) pctText.textContent = pct + '%';
|
||||
}
|
||||
|
||||
if (options.isRedirect) {
|
||||
if (bar && bar.parentElement) bar.parentElement.style.display = 'none';
|
||||
if (pctText) pctText.style.display = 'none';
|
||||
if (phaseEl) phaseEl.style.display = 'none';
|
||||
if (versionEl) versionEl.style.display = 'none';
|
||||
if (titleEl) {
|
||||
titleEl.textContent = 'Chiudi il browser, è stata aperta la app installata sul device';
|
||||
titleEl.style.fontSize = '1.4rem';
|
||||
titleEl.style.lineHeight = '1.5';
|
||||
}
|
||||
if (descEl) descEl.style.display = 'none';
|
||||
}
|
||||
},
|
||||
hide: function() {
|
||||
const loader = document.getElementById('pwa-boot-loader');
|
||||
|
||||
Reference in New Issue
Block a user