diff --git a/.env b/.env index 1cfc03b..64de438 100644 --- a/.env +++ b/.env @@ -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 diff --git a/backup.sh b/backup.sh new file mode 100755 index 0000000..0abaa8d --- /dev/null +++ b/backup.sh @@ -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." diff --git a/comunita_123456.json b/comunita_123456.json new file mode 100644 index 0000000..4d7cda6 --- /dev/null +++ b/comunita_123456.json @@ -0,0 +1,5 @@ +{ + "id_comunita": "123456", + "nome_comunita": "Comunità Test 123456", + "canti": [378, 379, 380] +} diff --git a/deploy_www.sh b/deploy_www.sh index 5d27508..cbe024f 100755 --- a/deploy_www.sh +++ b/deploy_www.sh @@ -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 diff --git a/git_backup.zip b/git_backup.zip new file mode 100644 index 0000000..9b685bc Binary files /dev/null and b/git_backup.zip differ diff --git a/implementazione_servizio_miei.md b/implementazione_servizio_miei.md new file mode 100644 index 0000000..f542cfb --- /dev/null +++ b/implementazione_servizio_miei.md @@ -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=` +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/.json`), ne crea automaticamente una copia di backup rinominandola o copiandola in `data/.bak.json`. +3. **Salvataggio**: + - Scrive il nuovo JSON in `data/.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. diff --git a/nuova_funziona_importazione_comunity.md b/nuova_funziona_importazione_comunity.md new file mode 100644 index 0000000..7800de7 --- /dev/null +++ b/nuova_funziona_importazione_comunity.md @@ -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. diff --git a/scratch/check_settings.py b/scratch/check_settings.py new file mode 100644 index 0000000..f6c3404 --- /dev/null +++ b/scratch/check_settings.py @@ -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) diff --git a/scratch/deploy_ftp.py b/scratch/deploy_ftp.py new file mode 100644 index 0000000..69aa2b4 --- /dev/null +++ b/scratch/deploy_ftp.py @@ -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() diff --git a/scratch/parse_comunita.py b/scratch/parse_comunita.py new file mode 100644 index 0000000..d1d79ac --- /dev/null +++ b/scratch/parse_comunita.py @@ -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')}") diff --git a/scratch/probe_prod.py b/scratch/probe_prod.py new file mode 100644 index 0000000..241276a --- /dev/null +++ b/scratch/probe_prod.py @@ -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]}") diff --git a/scratch/test-css.html b/scratch/test-css.html new file mode 100644 index 0000000..b7e96cf --- /dev/null +++ b/scratch/test-css.html @@ -0,0 +1,33 @@ + + + + + + +
+ ti amerò come + LA + MI +
+ + diff --git a/scratch/test_merge.ts b/scratch/test_merge.ts new file mode 100644 index 0000000..ae78a4d --- /dev/null +++ b/scratch/test_merge.ts @@ -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(); + + 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)); diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 40f6fec..73d56e4 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -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); } diff --git a/src/app/home/home.page.ts b/src/app/home/home.page.ts index 1e35309..5073cb5 100644 --- a/src/app/home/home.page.ts +++ b/src/app/home/home.page.ts @@ -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 = ''; diff --git a/src/app/pages/player/player.page.html b/src/app/pages/player/player.page.html index 992c0ff..dfa3849 100644 --- a/src/app/pages/player/player.page.html +++ b/src/app/pages/player/player.page.html @@ -21,6 +21,9 @@ + + + (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((_, 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; diff --git a/src/app/pages/propose-canto/propose-canto.page.ts b/src/app/pages/propose-canto/propose-canto.page.ts index a76dd53..3a17636 100644 --- a/src/app/pages/propose-canto/propose-canto.page.ts +++ b/src/app/pages/propose-canto/propose-canto.page.ts @@ -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 { diff --git a/src/app/pages/settings/settings.page.ts b/src/app/pages/settings/settings.page.ts index ebebbe7..32997c9 100644 --- a/src/app/pages/settings/settings.page.ts +++ b/src/app/pages/settings/settings.page.ts @@ -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)) || [] })); diff --git a/src/app/services/lyrics-parser.service.spec.ts b/src/app/services/lyrics-parser.service.spec.ts new file mode 100644 index 0000000..963c386 --- /dev/null +++ b/src/app/services/lyrics-parser.service.spec.ts @@ -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(); + }); +}); diff --git a/src/app/services/lyrics-parser.service.ts b/src/app/services/lyrics-parser.service.ts index 5af9c5b..dd43deb 100644 --- a/src/app/services/lyrics-parser.service.ts +++ b/src/app/services/lyrics-parser.service.ts @@ -45,36 +45,77 @@ export class LyricsParserService { let currentLines: ParsedLine[] = []; let verseNumCounter = 0; + const memories = new Map(); + const copies = new Map(); + + 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, diff --git a/src/app/services/playlist.service.ts b/src/app/services/playlist.service.ts index ea41c54..6d7c6f6 100644 --- a/src/app/services/playlist.service.ts +++ b/src/app/services/playlist.service.ts @@ -32,6 +32,7 @@ export class PlaylistService { public remotePlaylist = signal(null); public remoteCustomSongs = signal([]); + public remoteShareCanti = signal([]); private _storage: Storage | null = null; private initPromise!: Promise; @@ -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(); + + 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); + } } diff --git a/src/app/version.ts b/src/app/version.ts index eb86d9c..231e707 100644 --- a/src/app/version.ts +++ b/src/app/version.ts @@ -1 +1 @@ -export const VERSION = '2026.06.16.1611'; +export const VERSION = '2026.06.17.0049'; diff --git a/src/index.html b/src/index.html index 56ad07e..eff7823 100644 --- a/src/index.html +++ b/src/index.html @@ -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');