modificato deploy verso contabo

This commit is contained in:
David Frassi
2026-06-17 01:04:27 +02:00
parent 2d23dcae83
commit 97a11d5074
24 changed files with 1063 additions and 64 deletions
+4 -1
View File
@@ -1,6 +1,9 @@
FTP_PASSWORD=cantiDavid@72 FTP_PASSWORD=cantiDavid@72
FTP_THREADS=30 FTP_THREADS=100
BASE_HREF=/ionic/ BASE_HREF=/ionic/
CONTACT_EMAIL=info@canticristiani.it CONTACT_EMAIL=info@canticristiani.it
API_AUTH_USER=canti API_AUTH_USER=canti
API_AUTH_PASS=antani2026 API_AUTH_PASS=antani2026
VPS_HOST=185.193.67.105
VPS_USER=root
VPS_PATH=/var/docker/canticristiani-pwa/html
Executable
+4
View File
@@ -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."
+5
View File
@@ -0,0 +1,5 @@
{
"id_comunita": "123456",
"nome_comunita": "Comunità Test 123456",
"canti": [378, 379, 380]
}
+38 -10
View File
@@ -8,19 +8,41 @@ else
exit 1 exit 1
fi fi
# --- Configurazione Parallelismo --- # --- Configurazione Target e Parallelismo ---
THREADS=${1:-${FTP_THREADS:-100}} 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 export FTP_THREADS=$THREADS
# --- Configurazione FTP per ROOT www.canticristiani.it --- echo "🎯 Target di deploy: $TARGET"
FTP_HOST="ftp.canticristiani.it"
FTP_USER="canticristiani.it"
FTP_PASS="$FTP_PASSWORD"
REMOTE_DIR="" # Carica nella root della cartella FTP
if [ -z "$FTP_PASS" ]; then # --- 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" echo "❌ Errore: FTP_PASSWORD non definita nel file .env"
exit 1 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 fi
# --- Aggiornamento Versione --- # --- Aggiornamento Versione ---
@@ -61,5 +83,11 @@ BUILD_TIMESTAMP=$(date +%s)000
echo "{\"version\":\"$VERSION\",\"buildTime\":$BUILD_TIMESTAMP}" > www/version.json echo "{\"version\":\"$VERSION\",\"buildTime\":$BUILD_TIMESTAMP}" > www/version.json
echo "📄 Generato version.json: v$VERSION (timestamp: $BUILD_TIMESTAMP)" echo "📄 Generato version.json: v$VERSION (timestamp: $BUILD_TIMESTAMP)"
# --- Upload via FTP --- # --- Upload ---
python3 scratch/deploy_ftp.py 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
BIN
View File
Binary file not shown.
+84
View File
@@ -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.
+54
View File
@@ -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.
+13
View File
@@ -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)
+154
View File
@@ -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()
+34
View File
@@ -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')}")
+11
View File
@@ -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]}")
+33
View File
@@ -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>
+67
View File
@@ -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));
+13
View File
@@ -309,6 +309,9 @@ export class AppComponent implements OnInit {
if (!skipRedirect) { if (!skipRedirect) {
this.showRedirectOverlay.set(true); this.showRedirectOverlay.set(true);
this.redirectFailed.set(false); this.redirectFailed.set(false);
if ((window as any).PwaLoader) {
(window as any).PwaLoader.update({ isRedirect: true });
}
// Tentiamo il reindirizzamento automatico // Tentiamo il reindirizzamento automatico
setTimeout(() => { setTimeout(() => {
window.location.href = this.protocolLink; window.location.href = this.protocolLink;
@@ -318,6 +321,9 @@ export class AppComponent implements OnInit {
if (this.showRedirectOverlay()) { if (this.showRedirectOverlay()) {
this.redirectFailed.set(true); this.redirectFailed.set(true);
localStorage.setItem('pwa-installed', 'false'); localStorage.setItem('pwa-installed', 'false');
if ((window as any).PwaLoader) {
(window as any).PwaLoader.hide();
}
} }
}, 2000); }, 2000);
}, 800); }, 800);
@@ -335,11 +341,18 @@ export class AppComponent implements OnInit {
openPwaManual() { openPwaManual() {
window.location.href = this.protocolLink; 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 // Se dopo 2 secondi l'utente è ancora qui, probabilmente l'app non è installata
setTimeout(() => { setTimeout(() => {
if (this.showRedirectOverlay()) { if (this.showRedirectOverlay()) {
this.redirectFailed.set(true); this.redirectFailed.set(true);
localStorage.setItem('pwa-installed', 'false'); localStorage.setItem('pwa-installed', 'false');
if ((window as any).PwaLoader) {
(window as any).PwaLoader.hide();
}
} }
}, 2000); }, 2000);
} }
+169 -11
View File
@@ -278,11 +278,12 @@ export class HomePage implements OnDestroy {
const comunitaIds = this.comunitaService.comunitaCantiIds(); const comunitaIds = this.comunitaService.comunitaCantiIds();
if (comunitaCode && this.comunitaService.isFilterActive()) { 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 = [ list = [
...this.cantiService.canti(), ...this.cantiService.canti(),
...this.myCantiService.myCanti(), ...this.myCantiService.myCanti(),
...this.playlistService.remoteCustomSongs(), ...this.playlistService.remoteCustomSongs(),
...this.playlistService.remoteShareCanti(),
...this.comunitaService.comunitaCantiPersonali() ...this.comunitaService.comunitaCantiPersonali()
]; ];
@@ -316,11 +317,12 @@ export class HomePage implements OnDestroy {
return numA - numB; return numA - numB;
}); });
} else { } 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 = [ list = [
...this.cantiService.canti(), ...this.cantiService.canti(),
...this.myCantiService.myCanti(), ...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) { if (this.settingsService.isVersionCheckComplete() && !queryParamsSubscribed) {
queryParamsSubscribed = true; queryParamsSubscribed = true;
this.queryParamsSubscription = this.route.queryParams.subscribe(params => { 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']) { if (params['import']) {
this.handleImport(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']) { if (params['playlist-uid']) {
this.handleRemotePlaylistImport(params['playlist-uid'], params['playlist-id']); this.handleRemotePlaylistImport(params['playlist-uid'], params['playlist-id']);
} }
@@ -508,7 +522,7 @@ export class HomePage implements OnDestroy {
if (versionReady && lastPl && !hasAutoActivatedPlaylist) { if (versionReady && lastPl && !hasAutoActivatedPlaylist) {
const params = this.route.snapshot.queryParams; 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; hasAutoActivatedPlaylist = true;
this.selectPlaylist(lastPl); this.selectPlaylist(lastPl);
this.activeFilterType.set('playlist'); this.activeFilterType.set('playlist');
@@ -581,7 +595,8 @@ export class HomePage implements OnDestroy {
async handleImport(base64: string) { async handleImport(base64: string) {
try { try {
const decoded = decodeURIComponent(escape(atob(base64))); const base64Safe = base64.replace(/ /g, '+');
const decoded = decodeURIComponent(escape(atob(base64Safe)));
const json = JSON.parse(decoded); const json = JSON.parse(decoded);
if (json && json.comunitaCode) { 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)) { if (this.playlistService.processImportJson(json)) {
this.limit.set(50); // Mostra più canti inizialmente per le playlist speciali this.limit.set(50); // Mostra più canti inizialmente per le playlist speciali
this.activeFilterType.set('playlist'); this.activeFilterType.set('playlist');
await this.router.navigate([], { queryParams: { import: null }, queryParamsHandling: 'merge', replaceUrl: true }); await this.router.navigate([], { queryParams: { import: null, openFirst: null }, queryParamsHandling: 'merge', replaceUrl: true });
if (json.ids && json.ids.length > 0) { if (openFirst && json.ids && json.ids.length > 0) {
this.goToCanto(json.ids[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) { async handleRemotePlaylistImport(uid: string, pid?: string) {
const loading = await this.loadingCtrl.create({ const loading = await this.loadingCtrl.create({
message: 'Scaricamento playlist da remoto...' message: 'Scaricamento playlist da remoto...'
@@ -657,6 +784,7 @@ export class HomePage implements OnDestroy {
await loading.present(); await loading.present();
let hasNavigatedToCanto = false; let hasNavigatedToCanto = false;
const openFirst = this.route.snapshot.queryParams['openFirst'] === '1';
try { try {
const response = await fetch(`https://api.canticristiani.it/${uid}.json?cb=${Date.now()}`, { cache: 'no-store' }); const response = await fetch(`https://api.canticristiani.it/${uid}.json?cb=${Date.now()}`, { cache: 'no-store' });
if (!response.ok) { if (!response.ok) {
@@ -672,7 +800,9 @@ export class HomePage implements OnDestroy {
id_canti: Number(item.id_canti), id_canti: Number(item.id_canti),
titolo: item.titolo, titolo: item.titolo,
testo: item.testo, 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)) || [] id_momenti: item.momenti?.map((m: any) => Number(m)).filter((m: any) => !isNaN(m)) || []
})); }));
@@ -731,9 +861,11 @@ export class HomePage implements OnDestroy {
if (selectedPl.ids && selectedPl.ids.length > 0) { if (selectedPl.ids && selectedPl.ids.length > 0) {
hasNavigatedToCanto = true; hasNavigatedToCanto = true;
await this.router.navigate([], { queryParams: { 'playlist-uid': null, 'playlist-id': null }, queryParamsHandling: 'merge', replaceUrl: true }); await this.router.navigate([], { queryParams: { 'playlist-uid': null, 'playlist-id': null, 'openFirst': null }, queryParamsHandling: 'merge', replaceUrl: true });
if (openFirst) {
this.goToCanto(selectedPl.ids[0]); this.goToCanto(selectedPl.ids[0]);
} }
}
} else { } else {
throw new Error('Formato dati non valido.'); throw new Error('Formato dati non valido.');
} }
@@ -748,7 +880,7 @@ export class HomePage implements OnDestroy {
await alert.present(); await alert.present();
} finally { } finally {
if (!hasNavigatedToCanto) { 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), id_canti: Number(item.id_canti),
titolo: item.titolo || 'Senza Titolo', titolo: item.titolo || 'Senza Titolo',
testo: item.testo || '', 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)) || [] 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.cantiService.canti(),
...this.myCantiService.myCanti(), ...this.myCantiService.myCanti(),
...this.playlistService.remoteCustomSongs(), ...this.playlistService.remoteCustomSongs(),
...this.playlistService.remoteShareCanti(),
...this.comunitaService.comunitaCantiPersonali() ...this.comunitaService.comunitaCantiPersonali()
].find(c => c.id === id || String(c.id_canti) === id); ].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=')) { if (data.includes('import=')) {
try { try {
let base64 = ''; let base64 = '';
+3
View File
@@ -21,6 +21,9 @@
<ion-button fill="clear" (click)="editOrCloneCanto()" *ngIf="settingsService.showEditor() && !isLandscapeActive()" style="margin: 0; --padding-start: 6px; --padding-end: 6px;"> <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-icon slot="icon-only" name="create-outline" color="secondary" style="font-size: 1.3rem;"></ion-icon>
</ion-button> </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-button fill="clear" (click)="toggleChords()" *ngIf="!isLandscapeActive()" style="margin: 0; --padding-start: 6px; --padding-end: 6px;">
<ion-icon slot="icon-only" <ion-icon slot="icon-only"
[name]="showChords() ? 'musical-notes-outline' : 'text-outline'" [name]="showChords() ? 'musical-notes-outline' : 'text-outline'"
+92 -1
View File
@@ -1,5 +1,5 @@
import { Component, OnInit, OnDestroy, signal, computed, effect, inject, ElementRef, AfterViewInit, untracked, HostListener } from '@angular/core'; 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 { ActivatedRoute, Router } from '@angular/router';
import { AlertController, ToastController, GestureController } from '@ionic/angular'; import { AlertController, ToastController, GestureController } from '@ionic/angular';
import { CantiService, Canto } from '../../services/canti.service'; import { CantiService, Canto } from '../../services/canti.service';
@@ -150,6 +150,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
private el = inject(ElementRef); private el = inject(ElementRef);
private sanitizer = inject(DomSanitizer); private sanitizer = inject(DomSanitizer);
public faceDetector = inject(FaceDetectorService); public faceDetector = inject(FaceDetectorService);
private meta = inject(Meta);
public enableCameraNavigation = signal<boolean>(false); public enableCameraNavigation = signal<boolean>(false);
@@ -212,6 +213,9 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
if (!found) { if (!found) {
found = this.playlistService.remoteCustomSongs().find(c => c.id === id || String(c.id_canti) === id); 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) { if (!found) {
found = comunitaCantiPers.find(c => c.id === id); 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.cantiService.getStorage()?.set('last_song_id', id);
this.channel.postMessage({ type: 'SYNC_CANTO', id: found.id }); this.channel.postMessage({ type: 'SYNC_CANTO', id: found.id });
this.channel.postMessage({ type: 'SYNC_INDEX', index: 0 }); 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(() => { setTimeout(() => {
const scrollEl = this.el.nativeElement.querySelector('.lyrics-container'); const scrollEl = this.el.nativeElement.querySelector('.lyrics-container');
if (scrollEl) { 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) { private initPlayer(id: string) {
if (!this.youtubePlayerService.isPlayerSupported()) { if (!this.youtubePlayerService.isPlayerSupported()) {
return; return;
@@ -131,7 +131,8 @@ export class ProposeCantoPage implements OnInit {
const song = [ const song = [
...this.cantiService.canti(), ...this.cantiService.canti(),
...this.myCantiService.myCanti(), ...this.myCantiService.myCanti(),
...this.playlistService.remoteCustomSongs() ...this.playlistService.remoteCustomSongs(),
...this.playlistService.remoteShareCanti()
].find(c => c.id === editId); ].find(c => c.id === editId);
if (song) { if (song) {
@@ -961,6 +962,10 @@ export class ProposeCantoPage implements OnInit {
await this.playlistService.replaceSongIdInPlaylists(this.editId, savedCanto.id); 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) { if (this.editId && savedCanto && savedCanto.id && this.editId !== savedCanto.id) {
this.router.navigate(['/player'], { queryParams: { id: savedCanto.id }, replaceUrl: true }); this.router.navigate(['/player'], { queryParams: { id: savedCanto.id }, replaceUrl: true });
} else { } else {
+6 -2
View File
@@ -156,7 +156,9 @@ export class SettingsPage {
id_canti: Number(item.id_canti), id_canti: Number(item.id_canti),
titolo: item.titolo || 'Senza Titolo', titolo: item.titolo || 'Senza Titolo',
testo: item.testo || '', 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)) || [] 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), id_canti: Number(item.id_canti),
titolo: item.titolo || 'Senza Titolo', titolo: item.titolo || 'Senza Titolo',
testo: item.testo || '', 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)) || [] 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();
});
});
+77 -15
View File
@@ -45,36 +45,77 @@ export class LyricsParserService {
let currentLines: ParsedLine[] = []; let currentLines: ParsedLine[] = [];
let verseNumCounter = 0; 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 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) { for (const line of lines) {
const trimmed = line.trim(); const trimmed = line.trim();
// Detect section start tags const startMatch = trimmed.match(startTagRegex);
if (trimmed === '{start_verse}' || trimmed === '{sov}') { if (startMatch) {
this.pushSection(sections, currentType, currentLines); this.pushSection(sections, currentType, currentLines);
const tag = startMatch[1];
if (tag === 'start_verse' || tag === 'sov') {
currentType = 'verse'; currentType = 'verse';
currentLines = []; } else if (tag === 'start_chorus' || tag === 'soc') {
continue;
}
if (trimmed === '{start_chorus}' || trimmed === '{soc}') {
this.pushSection(sections, currentType, currentLines);
currentType = 'chorus'; currentType = 'chorus';
currentLines = []; } else if (tag === 'start_verse_num') {
continue;
}
if (trimmed === '{start_verse_num}') {
this.pushSection(sections, currentType, currentLines);
currentType = 'verse_num'; currentType = 'verse_num';
verseNumCounter++; verseNumCounter++;
}
currentLines = []; 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; 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 (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); this.pushSection(sections, currentType, currentLines, currentType === 'verse_num' ? verseNumCounter : undefined);
currentLines = []; currentLines = [];
currentRawLines = [];
currentAction = null;
continue; continue;
} }
@@ -88,12 +129,33 @@ export class LyricsParserService {
continue; 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 // Parse line
if (withChords) { if (withChords) {
currentLines.push(this.parseChordLine(line)); currentLines.push(this.parseChordLine(resolvedLine));
} else { } else {
// CLEAN CHORDS in text-only mode: remove [anything] // CLEAN CHORDS in text-only mode: remove [anything]
const cleanLine = line.replace(/\[[^\]]*\]/g, '').trim(); const cleanLine = resolvedLine.replace(/\[[^\]]*\]/g, '').trim();
if (cleanLine.length > 0) { if (cleanLine.length > 0) {
currentLines.push({ currentLines.push({
text: cleanLine, text: cleanLine,
+65 -9
View File
@@ -32,6 +32,7 @@ export class PlaylistService {
public remotePlaylist = signal<any | null>(null); public remotePlaylist = signal<any | null>(null);
public remoteCustomSongs = signal<any[]>([]); public remoteCustomSongs = signal<any[]>([]);
public remoteShareCanti = signal<Canto[]>([]);
private _storage: Storage | null = null; private _storage: Storage | null = null;
private initPromise!: Promise<void>; private initPromise!: Promise<void>;
@@ -114,6 +115,10 @@ export class PlaylistService {
if (remoteSongs) { if (remoteSongs) {
this.remoteCustomSongs.set(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 // Aggiorna in background la playlist remota per sincronizzare eventuali modifiche
if (remotePl) { if (remotePl) {
@@ -141,7 +146,9 @@ export class PlaylistService {
id_canti: Number(item.id_canti), id_canti: Number(item.id_canti),
titolo: item.titolo || 'Senza Titolo', titolo: item.titolo || 'Senza Titolo',
testo: item.testo || '', 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)) || [] 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; await this.initPromise;
const key = this.getPlaylistsStorageKey(); const key = this.getPlaylistsStorageKey();
const lastKey = `lastPlaylist_${key}`; const lastKey = `lastPlaylist_${key}`;
@@ -231,11 +238,15 @@ export class PlaylistService {
this.playlists.update(p => [newPlaylist, ...p]); this.playlists.update(p => [newPlaylist, ...p]);
} }
if (setAsLast) {
this.lastPlaylist.set(newPlaylist); this.lastPlaylist.set(newPlaylist);
}
this.activeListIds.set(ids); this.activeListIds.set(ids);
this.activeListName.set(name); this.activeListName.set(name);
await this._storage?.set(key, this.playlists()); await this._storage?.set(key, this.playlists());
if (setAsLast) {
await this._storage?.set(lastKey, newPlaylist); await this._storage?.set(lastKey, newPlaylist);
}
// Reset selection mode, IDs and editing state after saving // Reset selection mode, IDs and editing state after saving
this.selectedIds.set(new Set()); this.selectedIds.set(new Set());
@@ -345,13 +356,37 @@ export class PlaylistService {
} }
try { try {
const customSongs = this.myCantiService.myCanti().map(c => ({ const mergedSongsMap = new Map<number, any>();
id_canti: c.id_canti || Date.now(),
this.remoteShareCanti().forEach(c => {
const id_canti = c.id_canti || Date.now();
mergedSongsMap.set(id_canti, {
id_canti,
titolo: c.titolo || 'Senza Titolo', titolo: c.titolo || 'Senza Titolo',
autore: c.autore || '',
link_youtube: c.link_youtube || '',
accordi: c.accordi || '',
momenti: c.id_momenti?.map(id => String(id)) || [], momenti: c.id_momenti?.map(id => String(id)) || [],
periodi: [] as string[], periodi: [] as string[],
testo: c.testo || '' 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 => ({ const playlistSongs = this.playlists().map(pl => ({
id_canti: Number(pl.id) || Date.now(), id_canti: Number(pl.id) || Date.now(),
@@ -451,7 +486,7 @@ export class PlaylistService {
// Always use the production URL for sharing links as requested // Always use the production URL for sharing links as requested
const productionUrl = 'https://www.canticristiani.it'; const productionUrl = 'https://www.canticristiani.it';
return `${productionUrl}/?import=${base64}`; return `${productionUrl}/?import=${base64}&openFirst=1`;
} }
processImportJson(json: any): boolean { processImportJson(json: any): boolean {
@@ -460,7 +495,7 @@ export class PlaylistService {
this.activeListIds.set(json.ids); this.activeListIds.set(json.ids);
this.activeListName.set(json.name); this.activeListName.set(json.name);
// Automatically save to the device! // 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 true;
} }
return false; return false;
@@ -478,7 +513,7 @@ export class PlaylistService {
{ {
text: isRemote ? 'Condividi (Sola Lettura)' : 'Sola Lettura (Consultazione)', text: isRemote ? 'Condividi (Sola Lettura)' : 'Sola Lettura (Consultazione)',
handler: () => { 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) { if (isRemote) {
let remoteUid = uid; let remoteUid = uid;
let remotePid = activeId; let remotePid = activeId;
@@ -487,7 +522,7 @@ export class PlaylistService {
remoteUid = parts[1]; remoteUid = parts[1];
remotePid = parts[2]; 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); this.executeShare(shareLink, name);
} }
@@ -569,4 +604,25 @@ export class PlaylistService {
async loadPlaylists() { 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
View File
@@ -1 +1 @@
export const VERSION = '2026.06.16.1611'; export const VERSION = '2026.06.17.0049';
+13
View File
@@ -75,6 +75,19 @@
if (bar) bar.style.width = pct + '%'; if (bar) bar.style.width = pct + '%';
if (pctText) pctText.textContent = 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() { hide: function() {
const loader = document.getElementById('pwa-boot-loader'); const loader = document.getElementById('pwa-boot-loader');