Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b0bb5735c8 | |||
| 93385097f2 |
+23
-10
@@ -29,25 +29,38 @@ if ! curl -s -L "$SOURCE_URL" -o "$TEMP_FILE"; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- Rotazione file su FTP ---
|
# --- Rotazione e Caricamento file (FTP o VPS) ---
|
||||||
echo "🔄 Rotazione file su FTP (canti.json -> canti_ex.json)..."
|
if [ -n "$VPS_HOST" ]; then
|
||||||
# Rinominiamo canti.json in canti_ex.json.
|
echo "🔄 Rotazione file su VPS ($VPS_HOST)..."
|
||||||
# Usiamo i percorsi assoluti per sicurezza.
|
ssh "$VPS_USER@$VPS_HOST" "mkdir -p $VPS_PATH/api && mv $VPS_PATH/api/canti.json $VPS_PATH/api/canti_ex.json 2>/dev/null || true"
|
||||||
curl -s -u "$FTP_USER:$FTP_PASS" \
|
|
||||||
|
echo "🚀 Caricamento nuovo file su VPS via SCP..."
|
||||||
|
if scp "$TEMP_FILE" "$VPS_USER@$VPS_HOST:$VPS_PATH/$REMOTE_PATH" && ssh "$VPS_USER@$VPS_HOST" "chmod 644 $VPS_PATH/$REMOTE_PATH"; then
|
||||||
|
echo "✅ Allineamento su VPS completato con successo!"
|
||||||
|
else
|
||||||
|
echo "❌ Errore durante il caricamento via SCP su VPS."
|
||||||
|
rm "$TEMP_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "🔄 Rotazione file su FTP (canti.json -> canti_ex.json)..."
|
||||||
|
# Rinominiamo canti.json in canti_ex.json.
|
||||||
|
# Usiamo i percorsi assoluti per sicurezza.
|
||||||
|
curl -s -u "$FTP_USER:$FTP_PASS" \
|
||||||
--ftp-pasv \
|
--ftp-pasv \
|
||||||
-Q "*DELE /htdocs/api/canti_ex.json" \
|
-Q "*DELE /htdocs/api/canti_ex.json" \
|
||||||
-Q "*RNFR /htdocs/api/canti.json" \
|
-Q "*RNFR /htdocs/api/canti.json" \
|
||||||
-Q "*RNTO /htdocs/api/canti_ex.json" \
|
-Q "*RNTO /htdocs/api/canti_ex.json" \
|
||||||
"ftp://$FTP_HOST/" > /dev/null
|
"ftp://$FTP_HOST/" > /dev/null
|
||||||
|
|
||||||
# --- Upload via FTP ---
|
echo "🚀 Caricamento nuovo file su FTP ($FTP_HOST) via FTP..."
|
||||||
echo "🚀 Caricamento nuovo file su $FTP_HOST/$REMOTE_PATH..."
|
if curl -s -u "$FTP_USER:$FTP_PASS" --ftp-pasv --ftp-create-dirs -T "$TEMP_FILE" "ftp://$FTP_HOST/$REMOTE_PATH"; then
|
||||||
if curl -s -u "$FTP_USER:$FTP_PASS" --ftp-pasv --ftp-create-dirs -T "$TEMP_FILE" "ftp://$FTP_HOST/$REMOTE_PATH"; then
|
echo "✅ Allineamento su FTP completato con successo!"
|
||||||
echo "✅ Allineamento completato con successo!"
|
else
|
||||||
else
|
|
||||||
echo "❌ Errore durante il caricamento FTP."
|
echo "❌ Errore durante il caricamento FTP."
|
||||||
rm "$TEMP_FILE"
|
rm "$TEMP_FILE"
|
||||||
exit 1
|
exit 1
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Cleanup
|
# Cleanup
|
||||||
|
|||||||
+16
-4
@@ -29,14 +29,26 @@ if ! curl -s -u "$API_AUTH_USER:$API_AUTH_PASS" -L "$SOURCE_URL" -o "$TEMP_FILE"
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- Upload via FTP ---
|
# --- Caricamento file (FTP o VPS) ---
|
||||||
echo "🚀 Caricamento nuovo file su $FTP_HOST/$REMOTE_PATH..."
|
if [ -n "$VPS_HOST" ]; then
|
||||||
if curl -s -u "$FTP_USER:$FTP_PASS" --ftp-pasv --ftp-create-dirs -T "$TEMP_FILE" "ftp://$FTP_HOST/$REMOTE_PATH"; then
|
echo "🚀 Caricamento nuovo file su VPS ($VPS_HOST) via SCP..."
|
||||||
echo "✅ Allineamento completato con successo!"
|
ssh "$VPS_USER@$VPS_HOST" "mkdir -p $VPS_PATH/api"
|
||||||
|
if scp "$TEMP_FILE" "$VPS_USER@$VPS_HOST:$VPS_PATH/$REMOTE_PATH" && ssh "$VPS_USER@$VPS_HOST" "chmod 644 $VPS_PATH/$REMOTE_PATH"; then
|
||||||
|
echo "✅ Allineamento su VPS completato con successo!"
|
||||||
|
else
|
||||||
|
echo "❌ Errore durante il caricamento via SCP su VPS."
|
||||||
|
rm "$TEMP_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
|
echo "🚀 Caricamento nuovo file su FTP ($FTP_HOST) via FTP..."
|
||||||
|
if curl -s -u "$FTP_USER:$FTP_PASS" --ftp-pasv --ftp-create-dirs -T "$TEMP_FILE" "ftp://$FTP_HOST/$REMOTE_PATH"; then
|
||||||
|
echo "✅ Allineamento su FTP completato con successo!"
|
||||||
|
else
|
||||||
echo "❌ Errore durante il caricamento FTP."
|
echo "❌ Errore durante il caricamento FTP."
|
||||||
rm "$TEMP_FILE"
|
rm "$TEMP_FILE"
|
||||||
exit 1
|
exit 1
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Cleanup
|
# Cleanup
|
||||||
|
|||||||
+1
-1
@@ -89,5 +89,5 @@ if [ "$TARGET" = "tophost" ]; then
|
|||||||
python3 scratch/deploy_ftp.py
|
python3 scratch/deploy_ftp.py
|
||||||
else
|
else
|
||||||
echo "🚀 Upload via SSH/rsync su Contabo ($VPS_HOST) in corso..."
|
echo "🚀 Upload via SSH/rsync su Contabo ($VPS_HOST) in corso..."
|
||||||
rsync -avz --delete www/ "$VPS_USER@$VPS_HOST:$VPS_PATH"
|
rsync -avz --delete --exclude 'api' www/ "$VPS_USER@$VPS_HOST:$VPS_PATH"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -6,6 +6,9 @@
|
|||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
"theme_color": "#3880ff",
|
"theme_color": "#3880ff",
|
||||||
"background_color": "#ffffff",
|
"background_color": "#ffffff",
|
||||||
|
"launch_handler": {
|
||||||
|
"client_mode": "focus-existing"
|
||||||
|
},
|
||||||
"protocol_handlers": [
|
"protocol_handlers": [
|
||||||
{
|
{
|
||||||
"protocol": "web+canti",
|
"protocol": "web+canti",
|
||||||
|
|||||||
@@ -4,12 +4,28 @@
|
|||||||
|
|
||||||
<!-- PWA Redirect Overlay -->
|
<!-- PWA Redirect Overlay -->
|
||||||
<div *ngIf="showRedirectOverlay()"
|
<div *ngIf="showRedirectOverlay()"
|
||||||
style="position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(18, 18, 18, 0.85); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); color: #ffffff; display: flex; flex-direction: column; justify-content: center; align-items: center; z-index: 100000; font-family: 'Outfit', sans-serif; padding: 20px;">
|
style="position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: radial-gradient(circle at top left, #2d3436 0%, #121212 100%); color: #ffffff; display: flex; flex-direction: column; justify-content: center; align-items: center; z-index: 100000; font-family: 'Outfit', sans-serif; padding: 20px;">
|
||||||
<div style="text-align: center; padding: 30px; max-width: 420px; width: 100%; background: rgba(30, 30, 30, 0.75); border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 24px; box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45);">
|
<div style="text-align: center; padding: 30px; max-width: 420px; width: 100%; background: rgba(30, 30, 30, 0.75); border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 24px; box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45);">
|
||||||
<div style="margin-bottom: 25px; display: inline-block;">
|
<div style="margin-bottom: 25px; display: inline-block;">
|
||||||
<img src="assets/icon/favicon.png" alt="CantiCristiani" style="width: 80px; height: 80px; border-radius: 20px; box-shadow: 0 8px 24px rgba(230, 126, 34, 0.4); border: 2px solid rgba(230, 126, 34, 0.2);">
|
<img src="assets/icon/favicon.png" alt="CantiCristiani" style="width: 80px; height: 80px; border-radius: 20px; box-shadow: 0 8px 24px rgba(230, 126, 34, 0.4); border: 2px solid rgba(230, 126, 34, 0.2);">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Se la PWA è rilevata come installata -->
|
||||||
|
<ng-container *ngIf="isPwaInstalled()">
|
||||||
|
<h2 style="font-size: 1.6rem; font-weight: 600; margin-bottom: 15px; color: #ffffff; -webkit-font-smoothing: antialiased;">Applicazione Installata</h2>
|
||||||
|
<div style="font-size: 1.05rem; color: rgba(255, 255, 255, 0.9); line-height: 1.6; -webkit-font-smoothing: antialiased; text-align: center; padding: 0 10px; margin-bottom: 25px;">
|
||||||
|
La app risulta già installata sul dispositivo, chiudi il browser ed usa quella oppure disinstallala se preferisci utilizzarla da qui.
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 12px; width: 100%;">
|
||||||
|
<button (click)="stayInBrowserForceUninstallCheck()"
|
||||||
|
style="background: transparent; color: rgba(255, 255, 255, 0.7); border: 1px solid rgba(255, 255, 255, 0.15); padding: 12px 20px; border-radius: 12px; font-size: 0.95rem; font-weight: 500; cursor: pointer; transition: all 0.2s ease; width: 100%;">
|
||||||
|
Continua nel browser (L'ho disinstallata)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Se l'applicazione NON è installata (flusso normale di redirect / installazione guidata) -->
|
||||||
|
<ng-container *ngIf="!isPwaInstalled()">
|
||||||
<!-- Fase di redirect normale (in attesa di apertura) -->
|
<!-- Fase di redirect normale (in attesa di apertura) -->
|
||||||
<ng-container *ngIf="!redirectFailed()">
|
<ng-container *ngIf="!redirectFailed()">
|
||||||
<h2 style="font-size: 1.6rem; font-weight: 600; margin-bottom: 8px; color: #ffffff; -webkit-font-smoothing: antialiased;">Apertura App in corso</h2>
|
<h2 style="font-size: 1.6rem; font-weight: 600; margin-bottom: 8px; color: #ffffff; -webkit-font-smoothing: antialiased;">Apertura App in corso</h2>
|
||||||
@@ -21,10 +37,10 @@
|
|||||||
style="background: linear-gradient(135deg, #e67e22 0%, #d35400 100%); color: #ffffff; border: none; padding: 14px 20px; border-radius: 12px; font-size: 1rem; font-weight: 600; cursor: pointer; transition: all 0.2s ease; box-shadow: 0 4px 15px rgba(230, 126, 34, 0.25);">
|
style="background: linear-gradient(135deg, #e67e22 0%, #d35400 100%); color: #ffffff; border: none; padding: 14px 20px; border-radius: 12px; font-size: 1rem; font-weight: 600; cursor: pointer; transition: all 0.2s ease; box-shadow: 0 4px 15px rgba(230, 126, 34, 0.25);">
|
||||||
Apri Manualmente
|
Apri Manualmente
|
||||||
</button>
|
</button>
|
||||||
<button (click)="stayInBrowser()"
|
<div style="margin-top: 15px; font-size: 0.85rem; color: rgba(255, 255, 255, 0.5); line-height: 1.5; -webkit-font-smoothing: antialiased; padding: 0 10px; text-align: center;">
|
||||||
style="background: transparent; color: rgba(255, 255, 255, 0.7); border: 1px solid rgba(255, 255, 255, 0.15); padding: 12px 20px; border-radius: 12px; font-size: 0.95rem; font-weight: 500; cursor: pointer; transition: all 0.2s ease;">
|
Per evitare problemi di cache, la navigazione da browser è disattivata se la PWA è installata.<br>
|
||||||
Rimani nel browser
|
Se preferisci usare il browser, disinstalla l'app dal dispositivo.
|
||||||
</button>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
@@ -74,12 +90,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
</ng-container>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- PWA Install Overlay -->
|
<!-- PWA Install Overlay -->
|
||||||
<div *ngIf="showInstallOverlay()"
|
<div *ngIf="showInstallOverlay()"
|
||||||
style="position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(18, 18, 18, 0.85); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); color: #ffffff; display: flex; flex-direction: column; justify-content: center; align-items: center; z-index: 100000; font-family: 'Outfit', sans-serif; padding: 20px;">
|
style="position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: radial-gradient(circle at top left, #2d3436 0%, #121212 100%); color: #ffffff; display: flex; flex-direction: column; justify-content: center; align-items: center; z-index: 100000; font-family: 'Outfit', sans-serif; padding: 20px;">
|
||||||
|
|
||||||
<!-- Android Install Prompt -->
|
<!-- Android Install Prompt -->
|
||||||
<div *ngIf="settingsService.isAndroid()"
|
<div *ngIf="settingsService.isAndroid()"
|
||||||
|
|||||||
+166
-13
@@ -26,6 +26,9 @@ export class AppComponent implements OnInit {
|
|||||||
|
|
||||||
public showRedirectOverlay = signal<boolean>(false);
|
public showRedirectOverlay = signal<boolean>(false);
|
||||||
public showInstallOverlay = signal<boolean>(false);
|
public showInstallOverlay = signal<boolean>(false);
|
||||||
|
public isInstalling = signal<boolean>(false);
|
||||||
|
public isRedirecting = signal<boolean>(false);
|
||||||
|
public isPwaInstalled = signal<boolean>(false);
|
||||||
public redirectFailed = signal<boolean>(false);
|
public redirectFailed = signal<boolean>(false);
|
||||||
public protocolLink = '';
|
public protocolLink = '';
|
||||||
|
|
||||||
@@ -45,13 +48,12 @@ export class AppComponent implements OnInit {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Se l'overlay di redirect o di installazione è mostrato, NON nascondiamo il loader iniziale
|
// Se stiamo attivamente installando o reindirizzando, NON nascondiamo il loader
|
||||||
// per rimanere nella welcome page mentre l'utente sceglie.
|
if (this.isInstalling() || this.isRedirecting()) {
|
||||||
if (this.showRedirectOverlay() || this.showInstallOverlay()) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Altrimenti, nascondiamo il loader per far entrare l'utente nell'app
|
// Altrimenti, nascondiamo il loader per far entrare l'utente
|
||||||
if ((window as any).PwaLoader) {
|
if ((window as any).PwaLoader) {
|
||||||
(window as any).PwaLoader.hide();
|
(window as any).PwaLoader.hide();
|
||||||
}
|
}
|
||||||
@@ -95,6 +97,15 @@ export class AppComponent implements OnInit {
|
|||||||
// Set signal indicating startup version check is complete
|
// Set signal indicating startup version check is complete
|
||||||
this.settingsService.isVersionCheckComplete.set(true);
|
this.settingsService.isVersionCheckComplete.set(true);
|
||||||
|
|
||||||
|
// Handle PWA Launch Queue if supported (focus-existing launch behavior)
|
||||||
|
if ('launchQueue' in window) {
|
||||||
|
(window as any).launchQueue.setConsumer((launchParams: any) => {
|
||||||
|
if (launchParams.targetURL) {
|
||||||
|
this.handleLaunchUrl(launchParams.targetURL);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
this.route.queryParams.subscribe(params => {
|
this.route.queryParams.subscribe(params => {
|
||||||
const protocolUrl = params['url'];
|
const protocolUrl = params['url'];
|
||||||
if (protocolUrl && protocolUrl.startsWith('web+canti:')) {
|
if (protocolUrl && protocolUrl.startsWith('web+canti:')) {
|
||||||
@@ -120,10 +131,73 @@ export class AppComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
window.addEventListener('appinstalled', () => {
|
||||||
|
console.log('[AppComponent] PWA appinstalled event caught.');
|
||||||
|
localStorage.setItem('pwa-installed', 'true');
|
||||||
|
this.isPwaInstalled.set(true);
|
||||||
|
this.isInstalling.set(false);
|
||||||
|
this.isRedirecting.set(true);
|
||||||
|
this.showInstallOverlay.set(false);
|
||||||
|
this.showRedirectOverlay.set(false);
|
||||||
|
|
||||||
|
if ((window as any).PwaLoader) {
|
||||||
|
(window as any).PwaLoader.show();
|
||||||
|
(window as any).PwaLoader.update({
|
||||||
|
title: 'Chiudi il browser',
|
||||||
|
desc: 'Applicazione installata con successo! Chiudi il browser e continua sulla PWA.',
|
||||||
|
isRedirect: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = this.protocolLink;
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
|
||||||
this.checkAndRedirectToPwa();
|
this.checkAndRedirectToPwa();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleLaunchUrl(urlStr: string) {
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(urlStr);
|
||||||
|
|
||||||
|
// 1. Check if it's a protocol link inside query params (e.g. /?url=web+canti://...)
|
||||||
|
const protocolUrl = urlObj.searchParams.get('url');
|
||||||
|
if (protocolUrl && protocolUrl.startsWith('web+canti:')) {
|
||||||
|
const cleanUrl = protocolUrl.replace('web+canti://', 'http://localhost/');
|
||||||
|
const innerUrlObj = new URL(cleanUrl);
|
||||||
|
|
||||||
|
let targetPath = innerUrlObj.pathname;
|
||||||
|
if (targetPath === '/open' || targetPath === '//open') {
|
||||||
|
targetPath = '/';
|
||||||
|
} else if (targetPath.startsWith('/open/')) {
|
||||||
|
targetPath = targetPath.substring(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryParams: any = {};
|
||||||
|
innerUrlObj.searchParams.forEach((value, key) => {
|
||||||
|
queryParams[key] = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[AppComponent] Launch queue routing (protocol) to:', targetPath, queryParams);
|
||||||
|
this.router.navigate([targetPath], { queryParams, replaceUrl: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Otherwise, route directly to the pathname and query params of the URL
|
||||||
|
let targetPath = urlObj.pathname;
|
||||||
|
const queryParams: any = {};
|
||||||
|
urlObj.searchParams.forEach((value, key) => {
|
||||||
|
queryParams[key] = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[AppComponent] Launch queue routing (direct) to:', targetPath, queryParams);
|
||||||
|
this.router.navigate([targetPath], { queryParams, replaceUrl: true });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse launch url:', urlStr, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async checkVersionSync(): Promise<boolean> {
|
async checkVersionSync(): Promise<boolean> {
|
||||||
// Controlla SEMPRE version.json per primo — è il modo più affidabile per
|
// Controlla SEMPRE version.json per primo — è il modo più affidabile per
|
||||||
// rilevare un disallineamento di versione, indipendentemente dallo stato del SW.
|
// rilevare un disallineamento di versione, indipendentemente dallo stato del SW.
|
||||||
@@ -304,9 +378,30 @@ export class AppComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Se non è rilevata in localStorage/relatedApps ed è Android o Desktop con supporto ai prompt:
|
||||||
|
// attendiamo 1.5s per dare tempo all'evento 'beforeinstallprompt' di scattare.
|
||||||
|
// Se non scatta, significa che l'app è già installata.
|
||||||
|
if (!isInstalled && !this.settingsService.isIos() && ('onbeforeinstallprompt' in window)) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||||
|
isInstalled = localStorage.getItem('pwa-installed') === 'true';
|
||||||
|
if (!isInstalled) {
|
||||||
|
const hasPrompt = this.settingsService.showInstallButton() || this.settingsService.deferredPrompt();
|
||||||
|
if (!hasPrompt) {
|
||||||
|
console.log('[AppComponent] PWA detected as already installed (onbeforeinstallprompt supported but no prompt fired).');
|
||||||
|
isInstalled = true;
|
||||||
|
localStorage.setItem('pwa-installed', 'true');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isPwaInstalled.set(isInstalled);
|
||||||
|
|
||||||
if (isInstalled) {
|
if (isInstalled) {
|
||||||
const skipRedirect = sessionStorage.getItem('skip-pwa-redirect') === 'true';
|
if (sessionStorage.getItem('skip-pwa-redirect') === 'true') {
|
||||||
if (!skipRedirect) {
|
this.showRedirectOverlay.set(false);
|
||||||
|
this.checkLoaderDismissal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.showRedirectOverlay.set(true);
|
this.showRedirectOverlay.set(true);
|
||||||
this.redirectFailed.set(false);
|
this.redirectFailed.set(false);
|
||||||
if ((window as any).PwaLoader) {
|
if ((window as any).PwaLoader) {
|
||||||
@@ -316,18 +411,16 @@ export class AppComponent implements OnInit {
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.href = this.protocolLink;
|
window.location.href = this.protocolLink;
|
||||||
|
|
||||||
// Se dopo 2 secondi l'utente è ancora qui, probabilmente l'app non è installata
|
// Se dopo 2 secondi l'utente è ancora qui, mostriamo lo stato fallito per aprire manualmente o indicare la disinstallazione
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (this.showRedirectOverlay()) {
|
if (this.showRedirectOverlay()) {
|
||||||
this.redirectFailed.set(true);
|
this.redirectFailed.set(true);
|
||||||
localStorage.setItem('pwa-installed', 'false');
|
|
||||||
if ((window as any).PwaLoader) {
|
if ((window as any).PwaLoader) {
|
||||||
(window as any).PwaLoader.hide();
|
(window as any).PwaLoader.hide();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}, 800);
|
}, 800);
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Se non è installata, proponiamo l'installazione immediata per evitare la cache del browser e avere un'esperienza ottimale
|
// Se non è installata, proponiamo l'installazione immediata per evitare la cache del browser e avere un'esperienza ottimale
|
||||||
const skipInstall = sessionStorage.getItem('skip-pwa-install') === 'true';
|
const skipInstall = sessionStorage.getItem('skip-pwa-install') === 'true';
|
||||||
@@ -335,6 +428,8 @@ export class AppComponent implements OnInit {
|
|||||||
const hasDesktopPrompt = this.settingsService.showInstallButton() || this.settingsService.deferredPrompt();
|
const hasDesktopPrompt = this.settingsService.showInstallButton() || this.settingsService.deferredPrompt();
|
||||||
if (!skipInstall && (isMobile || hasDesktopPrompt)) {
|
if (!skipInstall && (isMobile || hasDesktopPrompt)) {
|
||||||
this.showInstallOverlay.set(true);
|
this.showInstallOverlay.set(true);
|
||||||
|
} else {
|
||||||
|
this.checkLoaderDismissal();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -360,22 +455,80 @@ export class AppComponent implements OnInit {
|
|||||||
stayInBrowser() {
|
stayInBrowser() {
|
||||||
sessionStorage.setItem('skip-pwa-redirect', 'true');
|
sessionStorage.setItem('skip-pwa-redirect', 'true');
|
||||||
this.showRedirectOverlay.set(false);
|
this.showRedirectOverlay.set(false);
|
||||||
|
this.checkLoaderDismissal();
|
||||||
|
}
|
||||||
|
|
||||||
|
stayInBrowserForceUninstallCheck() {
|
||||||
|
localStorage.setItem('pwa-installed', 'false');
|
||||||
|
this.isPwaInstalled.set(false);
|
||||||
|
this.stayInBrowser();
|
||||||
}
|
}
|
||||||
|
|
||||||
closeInstallOverlay() {
|
closeInstallOverlay() {
|
||||||
sessionStorage.setItem('skip-pwa-install', 'true');
|
sessionStorage.setItem('skip-pwa-install', 'true');
|
||||||
this.showInstallOverlay.set(false);
|
this.showInstallOverlay.set(false);
|
||||||
|
this.checkLoaderDismissal();
|
||||||
}
|
}
|
||||||
|
|
||||||
async triggerInstall() {
|
async triggerInstall() {
|
||||||
await this.settingsService.installPwa();
|
this.isInstalling.set(true);
|
||||||
this.closeInstallOverlay();
|
this.showInstallOverlay.set(false);
|
||||||
|
if ((window as any).PwaLoader) {
|
||||||
|
(window as any).PwaLoader.show();
|
||||||
|
(window as any).PwaLoader.update({
|
||||||
|
title: 'Installazione in corso...',
|
||||||
|
desc: 'Completa l\'installazione tramite la finestra del browser.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const outcome = await this.settingsService.installPwa();
|
||||||
|
if (outcome === 'accepted') {
|
||||||
|
this.isInstalling.set(false);
|
||||||
|
this.isRedirecting.set(true);
|
||||||
|
if ((window as any).PwaLoader) {
|
||||||
|
(window as any).PwaLoader.update({
|
||||||
|
title: 'Apertura Applicazione...',
|
||||||
|
desc: 'Installazione completata! Chiudi il browser e continua sulla PWA.',
|
||||||
|
isRedirect: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.isInstalling.set(false);
|
||||||
|
this.isRedirecting.set(false);
|
||||||
|
this.showInstallOverlay.set(true);
|
||||||
|
this.checkLoaderDismissal();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async triggerInstallFromRedirect() {
|
async triggerInstallFromRedirect() {
|
||||||
await this.settingsService.installPwa();
|
this.isInstalling.set(true);
|
||||||
sessionStorage.setItem('skip-pwa-redirect', 'true');
|
|
||||||
this.showRedirectOverlay.set(false);
|
this.showRedirectOverlay.set(false);
|
||||||
|
if ((window as any).PwaLoader) {
|
||||||
|
(window as any).PwaLoader.show();
|
||||||
|
(window as any).PwaLoader.update({
|
||||||
|
title: 'Installazione in corso...',
|
||||||
|
desc: 'Completa l\'installazione tramite la finestra del browser.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const outcome = await this.settingsService.installPwa();
|
||||||
|
if (outcome === 'accepted') {
|
||||||
|
sessionStorage.setItem('skip-pwa-redirect', 'true');
|
||||||
|
this.isInstalling.set(false);
|
||||||
|
this.isRedirecting.set(true);
|
||||||
|
if ((window as any).PwaLoader) {
|
||||||
|
(window as any).PwaLoader.update({
|
||||||
|
title: 'Apertura Applicazione...',
|
||||||
|
desc: 'Installazione completata! Chiudi il browser e continua sulla PWA.',
|
||||||
|
isRedirect: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.isInstalling.set(false);
|
||||||
|
this.isRedirecting.set(false);
|
||||||
|
this.showRedirectOverlay.set(true);
|
||||||
|
this.checkLoaderDismissal();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -379,7 +379,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ion-infinite-scroll (ionInfinite)="loadData($event)" threshold="150px" [disabled]="limit() >= filteredCanti().length || (playlistService.selectionMode() && !isAddingSongs())">
|
<ion-infinite-scroll (ionInfinite)="loadData($event)" threshold="150px" [disabled]="(comunitaService.isFilterActive() && comunitaService.comunitaCode()) || limit() >= filteredCanti().length || (playlistService.selectionMode() && !isAddingSongs())">
|
||||||
<ion-infinite-scroll-content loadingSpinner="bubbles" loadingText="Caricamento altri canti...">
|
<ion-infinite-scroll-content loadingSpinner="bubbles" loadingText="Caricamento altri canti...">
|
||||||
</ion-infinite-scroll-content>
|
</ion-infinite-scroll-content>
|
||||||
</ion-infinite-scroll>
|
</ion-infinite-scroll>
|
||||||
|
|||||||
@@ -148,10 +148,9 @@ ion-item.glass {
|
|||||||
--padding-start: 16px;
|
--padding-start: 16px;
|
||||||
--inner-padding-end: 16px;
|
--inner-padding-end: 16px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
transition: transform 0.2s ease, background 0.2s ease;
|
transition: background 0.2s ease;
|
||||||
|
|
||||||
&:active {
|
&:active {
|
||||||
transform: scale(0.98);
|
|
||||||
--background: rgba(255, 255, 255, 0.1);
|
--background: rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -426,7 +425,8 @@ ion-title {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
.selection-column {
|
.selection-column {
|
||||||
padding: 10px 14px 10px 0;
|
padding: 12px 18px 12px 12px;
|
||||||
|
margin-left: -12px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -443,6 +443,11 @@ ion-title {
|
|||||||
padding: 10px 0;
|
padding: 10px 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
.top-row {
|
.top-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -427,6 +427,9 @@ export class HomePage implements OnDestroy {
|
|||||||
if (this.playlistService.selectionMode() && !this.isAddingSongs()) {
|
if (this.playlistService.selectionMode() && !this.isAddingSongs()) {
|
||||||
return this.filteredCanti();
|
return this.filteredCanti();
|
||||||
}
|
}
|
||||||
|
if (this.comunitaService.isFilterActive() && this.comunitaService.comunitaCode()) {
|
||||||
|
return this.filteredCanti();
|
||||||
|
}
|
||||||
return this.filteredCanti().slice(0, this.limit());
|
return this.filteredCanti().slice(0, this.limit());
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1647,6 +1650,9 @@ export class HomePage implements OnDestroy {
|
|||||||
const songSettings = pl ? pl.songSettings : undefined;
|
const songSettings = pl ? pl.songSettings : undefined;
|
||||||
await this.playlistService.savePlaylist(data.name, ids, songSettings);
|
await this.playlistService.savePlaylist(data.name, ids, songSettings);
|
||||||
|
|
||||||
|
// Azzoppa / azzera il filtro cerca libera quando si salva
|
||||||
|
this.searchQuery.set('');
|
||||||
|
|
||||||
const toast = await this.toastCtrl.create({
|
const toast = await this.toastCtrl.create({
|
||||||
message: 'Playlist salvata!',
|
message: 'Playlist salvata!',
|
||||||
duration: 2000,
|
duration: 2000,
|
||||||
|
|||||||
@@ -53,6 +53,7 @@
|
|||||||
min-height: 0.5em;
|
min-height: 0.5em;
|
||||||
font-family: 'Outfit', sans-serif;
|
font-family: 'Outfit', sans-serif;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
|
padding-right: 0.25em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.active-line .chord {
|
.active-line .chord {
|
||||||
|
|||||||
@@ -200,6 +200,7 @@
|
|||||||
color: var(--ion-color-secondary);
|
color: var(--ion-color-secondary);
|
||||||
height: 1.2em;
|
height: 1.2em;
|
||||||
margin-bottom: -0.2em;
|
margin-bottom: -0.2em;
|
||||||
|
padding-right: 0.25em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.seg-text {
|
.seg-text {
|
||||||
|
|||||||
@@ -1072,8 +1072,10 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
// Try to fetch YouTube thumbnail first if available
|
// Try to fetch YouTube thumbnail first if available
|
||||||
if (thumbUrl) {
|
if (thumbUrl) {
|
||||||
try {
|
try {
|
||||||
|
// Use images.weserv.nl as a CORS proxy to bypass YouTube's CORS policy
|
||||||
|
const proxiedUrl = `https://images.weserv.nl/?url=${encodeURIComponent(thumbUrl)}`;
|
||||||
const response = await Promise.race([
|
const response = await Promise.race([
|
||||||
fetch(thumbUrl),
|
fetch(proxiedUrl),
|
||||||
new Promise<Response>((_, reject) => setTimeout(() => reject(new Error('Timeout')), 3000))
|
new Promise<Response>((_, reject) => setTimeout(() => reject(new Error('Timeout')), 3000))
|
||||||
]);
|
]);
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@@ -1083,10 +1085,8 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[Share] Failed to fetch YouTube thumbnail due to CORS or timeout:', e);
|
console.warn('[Share] Failed to fetch YouTube thumbnail due to CORS or timeout:', e);
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
|
// If no YouTube thumb is available, fallback to local canticristiani logo (favicon)
|
||||||
// If no YouTube thumb or fetch failed, fallback to local canticristiani logo (favicon)
|
|
||||||
if (!fileToShare) {
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('assets/icon/favicon.png');
|
const response = await fetch('assets/icon/favicon.png');
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@@ -1106,7 +1106,10 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
url: shareLink
|
url: shareLink
|
||||||
};
|
};
|
||||||
|
|
||||||
if (fileToShare && navigator.canShare && navigator.canShare({ files: [fileToShare] })) {
|
const isMac = /Macintosh|MacIntel|MacPPC|Mac68K/.test(navigator.userAgent) ||
|
||||||
|
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||||
|
|
||||||
|
if (!isMac && fileToShare && navigator.canShare && navigator.canShare({ files: [fileToShare] })) {
|
||||||
shareDataObj.files = [fileToShare];
|
shareDataObj.files = [fileToShare];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -178,7 +178,10 @@ export class PlaylistPage {
|
|||||||
const blob = await res.blob();
|
const blob = await res.blob();
|
||||||
const file = new File([blob], fileName, { type: 'image/png' });
|
const file = new File([blob], fileName, { type: 'image/png' });
|
||||||
|
|
||||||
if (navigator.share && navigator.canShare && navigator.canShare({ files: [file] })) {
|
const isMac = /Macintosh|MacIntel|MacPPC|Mac68K/.test(navigator.userAgent) ||
|
||||||
|
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||||
|
|
||||||
|
if (!isMac && navigator.share && navigator.canShare && navigator.canShare({ files: [file] })) {
|
||||||
await navigator.share({
|
await navigator.share({
|
||||||
files: [file],
|
files: [file],
|
||||||
title: 'Playlist CantiCristiani',
|
title: 'Playlist CantiCristiani',
|
||||||
|
|||||||
@@ -16,8 +16,8 @@
|
|||||||
|
|
||||||
<div class="drag-overlay" *ngIf="isDraggingOver">
|
<div class="drag-overlay" *ngIf="isDraggingOver">
|
||||||
<div class="drag-message">
|
<div class="drag-message">
|
||||||
<ion-icon name="image-outline"></ion-icon>
|
<ion-icon name="document-text-outline"></ion-icon>
|
||||||
<p>Rilascia l'immagine qui per estrarre il testo</p>
|
<p>Rilascia l'immagine o il PDF qui per estrarre il testo</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -109,6 +109,9 @@
|
|||||||
<ion-button fill="clear" size="small" (click)="takePhoto()">
|
<ion-button fill="clear" size="small" (click)="takePhoto()">
|
||||||
<ion-icon name="camera-outline"></ion-icon>
|
<ion-icon name="camera-outline"></ion-icon>
|
||||||
</ion-button>
|
</ion-button>
|
||||||
|
<ion-button fill="clear" size="small" (click)="chooseFile()">
|
||||||
|
<ion-icon name="document-attach-outline"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ion-item class="custom-input-item textarea-item">
|
<ion-item class="custom-input-item textarea-item">
|
||||||
@@ -191,4 +194,5 @@
|
|||||||
|
|
||||||
<!-- Hidden inputs -->
|
<!-- Hidden inputs -->
|
||||||
<input type="file" #cameraInput (change)="onFileSelected($event, true)" accept="image/*" capture="camera" style="display: none;">
|
<input type="file" #cameraInput (change)="onFileSelected($event, true)" accept="image/*" capture="camera" style="display: none;">
|
||||||
|
<input type="file" #fileInput (change)="onFileSelected($event, false)" accept="image/*,application/pdf" style="display: none;">
|
||||||
</ion-content>
|
</ion-content>
|
||||||
|
|||||||
@@ -615,6 +615,7 @@ body.high-contrast :host ::ng-deep {
|
|||||||
color: var(--ion-color-secondary);
|
color: var(--ion-color-secondary);
|
||||||
height: 1.25em;
|
height: 1.25em;
|
||||||
margin-bottom: -0.2em;
|
margin-bottom: -0.2em;
|
||||||
|
padding-right: 0.25em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-seg-text {
|
.preview-seg-text {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
import { ProposeCantoPage } from './propose-canto.page';
|
import { ProposeCantoPage } from './propose-canto.page';
|
||||||
import { ToastController, PopoverController, NavController, AngularDelegate } from '@ionic/angular';
|
import { ToastController, PopoverController, NavController, AngularDelegate, AlertController } from '@ionic/angular';
|
||||||
import { CantiService } from '../../services/canti.service';
|
import { CantiService } from '../../services/canti.service';
|
||||||
import { MyCantiService } from '../../services/my-canti.service';
|
import { MyCantiService } from '../../services/my-canti.service';
|
||||||
import { PlaylistService } from '../../services/playlist.service';
|
import { PlaylistService } from '../../services/playlist.service';
|
||||||
@@ -41,6 +41,7 @@ describe('ProposeCantoPage', () => {
|
|||||||
const navCtrlMock = {};
|
const navCtrlMock = {};
|
||||||
const toastControllerMock = {};
|
const toastControllerMock = {};
|
||||||
const popoverControllerMock = {};
|
const popoverControllerMock = {};
|
||||||
|
const alertControllerMock = {};
|
||||||
const angularDelegateMock = {};
|
const angularDelegateMock = {};
|
||||||
|
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
@@ -55,7 +56,8 @@ describe('ProposeCantoPage', () => {
|
|||||||
{ provide: NavController, useValue: navCtrlMock },
|
{ provide: NavController, useValue: navCtrlMock },
|
||||||
{ provide: ToastController, useValue: toastControllerMock },
|
{ provide: ToastController, useValue: toastControllerMock },
|
||||||
{ provide: PopoverController, useValue: popoverControllerMock },
|
{ provide: PopoverController, useValue: popoverControllerMock },
|
||||||
{ provide: AngularDelegate, useValue: angularDelegateMock }
|
{ provide: AngularDelegate, useValue: angularDelegateMock },
|
||||||
|
{ provide: AlertController, useValue: alertControllerMock }
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -124,4 +126,26 @@ describe('ProposeCantoPage', () => {
|
|||||||
expect(component.sanitizeOcrChord('D0/FH#')).toBe('DO/F#');
|
expect(component.sanitizeOcrChord('D0/FH#')).toBe('DO/F#');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('isChordWord', () => {
|
||||||
|
it('should identify Italian and English chords correctly based on mode', () => {
|
||||||
|
expect(component.isChordWord('DO', true)).toBe(true);
|
||||||
|
expect(component.isChordWord('DO', false)).toBe(true);
|
||||||
|
expect(component.isChordWord('C', true)).toBe(false); // English chord in Italian notation -> false
|
||||||
|
expect(component.isChordWord('C', false)).toBe(true); // English chord in English notation -> true
|
||||||
|
expect(component.isChordWord('Lan', true)).toBe(true);
|
||||||
|
expect(component.isChordWord('Lan', false)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isItalianNotation', () => {
|
||||||
|
it('should detect Italian notation if DO/RE/MI/SOL/SI are present', () => {
|
||||||
|
const words = [{ text: 'Lan' }, { text: 'FA' }, { text: 'DO' }, { text: 'SOL' }];
|
||||||
|
expect(component.isItalianNotation(words)).toBe(true);
|
||||||
|
});
|
||||||
|
it('should return false if only English-like or non-italian chords are present', () => {
|
||||||
|
const words = [{ text: 'C' }, { text: 'G' }, { text: 'D' }];
|
||||||
|
expect(component.isItalianNotation(words)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Component, OnInit, ViewChild, ElementRef, inject } from '@angular/core';
|
import { Component, OnInit, ViewChild, ElementRef, inject } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { IonicModule, ToastController, IonTextarea, PopoverController, NavController } from '@ionic/angular';
|
import { IonicModule, ToastController, IonTextarea, PopoverController, NavController, AlertController } from '@ionic/angular';
|
||||||
import { createWorker } from 'tesseract.js';
|
import { createWorker } from 'tesseract.js';
|
||||||
import { CantiService } from '../../services/canti.service';
|
import { CantiService } from '../../services/canti.service';
|
||||||
import { MyCantiService } from '../../services/my-canti.service';
|
import { MyCantiService } from '../../services/my-canti.service';
|
||||||
@@ -20,6 +20,7 @@ import { LyricsParserService, ParsedSection } from '../../services/lyrics-parser
|
|||||||
export class ProposeCantoPage implements OnInit {
|
export class ProposeCantoPage implements OnInit {
|
||||||
@ViewChild('contentTextarea', { static: false }) contentTextarea!: IonTextarea;
|
@ViewChild('contentTextarea', { static: false }) contentTextarea!: IonTextarea;
|
||||||
@ViewChild('cameraInput', { static: false }) cameraInput!: ElementRef;
|
@ViewChild('cameraInput', { static: false }) cameraInput!: ElementRef;
|
||||||
|
@ViewChild('fileInput', { static: false }) fileInput!: ElementRef;
|
||||||
|
|
||||||
public cantiService = inject(CantiService);
|
public cantiService = inject(CantiService);
|
||||||
private myCantiService = inject(MyCantiService);
|
private myCantiService = inject(MyCantiService);
|
||||||
@@ -29,6 +30,7 @@ export class ProposeCantoPage implements OnInit {
|
|||||||
private route = inject(ActivatedRoute);
|
private route = inject(ActivatedRoute);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
public lyricsParser = inject(LyricsParserService);
|
public lyricsParser = inject(LyricsParserService);
|
||||||
|
private alertCtrl = inject(AlertController);
|
||||||
|
|
||||||
showChordsPreview: boolean = true;
|
showChordsPreview: boolean = true;
|
||||||
|
|
||||||
@@ -189,6 +191,10 @@ export class ProposeCantoPage implements OnInit {
|
|||||||
this.cameraInput.nativeElement.click();
|
this.cameraInput.nativeElement.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
chooseFile() {
|
||||||
|
this.fileInput.nativeElement.click();
|
||||||
|
}
|
||||||
|
|
||||||
onDragOver(event: DragEvent) {
|
onDragOver(event: DragEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -212,11 +218,12 @@ export class ProposeCantoPage implements OnInit {
|
|||||||
|
|
||||||
if (event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length > 0) {
|
if (event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length > 0) {
|
||||||
const file = event.dataTransfer.files[0];
|
const file = event.dataTransfer.files[0];
|
||||||
if (file.type.indexOf('image') !== -1) {
|
const isPdf = file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf');
|
||||||
await this.processImageFile(file);
|
if (file.type.indexOf('image') !== -1 || isPdf) {
|
||||||
|
await this.processFile(file);
|
||||||
} else {
|
} else {
|
||||||
const toast = await this.toastController.create({
|
const toast = await this.toastController.create({
|
||||||
message: 'Per favore, trascina un file immagine valido.',
|
message: 'Per favore, trascina un file immagine o PDF valido.',
|
||||||
duration: 3000,
|
duration: 3000,
|
||||||
color: 'warning'
|
color: 'warning'
|
||||||
});
|
});
|
||||||
@@ -231,7 +238,7 @@ export class ProposeCantoPage implements OnInit {
|
|||||||
console.log('[OCR-Capture] Nessun file selezionato.');
|
console.log('[OCR-Capture] Nessun file selezionato.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this.processImageFile(file);
|
await this.processFile(file);
|
||||||
event.target.value = '';
|
event.target.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,26 +246,310 @@ export class ProposeCantoPage implements OnInit {
|
|||||||
const items = event.clipboardData?.items;
|
const items = event.clipboardData?.items;
|
||||||
if (!items) return;
|
if (!items) return;
|
||||||
|
|
||||||
|
let hasImage = false;
|
||||||
for (let i = 0; i < items.length; i++) {
|
for (let i = 0; i < items.length; i++) {
|
||||||
if (items[i].type.indexOf('image') !== -1) {
|
if (items[i].type.indexOf('image') !== -1) {
|
||||||
|
hasImage = true;
|
||||||
event.preventDefault(); // Prevent pasting the image representation as text
|
event.preventDefault(); // Prevent pasting the image representation as text
|
||||||
const blob = items[i].getAsFile();
|
const blob = items[i].getAsFile();
|
||||||
if (blob) {
|
if (blob) {
|
||||||
const file = new File([blob], 'pasted-image.png', { type: blob.type });
|
const file = new File([blob], 'pasted-image.png', { type: blob.type });
|
||||||
await this.processImageFile(file);
|
await this.processFile(file);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!hasImage) {
|
||||||
|
const pastedText = event.clipboardData?.getData('text');
|
||||||
|
if (pastedText && this.looksLikeChordSheetOrSong(pastedText)) {
|
||||||
|
event.preventDefault();
|
||||||
|
const parsed = this.parsePastedChordSheet(pastedText);
|
||||||
|
this.insertText(parsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async processImageFile(file: File) {
|
isTextLineChords(line: string, isItalian: boolean): boolean {
|
||||||
console.log(`[OCR-Capture] File selezionato: nome="${file.name}", tipo="${file.type}", dimensione=${(file.size / (1024 * 1024)).toFixed(2)} MB`);
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) return false;
|
||||||
|
const tokens = trimmed.split(/\s+/);
|
||||||
|
let chordCount = 0;
|
||||||
|
let nonChordWordCount = 0;
|
||||||
|
|
||||||
|
for (const token of tokens) {
|
||||||
|
let clean = token.toUpperCase().replace(/\s+/g, '').replace(/\((.*?)\)/g, '/$1').replace(/[\.\,\[\]]/g, '');
|
||||||
|
clean = this.sanitizeOcrChord(clean);
|
||||||
|
if (this.isChordWord(clean, isItalian)) {
|
||||||
|
chordCount++;
|
||||||
|
} else {
|
||||||
|
if (clean.length > 4) {
|
||||||
|
nonChordWordCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tokens.length === 0) return false;
|
||||||
|
const ratio = chordCount / tokens.length;
|
||||||
|
return ratio >= 0.6 && nonChordWordCount === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSectionType(line: string): 'chorus' | 'verse' | 'intro' | 'none' {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (/^(ritornello|rit|chorus|refrain|coro)/i.test(trimmed)) {
|
||||||
|
return 'chorus';
|
||||||
|
}
|
||||||
|
if (/^(verso|strofa|verse|strophe|\d+(\.)?)/i.test(trimmed)) {
|
||||||
|
return 'verse';
|
||||||
|
}
|
||||||
|
if (/^(intro|introduzione|bridge|special|outro|strum)/i.test(trimmed)) {
|
||||||
|
return 'intro';
|
||||||
|
}
|
||||||
|
return 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
looksLikeChordSheetOrSong(text: string): boolean {
|
||||||
|
const lines = text.split('\n');
|
||||||
|
if (lines.length < 2) return false;
|
||||||
|
|
||||||
|
const words = text.split(/\s+/).map(t => ({ text: t }));
|
||||||
|
const isItalian = this.isItalianNotation(words);
|
||||||
|
|
||||||
|
let chordLinesCount = 0;
|
||||||
|
let sectionMarkersCount = 0;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (this.getSectionType(line) !== 'none') {
|
||||||
|
sectionMarkersCount++;
|
||||||
|
}
|
||||||
|
if (this.isTextLineChords(line, isItalian)) {
|
||||||
|
chordLinesCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return chordLinesCount > 0 || sectionMarkersCount > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
convertPureChordLine(line: string, isItalian: boolean): string {
|
||||||
|
return line.replace(/\S+/g, (match) => {
|
||||||
|
let clean = match.toUpperCase().replace(/\s+/g, '').replace(/\((.*?)\)/g, '/$1').replace(/[\.\,\[\]]/g, '');
|
||||||
|
clean = this.sanitizeOcrChord(clean);
|
||||||
|
if (this.isChordWord(clean, isItalian)) {
|
||||||
|
return `[${this.convertEnglishChordToItalian(clean, isItalian)}]`;
|
||||||
|
}
|
||||||
|
return match;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
parsePastedChordSheet(text: string): string {
|
||||||
|
const lines = text.split('\n');
|
||||||
|
const processedLines: string[] = [];
|
||||||
|
|
||||||
|
const words = text.split(/\s+/).map(t => ({ text: t }));
|
||||||
|
const isItalian = this.isItalianNotation(words);
|
||||||
|
|
||||||
|
let inVerse = false;
|
||||||
|
let inChorus = false;
|
||||||
|
let hasAccumulatedLines = false;
|
||||||
|
|
||||||
|
const closeSection = () => {
|
||||||
|
if (inChorus) {
|
||||||
|
processedLines.push('{end_chorus}');
|
||||||
|
inChorus = false;
|
||||||
|
}
|
||||||
|
if (inVerse) {
|
||||||
|
processedLines.push('{end_verse}');
|
||||||
|
inVerse = false;
|
||||||
|
}
|
||||||
|
hasAccumulatedLines = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i];
|
||||||
|
const trimmed = line.trim();
|
||||||
|
|
||||||
|
if (!trimmed) {
|
||||||
|
if (hasAccumulatedLines) {
|
||||||
|
closeSection();
|
||||||
|
processedLines.push('');
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sectionType = this.getSectionType(line);
|
||||||
|
if (sectionType !== 'none') {
|
||||||
|
closeSection();
|
||||||
|
if (sectionType === 'chorus') {
|
||||||
|
processedLines.push('{start_chorus}');
|
||||||
|
inChorus = true;
|
||||||
|
} else {
|
||||||
|
processedLines.push('{start_verse}');
|
||||||
|
inVerse = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no section is open, open a default one (verse)
|
||||||
|
if (!inChorus && !inVerse) {
|
||||||
|
processedLines.push('{start_verse}');
|
||||||
|
inVerse = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this line is chords
|
||||||
|
const isChords = this.isTextLineChords(line, isItalian);
|
||||||
|
|
||||||
|
if (isChords) {
|
||||||
|
// Look ahead to see if the next line is lyrics (not empty, not chords, not section marker)
|
||||||
|
let nextLine = '';
|
||||||
|
let nextLineIndex = i + 1;
|
||||||
|
while (nextLineIndex < lines.length) {
|
||||||
|
const nextTrimmed = lines[nextLineIndex].trim();
|
||||||
|
if (nextTrimmed) {
|
||||||
|
if (this.getSectionType(lines[nextLineIndex]) === 'none' && !this.isTextLineChords(lines[nextLineIndex], isItalian)) {
|
||||||
|
nextLine = lines[nextLineIndex];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
nextLineIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextLine) {
|
||||||
|
// Merge spatially!
|
||||||
|
const chords: { text: string; index: number }[] = [];
|
||||||
|
const regex = /\S+/g;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
while ((match = regex.exec(line)) !== null) {
|
||||||
|
let clean = match[0].toUpperCase().replace(/\s+/g, '').replace(/\((.*?)\)/g, '/$1').replace(/[\.\,\[\]]/g, '');
|
||||||
|
clean = this.sanitizeOcrChord(clean);
|
||||||
|
if (this.isChordWord(clean, isItalian)) {
|
||||||
|
chords.push({
|
||||||
|
text: `[${this.convertEnglishChordToItalian(clean, isItalian)}]`,
|
||||||
|
index: match.index
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
chords.push({
|
||||||
|
text: match[0],
|
||||||
|
index: match.index
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge chords with the next line (lyrics)
|
||||||
|
let merged = '';
|
||||||
|
let lyricIdx = 0;
|
||||||
|
let chordIdx = 0;
|
||||||
|
while (lyricIdx < nextLine.length || chordIdx < chords.length) {
|
||||||
|
if (chordIdx < chords.length && (lyricIdx === chords[chordIdx].index || lyricIdx >= nextLine.length)) {
|
||||||
|
merged += chords[chordIdx].text;
|
||||||
|
chordIdx++;
|
||||||
|
} else {
|
||||||
|
merged += nextLine[lyricIdx];
|
||||||
|
lyricIdx++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
processedLines.push(merged);
|
||||||
|
hasAccumulatedLines = true;
|
||||||
|
|
||||||
|
// Skip the next line since we consumed it
|
||||||
|
i = nextLineIndex;
|
||||||
|
} else {
|
||||||
|
// No lyrics line follows, just convert chords in place
|
||||||
|
processedLines.push(this.convertPureChordLine(line, isItalian));
|
||||||
|
hasAccumulatedLines = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Plain text line
|
||||||
|
processedLines.push(this.wrapChords(line, this.getChordRegex(isItalian), isItalian));
|
||||||
|
hasAccumulatedLines = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
closeSection();
|
||||||
|
|
||||||
|
return processedLines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
async convertPdfToImages(file: File): Promise<Blob[]> {
|
||||||
|
console.log('[OCR-Capture] Caricamento PDF per conversione in immagini...');
|
||||||
|
const pdfjsLib = await import('pdfjs-dist');
|
||||||
|
pdfjsLib.GlobalWorkerOptions.workerSrc = 'assets/pdf.worker.min.js';
|
||||||
|
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
const loadingTask = pdfjsLib.getDocument({ data: arrayBuffer });
|
||||||
|
const pdf = await loadingTask.promise;
|
||||||
|
|
||||||
|
console.log(`[OCR-Capture] PDF caricato con successo. Numero di pagine: ${pdf.numPages}`);
|
||||||
|
const blobs: Blob[] = [];
|
||||||
|
|
||||||
|
// Limit to maximum 5 pages to avoid extreme memory consumption or timeouts
|
||||||
|
const pagesToRender = Math.min(pdf.numPages, 5);
|
||||||
|
for (let i = 1; i <= pagesToRender; i++) {
|
||||||
|
console.log(`[OCR-Capture] Rendering pagina ${i}/${pagesToRender}...`);
|
||||||
|
const page = await pdf.getPage(i);
|
||||||
|
const viewport = page.getViewport({ scale: 2.0 }); // High resolution scale for OCR
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = viewport.width;
|
||||||
|
canvas.height = viewport.height;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('Impossibile ottenere il context 2D per il canvas.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.render({
|
||||||
|
canvasContext: ctx,
|
||||||
|
viewport: viewport,
|
||||||
|
canvas: canvas
|
||||||
|
}).promise;
|
||||||
|
|
||||||
|
const blob = await new Promise<Blob | null>((resolve) => {
|
||||||
|
canvas.toBlob(b => resolve(b), 'image/jpeg', 0.95);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (blob) {
|
||||||
|
blobs.push(blob);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return blobs;
|
||||||
|
}
|
||||||
|
|
||||||
|
async processFile(file: File) {
|
||||||
|
const isPdf = file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf');
|
||||||
|
console.log(`[OCR-Capture] File selezionato: nome="${file.name}", tipo="${file.type}", dimensione=${(file.size / (1024 * 1024)).toFixed(2)} MB, è PDF: ${isPdf}`);
|
||||||
|
|
||||||
this.isProcessingOCR = true;
|
this.isProcessingOCR = true;
|
||||||
this.ocrProgress = 0;
|
this.ocrProgress = 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (isPdf) {
|
||||||
|
const pageBlobs = await this.convertPdfToImages(file);
|
||||||
|
console.log(`[OCR-Capture] PDF convertito in ${pageBlobs.length} immagini.`);
|
||||||
|
|
||||||
|
for (let idx = 0; idx < pageBlobs.length; idx++) {
|
||||||
|
const pageBlob = pageBlobs[idx];
|
||||||
|
const pageFile = new File([pageBlob], `page_${idx + 1}.jpg`, { type: 'image/jpeg' });
|
||||||
|
console.log(`[OCR-Capture] Elaborazione pagina ${idx + 1}/${pageBlobs.length} via OCR...`);
|
||||||
|
|
||||||
|
this.ocrProgress = (idx / pageBlobs.length);
|
||||||
|
const extractedText = await this.processImageOCR(pageFile);
|
||||||
|
|
||||||
|
if (extractedText) {
|
||||||
|
console.log(`[OCR-Capture] Pagina ${idx + 1} estratta con successo.`);
|
||||||
|
this.content += (this.content ? '\n\n' : '') + extractedText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toast = await this.toastController.create({
|
||||||
|
message: 'Scansione PDF completata!',
|
||||||
|
duration: 2000,
|
||||||
|
color: 'success'
|
||||||
|
});
|
||||||
|
toast.present();
|
||||||
|
|
||||||
|
} else {
|
||||||
console.log('[OCR-Capture] Immagine/Fotocamera rilevata. Avvio ridimensionamento...');
|
console.log('[OCR-Capture] Immagine/Fotocamera rilevata. Avvio ridimensionamento...');
|
||||||
const compressedBlob = await this.resizeImage(file);
|
const compressedBlob = await this.resizeImage(file);
|
||||||
const compressedFile = new File([compressedBlob], file.name, { type: 'image/jpeg' });
|
const compressedFile = new File([compressedBlob], file.name, { type: 'image/jpeg' });
|
||||||
@@ -279,10 +570,11 @@ export class ProposeCantoPage implements OnInit {
|
|||||||
} else {
|
} else {
|
||||||
console.warn('[OCR-Capture] Nessun testo estratto dal file.');
|
console.warn('[OCR-Capture] Nessun testo estratto dal file.');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[OCR-Capture] Errore durante l\'elaborazione del file:', error);
|
console.error('[OCR-Capture] Errore durante l\'elaborazione del file:', error);
|
||||||
const errorToast = await this.toastController.create({
|
const errorToast = await this.toastController.create({
|
||||||
message: 'Errore durante la scansione dell\'immagine.',
|
message: 'Errore durante la scansione del file.',
|
||||||
duration: 3000,
|
duration: 3000,
|
||||||
color: 'danger'
|
color: 'danger'
|
||||||
});
|
});
|
||||||
@@ -383,6 +675,13 @@ export class ProposeCantoPage implements OnInit {
|
|||||||
|
|
||||||
sanitizeOcrChord(text: string): string {
|
sanitizeOcrChord(text: string): string {
|
||||||
if (!text) return text;
|
if (!text) return text;
|
||||||
|
|
||||||
|
// First, heal separators like I, 1, l, |, \ between chords/notes to '/'
|
||||||
|
const separatorRegex = /\b([A-G]|DO|RE|MI|FA|SOL|LA|SI)(#|b|♭)?(m|min|maj|dim|aug|sus)?(\d*)([I1l|\\\/])([A-G]|DO|RE|MI|FA|SOL|LA|SI|\d+)(#|b|♭)?\b/gi;
|
||||||
|
text = text.replace(separatorRegex, (match, p1, p2, p3, p4, sep, p5, p6) => {
|
||||||
|
return `${p1}${p2 || ''}${p3 || ''}${p4 || ''}/${p5}${p6 || ''}`;
|
||||||
|
});
|
||||||
|
|
||||||
if (text.includes('/')) {
|
if (text.includes('/')) {
|
||||||
return text.split('/').map(part => this.sanitizeOcrChord(part.trim())).join('/');
|
return text.split('/').map(part => this.sanitizeOcrChord(part.trim())).join('/');
|
||||||
}
|
}
|
||||||
@@ -397,13 +696,137 @@ export class ProposeCantoPage implements OnInit {
|
|||||||
cleaned = cleaned.replace(/([CDEFGAB]|DO|RE|MI|FA|SOL|LA|SI)H/gi, '$1#');
|
cleaned = cleaned.replace(/([CDEFGAB]|DO|RE|MI|FA|SOL|LA|SI)H/gi, '$1#');
|
||||||
// Clean duplicate sharps (e.g. ## -> #)
|
// Clean duplicate sharps (e.g. ## -> #)
|
||||||
cleaned = cleaned.replace(/##+/g, '#');
|
cleaned = cleaned.replace(/##+/g, '#');
|
||||||
|
|
||||||
|
// Clean duplicate chord letters at start (e.g. Ff#m -> F#m)
|
||||||
|
cleaned = cleaned.replace(/^([CDEFGAB])\1/gi, '$1');
|
||||||
|
|
||||||
|
// Convert Italian chords ending in 'M' or 'N' (e.g. LAM -> LAm, LAN -> LAm, LAN7 -> LAm7) to lowercase 'm'
|
||||||
|
cleaned = cleaned.replace(/\b(DO|RE|MI|FA|SOL|LA|SI)(#|b|♭)?[mMnN](\d*)\b/g, (match, p1, p2, p3) => {
|
||||||
|
return p1 + (p2 || '') + 'm' + (p3 || '');
|
||||||
|
});
|
||||||
|
|
||||||
return cleaned;
|
return cleaned;
|
||||||
}
|
}
|
||||||
|
|
||||||
convertEnglishChordToItalian(chord: string): string {
|
getChordRegex(isItalian: boolean): RegExp {
|
||||||
|
if (isItalian) {
|
||||||
|
return /^(DO|RE|MI|FA|SOL|LA|SI)(#|B)?(M|-|MIN|MAJ|AUG|DIM|4|5|6)?(7|9|11|13)?(\/(DO|RE|MI|FA|SOL|LA|SI|\d+)(#|B|b)?)?$/i;
|
||||||
|
} else {
|
||||||
|
return /^(DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B)(#|B)?(M|-|MIN|MAJ|AUG|DIM|4|5|6)?(7|9|11|13)?(\/(DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B|\d+)(#|B|b)?)?$/i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getMultiChordRegex(isItalian: boolean): RegExp {
|
||||||
|
if (isItalian) {
|
||||||
|
return /((?:DO|RE|MI|FA|SOL|LA|SI)(?:#|B)?(?:MIN|MAJ|AUG|DIM|M|-|4|5|6)?(?:7|9|11|13)?(?:\/(?:DO|RE|MI|FA|SOL|LA|SI|\d+)(?:#|B|b)?)?)/gi;
|
||||||
|
} else {
|
||||||
|
return /((?:DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B)(?:#|B)?(?:MIN|MAJ|AUG|DIM|M|-|4|5|6)?(?:7|9|11|13)?(?:\/(?:DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B|\d+)(?:#|B|b)?)?)/gi;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isItalianNotation(words: any[]): boolean {
|
||||||
|
if (!words || words.length === 0) return false;
|
||||||
|
|
||||||
|
// 1. Group words into horizontal lines
|
||||||
|
const heights = words.map(w => w.bbox ? (w.bbox.y1 - w.bbox.y0) : 10);
|
||||||
|
const avgHeight = heights.reduce((sum, h) => sum + h, 0) / heights.length;
|
||||||
|
const verticalTolerance = avgHeight * 1.0;
|
||||||
|
|
||||||
|
const lines: any[][] = [];
|
||||||
|
words.forEach(word => {
|
||||||
|
if (!word.text) return;
|
||||||
|
const wordYCenter = word.bbox ? ((word.bbox.y0 + word.bbox.y1) / 2) : 0;
|
||||||
|
let added = false;
|
||||||
|
for (const line of lines) {
|
||||||
|
const avgLineYCenter = line.reduce((sum, w) => sum + (w.bbox ? ((w.bbox.y0 + w.bbox.y1) / 2) : 0), 0) / line.length;
|
||||||
|
if (Math.abs(wordYCenter - avgLineYCenter) < verticalTolerance) {
|
||||||
|
line.push(word);
|
||||||
|
added = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!added) {
|
||||||
|
lines.push([word]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const genericChordRegex = /^(DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B)(#|B)?(M|-|MIN|MAJ|AUG|DIM|4|5|6)?(7|9|11|13)?(\/(DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B|\d+)(#|B|b)?)?$/i;
|
||||||
|
let englishChordsCount = 0;
|
||||||
|
let italianChordsCount = 0;
|
||||||
|
|
||||||
|
lines.forEach(line => {
|
||||||
|
let chordCount = 0;
|
||||||
|
line.forEach(w => {
|
||||||
|
let clean = w.text.toUpperCase().replace(/\s+/g, '').replace(/\((.*?)\)/g, '/$1').replace(/[\.\,\[\]]/g, '');
|
||||||
|
clean = this.sanitizeOcrChord(clean);
|
||||||
|
if (genericChordRegex.test(clean)) {
|
||||||
|
chordCount++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const ratio = line.length > 0 ? chordCount / line.length : 0;
|
||||||
|
if (ratio >= 0.5) {
|
||||||
|
line.forEach(w => {
|
||||||
|
let clean = w.text.toUpperCase().replace(/\s+/g, '').replace(/\((.*?)\)/g, '/$1').replace(/[\.\,\[\]]/g, '');
|
||||||
|
clean = this.sanitizeOcrChord(clean);
|
||||||
|
|
||||||
|
const isEnglish = /^(C|D|E|F|G|A|B)(#|B)?(M|-|MIN|MAJ|AUG|DIM|4|5|6)?(7|9|11|13)?(\/(C|D|E|F|G|A|B|\d+)(#|B|b)?)?$/i.test(clean);
|
||||||
|
const isItalian = /^(DO|RE|MI|FA|SOL|LA|SI)(#|B)?(M|-|MIN|MAJ|AUG|DIM|4|5|6)?(7|9|11|13)?(\/(DO|RE|MI|FA|SOL|LA|SI|\d+)(#|B|b)?)?$/i.test(clean);
|
||||||
|
|
||||||
|
if (isEnglish && !isItalian) {
|
||||||
|
englishChordsCount++;
|
||||||
|
} else if (isItalian && !isEnglish) {
|
||||||
|
italianChordsCount++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (englishChordsCount === 0 && italianChordsCount === 0) {
|
||||||
|
// Fallback: search the entire words list for unambiguous chords
|
||||||
|
words.forEach(w => {
|
||||||
|
if (!w.text) return;
|
||||||
|
let clean = w.text.toUpperCase().replace(/\s+/g, '').replace(/\((.*?)\)/g, '/$1').replace(/[\.\,\[\]]/g, '');
|
||||||
|
clean = this.sanitizeOcrChord(clean);
|
||||||
|
|
||||||
|
const isUnambiguousEnglish =
|
||||||
|
/^(C|D|G|B)(#|B)?(M|-|MIN|MAJ|AUG|DIM|4|5|6)?(7|9|11|13)?(\/(C|D|E|F|G|A|B|\d+)(#|B|b)?)?$/i.test(clean) ||
|
||||||
|
/^(A|E|F)(#|B|M|-|MIN|MAJ|AUG|DIM|4|5|6|7|9|11|13)/i.test(clean);
|
||||||
|
const isUnambiguousItalian =
|
||||||
|
/^(DO|RE|MI|FA|SOL|LA|SI)(#|B|M|-|MIN|MAJ|AUG|DIM|4|5|6|7|9|11|13)/i.test(clean);
|
||||||
|
|
||||||
|
if (isUnambiguousEnglish) englishChordsCount++;
|
||||||
|
if (isUnambiguousItalian) italianChordsCount++;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[OCR-Capture] Chord line analysis - English chords: ${englishChordsCount}, Italian chords: ${italianChordsCount}`);
|
||||||
|
return italianChordsCount > englishChordsCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
isChordWord(text: string, isItalian: boolean): boolean {
|
||||||
|
if (!text || text.length === 0) return false;
|
||||||
|
const lower = text.toLowerCase();
|
||||||
|
// Skip common valid words written purely in lowercase
|
||||||
|
if (text[0] === lower[0] && ['la', 'mi', 're', 'do', 'si', 'fa', 'e'].includes(lower)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let clean = text.toUpperCase().replace(/\s+/g, '').replace(/\((.*?)\)/g, '/$1').replace(/[\.\,\[\]]/g, '');
|
||||||
|
clean = this.sanitizeOcrChord(clean);
|
||||||
|
const chordRegex = this.getChordRegex(isItalian);
|
||||||
|
if (clean.includes('_')) {
|
||||||
|
const parts = clean.split('_').filter(p => p.length > 0);
|
||||||
|
if (parts.length === 0) return false;
|
||||||
|
return parts.every(p => chordRegex.test(p));
|
||||||
|
}
|
||||||
|
return chordRegex.test(clean);
|
||||||
|
}
|
||||||
|
|
||||||
|
convertEnglishChordToItalian(chord: string, isItalian: boolean = false): string {
|
||||||
|
if (isItalian) return chord;
|
||||||
if (!chord) return chord;
|
if (!chord) return chord;
|
||||||
if (chord.includes('/')) {
|
if (chord.includes('/')) {
|
||||||
return chord.split('/').map(part => this.convertEnglishChordToItalian(part.trim())).join('/');
|
return chord.split('/').map(part => this.convertEnglishChordToItalian(part.trim(), isItalian)).join('/');
|
||||||
}
|
}
|
||||||
const upper = chord.toUpperCase();
|
const upper = chord.toUpperCase();
|
||||||
if (upper.startsWith('DO')) {
|
if (upper.startsWith('DO')) {
|
||||||
@@ -449,6 +872,9 @@ export class ProposeCantoPage implements OnInit {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isItalian = this.isItalianNotation(words);
|
||||||
|
console.log(`[OCR-Capture] Rilevata notazione italiana: ${isItalian}`);
|
||||||
|
|
||||||
// Preprocess words to split run-together chords like BA
|
// Preprocess words to split run-together chords like BA
|
||||||
const preprocessedWords: any[] = [];
|
const preprocessedWords: any[] = [];
|
||||||
words.forEach(w => {
|
words.forEach(w => {
|
||||||
@@ -478,16 +904,17 @@ export class ProposeCantoPage implements OnInit {
|
|||||||
// Calculate average word height to set vertical tolerance
|
// Calculate average word height to set vertical tolerance
|
||||||
const heights = validWords.map(w => w.bbox.y1 - w.bbox.y0);
|
const heights = validWords.map(w => w.bbox.y1 - w.bbox.y0);
|
||||||
const avgHeight = heights.reduce((sum, h) => sum + h, 0) / heights.length;
|
const avgHeight = heights.reduce((sum, h) => sum + h, 0) / heights.length;
|
||||||
const verticalTolerance = avgHeight * 0.85;
|
const verticalTolerance = avgHeight * 1.0;
|
||||||
console.log(`[OCR-Capture] Altezza media carattere: ${avgHeight.toFixed(1)}px, tolleranza verticale: ${verticalTolerance.toFixed(1)}px`);
|
console.log(`[OCR-Capture] Altezza media carattere: ${avgHeight.toFixed(1)}px, tolleranza verticale: ${verticalTolerance.toFixed(1)}px`);
|
||||||
|
|
||||||
// 2. Group words into horizontal lines
|
// 2. Group words into horizontal lines
|
||||||
const lines: any[][] = [];
|
const lines: any[][] = [];
|
||||||
validWords.forEach(word => {
|
validWords.forEach(word => {
|
||||||
|
const wordYCenter = (word.bbox.y0 + word.bbox.y1) / 2;
|
||||||
let added = false;
|
let added = false;
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const avgLineY0 = line.reduce((sum, w) => sum + w.bbox.y0, 0) / line.length;
|
const avgLineYCenter = line.reduce((sum, w) => sum + (w.bbox.y0 + w.bbox.y1) / 2, 0) / line.length;
|
||||||
if (Math.abs(word.bbox.y0 - avgLineY0) < verticalTolerance) {
|
if (Math.abs(wordYCenter - avgLineYCenter) < verticalTolerance) {
|
||||||
line.push(word);
|
line.push(word);
|
||||||
added = true;
|
added = true;
|
||||||
break;
|
break;
|
||||||
@@ -509,32 +936,14 @@ export class ProposeCantoPage implements OnInit {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 3. Classify lines as Chords vs. Text
|
// 3. Classify lines as Chords vs. Text
|
||||||
const chordRegex = /^(DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B)(#|B)?(M|-|MIN|MAJ|AUG|DIM)?(7|9|11|13)?(\/(DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B)(#|B)?)?$/i;
|
const chordRegex = this.getChordRegex(isItalian);
|
||||||
const isChordWord = (text: string): boolean => {
|
|
||||||
if (!text || text.length === 0) return false;
|
|
||||||
const lower = text.toLowerCase();
|
|
||||||
// Skip common valid words written purely in lowercase
|
|
||||||
if (text[0] === lower[0] && ['la', 'mi', 're', 'do', 'si', 'fa', 'e'].includes(lower)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
let clean = text.toUpperCase().replace(/\s+/g, '');
|
|
||||||
clean = clean.replace(/\((.*?)\)/g, '/$1');
|
|
||||||
clean = clean.replace(/[\.\,]$/g, '');
|
|
||||||
clean = this.sanitizeOcrChord(clean);
|
|
||||||
if (clean.includes('_')) {
|
|
||||||
const parts = clean.split('_').filter(p => p.length > 0);
|
|
||||||
if (parts.length === 0) return false;
|
|
||||||
return parts.every(p => chordRegex.test(p));
|
|
||||||
}
|
|
||||||
return chordRegex.test(clean);
|
|
||||||
};
|
|
||||||
|
|
||||||
const classifiedLines = lines.map(line => {
|
const classifiedLines = lines.map(line => {
|
||||||
let chordCount = 0;
|
let chordCount = 0;
|
||||||
let hasLongNonChord = false;
|
let hasLongNonChord = false;
|
||||||
|
|
||||||
line.forEach(w => {
|
line.forEach(w => {
|
||||||
if (isChordWord(w.text)) {
|
if (this.isChordWord(w.text, isItalian)) {
|
||||||
chordCount++;
|
chordCount++;
|
||||||
} else {
|
} else {
|
||||||
const clean = w.text.replace(/[.,:;!\?]/g, '').trim();
|
const clean = w.text.replace(/[.,:;!\?]/g, '').trim();
|
||||||
@@ -574,7 +983,7 @@ export class ProposeCantoPage implements OnInit {
|
|||||||
const nextText = next ? next.words.map(w => w.text).join(' ') : '';
|
const nextText = next ? next.words.map(w => w.text).join(' ') : '';
|
||||||
if (next && !next.isChords && !this.isLabelLine(nextText)) {
|
if (next && !next.isChords && !this.isLabelLine(nextText)) {
|
||||||
// Merge spatially!
|
// Merge spatially!
|
||||||
const merged = this.mergeChordsAndLyrics(current.words, next.words);
|
const merged = this.mergeChordsAndLyrics(current.words, next.words, isItalian);
|
||||||
const lineText = next.words.map(w => w.text).join(' ');
|
const lineText = next.words.map(w => w.text).join(' ');
|
||||||
const isChorus = chorusStartRegex.test(lineText);
|
const isChorus = chorusStartRegex.test(lineText);
|
||||||
|
|
||||||
@@ -590,7 +999,7 @@ export class ProposeCantoPage implements OnInit {
|
|||||||
i++; // Skip next line because we consumed it!
|
i++; // Skip next line because we consumed it!
|
||||||
} else {
|
} else {
|
||||||
// Chord line but no text below it: just wrap and print
|
// Chord line but no text below it: just wrap and print
|
||||||
const multiChordRegex = /((?:DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B)(?:#|B)?(?:MIN|MAJ|AUG|DIM|M|-)?(?:7|9|11|13)?(?:\/(?:DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B)(?:#|B)?)?)/gi;
|
const multiChordRegex = this.getMultiChordRegex(isItalian);
|
||||||
const expandedChords: string[] = [];
|
const expandedChords: string[] = [];
|
||||||
current.words.forEach(w => {
|
current.words.forEach(w => {
|
||||||
const parts = w.text.split(/(_)/);
|
const parts = w.text.split(/(_)/);
|
||||||
@@ -603,13 +1012,13 @@ export class ProposeCantoPage implements OnInit {
|
|||||||
cleanText = cleanText.replace(/[\.\,]$/g, '');
|
cleanText = cleanText.replace(/[\.\,]$/g, '');
|
||||||
cleanText = this.sanitizeOcrChord(cleanText);
|
cleanText = this.sanitizeOcrChord(cleanText);
|
||||||
|
|
||||||
if (chordRegex.test(cleanText)) {
|
if (this.isChordWord(cleanText, isItalian)) {
|
||||||
return `[${this.convertEnglishChordToItalian(cleanText)}]`;
|
return `[${this.convertEnglishChordToItalian(cleanText, isItalian)}]`;
|
||||||
} else {
|
} else {
|
||||||
const matches = [...cleanText.matchAll(multiChordRegex)];
|
const matches = [...cleanText.matchAll(multiChordRegex)];
|
||||||
const fullMatchStr = matches.map(m => m[0]).join('');
|
const fullMatchStr = matches.map(m => m[0]).join('');
|
||||||
if (matches.length > 0 && fullMatchStr === cleanText) {
|
if (matches.length > 0 && fullMatchStr === cleanText) {
|
||||||
return matches.map((m: any) => `[${this.convertEnglishChordToItalian(m[0].toUpperCase())}]`).join(' ');
|
return matches.map((m: any) => `[${this.convertEnglishChordToItalian(m[0].toUpperCase(), isItalian)}]`).join(' ');
|
||||||
} else {
|
} else {
|
||||||
return part;
|
return part;
|
||||||
}
|
}
|
||||||
@@ -634,7 +1043,7 @@ export class ProposeCantoPage implements OnInit {
|
|||||||
inVerse = true;
|
inVerse = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
processedLines.push(this.wrapChords(lineText, chordRegex));
|
processedLines.push(this.wrapChords(lineText, chordRegex, isItalian));
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we see a large vertical gap, close open blocks
|
// If we see a large vertical gap, close open blocks
|
||||||
@@ -655,7 +1064,7 @@ export class ProposeCantoPage implements OnInit {
|
|||||||
return processedLines.join('\n');
|
return processedLines.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
mergeChordsAndLyrics(chordWords: any[], textWords: any[]): string {
|
mergeChordsAndLyrics(chordWords: any[], textWords: any[], isItalian: boolean = false): string {
|
||||||
// Pre-process chordWords to merge fragmented bass notes like 'Re', '(f', 'fa#)'
|
// Pre-process chordWords to merge fragmented bass notes like 'Re', '(f', 'fa#)'
|
||||||
let mergedChordWords: any[] = [];
|
let mergedChordWords: any[] = [];
|
||||||
for (let i = 0; i < chordWords.length; i++) {
|
for (let i = 0; i < chordWords.length; i++) {
|
||||||
@@ -711,13 +1120,16 @@ export class ProposeCantoPage implements OnInit {
|
|||||||
if (!part.trim()) return part;
|
if (!part.trim()) return part;
|
||||||
let clean = part.toUpperCase().replace(/\s+/g, '').replace(/\((.*?)\)/g, '/$1').replace(/[\.\,\[\]]/g, '');
|
let clean = part.toUpperCase().replace(/\s+/g, '').replace(/\((.*?)\)/g, '/$1').replace(/[\.\,\[\]]/g, '');
|
||||||
clean = this.sanitizeOcrChord(clean);
|
clean = this.sanitizeOcrChord(clean);
|
||||||
return `[${this.convertEnglishChordToItalian(clean)}]`;
|
if (this.isChordWord(part, isItalian)) {
|
||||||
|
return `[${this.convertEnglishChordToItalian(clean, isItalian)}]`;
|
||||||
|
} else {
|
||||||
|
return part;
|
||||||
|
}
|
||||||
}).join('');
|
}).join('');
|
||||||
}).join(' ');
|
}).join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
const chordRegex = /^(DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B)(#|B)?(M|-|MIN|MAJ|AUG|DIM)?(7|9|11|13)?(\/(DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B)(#|B)?)?$/i;
|
const multiChordRegex = this.getMultiChordRegex(isItalian);
|
||||||
const multiChordRegex = /((?:DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B)(?:#|B)?(?:MIN|MAJ|AUG|DIM|M|-)?(?:7|9|11|13)?(?:\/(?:DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B)(?:#|B)?)?)/gi;
|
|
||||||
|
|
||||||
const expandedChordWords: any[] = [];
|
const expandedChordWords: any[] = [];
|
||||||
mergedChordWords.forEach(chord => {
|
mergedChordWords.forEach(chord => {
|
||||||
@@ -822,7 +1234,11 @@ export class ProposeCantoPage implements OnInit {
|
|||||||
|
|
||||||
let cleanChord = chord.text.replace(/[\(\)\[\]]/g, '').toUpperCase();
|
let cleanChord = chord.text.replace(/[\(\)\[\]]/g, '').toUpperCase();
|
||||||
cleanChord = this.sanitizeOcrChord(cleanChord);
|
cleanChord = this.sanitizeOcrChord(cleanChord);
|
||||||
wordResult += wordText.substring(lastCharIndex, charIndex) + `[${this.convertEnglishChordToItalian(cleanChord)}]`;
|
if (this.isChordWord(chord.text, isItalian)) {
|
||||||
|
wordResult += wordText.substring(lastCharIndex, charIndex) + `[${this.convertEnglishChordToItalian(cleanChord, isItalian)}]`;
|
||||||
|
} else {
|
||||||
|
wordResult += wordText.substring(lastCharIndex, charIndex);
|
||||||
|
}
|
||||||
lastCharIndex = charIndex;
|
lastCharIndex = charIndex;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -851,7 +1267,12 @@ export class ProposeCantoPage implements OnInit {
|
|||||||
smartProcessOCR(text: string): string {
|
smartProcessOCR(text: string): string {
|
||||||
let lines = text.split('\n');
|
let lines = text.split('\n');
|
||||||
let processedLines: string[] = [];
|
let processedLines: string[] = [];
|
||||||
const chordRegex = /\b(DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B)(#|b)?(m|-|min|maj|aug|dim)?(7|9|11|13)?\b/gi;
|
|
||||||
|
const words = text.split(/\s+/).map(t => ({ text: t }));
|
||||||
|
const isItalian = this.isItalianNotation(words);
|
||||||
|
const chordRegex = isItalian
|
||||||
|
? /(DO|RE|MI|FA|SOL|LA|SI)(#|b)?(m|-|min|maj|aug|dim)?(7|9|11|13)?/i
|
||||||
|
: /(DO|RE|MI|FA|SOL|LA|SI|C|D|E|F|G|A|B)(#|b)?(m|-|min|maj|aug|dim)?(7|9|11|13)?/i;
|
||||||
let inChorus = false;
|
let inChorus = false;
|
||||||
let inVerse = false;
|
let inVerse = false;
|
||||||
|
|
||||||
@@ -869,13 +1290,13 @@ export class ProposeCantoPage implements OnInit {
|
|||||||
if (inVerse) { processedLines.push('{end_verse}'); inVerse = false; }
|
if (inVerse) { processedLines.push('{end_verse}'); inVerse = false; }
|
||||||
processedLines.push('{start_chorus}');
|
processedLines.push('{start_chorus}');
|
||||||
inChorus = true;
|
inChorus = true;
|
||||||
if (trimmed.length > 12) processedLines.push(this.wrapChords(trimmed, chordRegex));
|
if (trimmed.length > 12) processedLines.push(this.wrapChords(trimmed, chordRegex, isItalian));
|
||||||
} else {
|
} else {
|
||||||
if (!inVerse && !inChorus && trimmed.length > 5) {
|
if (!inVerse && !inChorus && trimmed.length > 5) {
|
||||||
processedLines.push('{start_verse}');
|
processedLines.push('{start_verse}');
|
||||||
inVerse = true;
|
inVerse = true;
|
||||||
}
|
}
|
||||||
processedLines.push(this.wrapChords(trimmed, chordRegex));
|
processedLines.push(this.wrapChords(trimmed, chordRegex, isItalian));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -884,21 +1305,24 @@ export class ProposeCantoPage implements OnInit {
|
|||||||
return processedLines.join('\n');
|
return processedLines.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
private wrapChords(line: string, regex: RegExp): string {
|
private wrapChords(line: string, regex: RegExp, isItalian: boolean): string {
|
||||||
const globalRegex = new RegExp(regex.source.replace(/^\^/, '').replace(/\$$/, ''), 'gi');
|
let source = regex.source.replace(/^\^/, '').replace(/\$$/, '');
|
||||||
|
if (!source.startsWith('\\b')) source = '\\b' + source;
|
||||||
|
if (!source.endsWith('\\b')) source = source + '\\b';
|
||||||
|
const globalRegex = new RegExp(source, 'gi');
|
||||||
const parts = line.split(/(_)/);
|
const parts = line.split(/(_)/);
|
||||||
return parts.map((part: string) => {
|
return parts.map((part: string) => {
|
||||||
if (part === '_') return ' _ ';
|
if (part === '_') return ' _ ';
|
||||||
return part.replace(globalRegex, (match) => {
|
return part.replace(globalRegex, (match) => {
|
||||||
const lower = match.toLowerCase();
|
const lower = match.toLowerCase();
|
||||||
if (match === 'a' || match === 'e' || match === 'o' || match === 'i') {
|
if (['a', 'e', 'o', 'i'].includes(lower)) {
|
||||||
return match;
|
return match;
|
||||||
}
|
}
|
||||||
if (['la', 'mi', 're', 'do', 'si', 'fa', 'sol'].includes(lower) && match === lower) {
|
if (['la', 'mi', 're', 'do', 'si', 'fa', 'sol'].includes(lower)) {
|
||||||
return match;
|
return match;
|
||||||
}
|
}
|
||||||
const sanitized = this.sanitizeOcrChord(match.toUpperCase());
|
const sanitized = this.sanitizeOcrChord(match.toUpperCase());
|
||||||
return `[${this.convertEnglishChordToItalian(sanitized)}]`;
|
return `[${this.convertEnglishChordToItalian(sanitized, isItalian)}]`;
|
||||||
});
|
});
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
@@ -908,6 +1332,15 @@ export class ProposeCantoPage implements OnInit {
|
|||||||
async saveToMyCanti() {
|
async saveToMyCanti() {
|
||||||
if (!this.title || !this.content) return;
|
if (!this.title || !this.content) return;
|
||||||
|
|
||||||
|
// Automatically convert any [LAM]/[LAN], [REM]/[REN] etc. to [LAm], [REm] in content before saving
|
||||||
|
this.content = this.content.replace(/\[(DO|RE|MI|FA|SOL|LA|SI)(#|b|♭)?[mMnN](\d*)\]/g, (match, p1, p2, p3) => {
|
||||||
|
return `[${p1}${p2 || ''}m${p3 || ''}]`;
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.proceedSaveToMyCanti();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async proceedSaveToMyCanti() {
|
||||||
// Combine lit and tematico for id_momenti
|
// Combine lit and tematico for id_momenti
|
||||||
const id_momenti = [...this.selectedLiturgico, ...this.selectedTematico];
|
const id_momenti = [...this.selectedLiturgico, ...this.selectedTematico];
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- PWA Install Group (Desktop Fallback Instructions) -->
|
||||||
|
<div class="settings-group glass ion-margin-bottom" *ngIf="!settingsService.showInstallButton() && !settingsService.isStandalone() && !settingsService.isIos()">
|
||||||
|
<ion-item class="transparent-item" lines="none">
|
||||||
|
<ion-icon name="download-outline" slot="start" color="secondary"></ion-icon>
|
||||||
|
<ion-label class="outfit-font" style="white-space: normal;">
|
||||||
|
<h2 class="settings-item-title" style="color: var(--ion-color-secondary); font-weight: bold; margin-bottom: 6px;">Come installare l'applicazione</h2>
|
||||||
|
<p class="settings-item-subtitle" style="font-size: 0.85rem; line-height: 1.45; opacity: 0.9; color: var(--ion-text-color); margin: 0;">
|
||||||
|
Se usi <strong>Chrome / Edge / Brave</strong>: puoi installarla cliccando sull'icona di installazione <span style="display: inline-flex; align-items: center; justify-content: center; background: rgba(255,255,255,0.1); border-radius: 6px; padding: 2px 6px; font-size: 0.9rem; vertical-align: middle;">⊕</span> o <span style="display: inline-flex; align-items: center; justify-content: center; background: rgba(255,255,255,0.1); border-radius: 6px; padding: 2px 6px; font-size: 0.9rem; vertical-align: middle;">📥</span> che appare a destra nella barra degli indirizzi del browser.<br><br>
|
||||||
|
Se usi <strong>Safari (su Mac)</strong>: clicca sul menu <strong>File</strong> in alto e seleziona <strong>Aggiungi al Dock...</strong>.
|
||||||
|
</p>
|
||||||
|
</ion-label>
|
||||||
|
</ion-item>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="settings-group glass ion-margin-bottom">
|
<div class="settings-group glass ion-margin-bottom">
|
||||||
<ion-item class="transparent-item" lines="none">
|
<ion-item class="transparent-item" lines="none">
|
||||||
<ion-icon name="scan-outline" slot="start" color="secondary"></ion-icon>
|
<ion-icon name="scan-outline" slot="start" color="secondary"></ion-icon>
|
||||||
@@ -175,7 +189,7 @@
|
|||||||
<div class="glass" style="background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); padding: 8px 12px; border-radius: 10px; flex-grow: 1; display: flex; align-items: center;">
|
<div class="glass" style="background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); padding: 8px 12px; border-radius: 10px; flex-grow: 1; display: flex; align-items: center;">
|
||||||
<input type="text"
|
<input type="text"
|
||||||
#comunitaCodeInput
|
#comunitaCodeInput
|
||||||
placeholder="Es: 123456"
|
placeholder="il codice di 6 cifre"
|
||||||
style="background: transparent; border: none; color: var(--ion-text-color); font-size: 0.85rem; width: 100%; outline: none;"
|
style="background: transparent; border: none; color: var(--ion-text-color); font-size: 0.85rem; width: 100%; outline: none;"
|
||||||
class="outfit-font"
|
class="outfit-font"
|
||||||
(keyup.enter)="saveComunitaCode(comunitaCodeInput.value)">
|
(keyup.enter)="saveComunitaCode(comunitaCodeInput.value)">
|
||||||
|
|||||||
@@ -48,7 +48,12 @@ export class CantiService {
|
|||||||
public progress = signal<number>(0);
|
public progress = signal<number>(0);
|
||||||
public firstLoadCompleted = signal<boolean>(false);
|
public firstLoadCompleted = signal<boolean>(false);
|
||||||
|
|
||||||
private API_URL = 'https://www.canticristiani.it/api/canti.json';
|
private getApiUrl(): string {
|
||||||
|
const isProduction = window.location.hostname.includes('canticristiani.it');
|
||||||
|
return isProduction
|
||||||
|
? `${window.location.origin}/api/canti.json`
|
||||||
|
: 'https://www.canticristiani.it/api/canti.json';
|
||||||
|
}
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.init();
|
this.init();
|
||||||
@@ -92,7 +97,7 @@ export class CantiService {
|
|||||||
this.loading.set(true);
|
this.loading.set(true);
|
||||||
this.progress.set(0);
|
this.progress.set(0);
|
||||||
|
|
||||||
this.http.get(`${this.API_URL}?t=${Date.now()}`, {
|
this.http.get(`${this.getApiUrl()}?t=${Date.now()}`, {
|
||||||
reportProgress: true,
|
reportProgress: true,
|
||||||
observe: 'events'
|
observe: 'events'
|
||||||
}).subscribe({
|
}).subscribe({
|
||||||
|
|||||||
@@ -101,4 +101,29 @@ Rallegri*amoci,*
|
|||||||
expect(sections[1].lines[0].text).toBe('Rallegriamoci,');
|
expect(sections[1].lines[0].text).toBe('Rallegriamoci,');
|
||||||
expect(sections[1].lines[0].segments[0].chord).toBeUndefined();
|
expect(sections[1].lines[0].segments[0].chord).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
it('should format sections outside of soc/eoc tags as verses', () => {
|
||||||
|
const rawSong = `
|
||||||
|
{c:Intro:} [RE]
|
||||||
|
|
||||||
|
[RE]Guardami Signor
|
||||||
|
{soc}
|
||||||
|
Abba Padre!
|
||||||
|
{eoc}
|
||||||
|
[RE]Più solo non sarò
|
||||||
|
`;
|
||||||
|
const sections = service.parseAccordi(rawSong);
|
||||||
|
// There should be three sections:
|
||||||
|
// 1. Verse (Intro & Guardami Signor)
|
||||||
|
// 2. Chorus (Abba Padre)
|
||||||
|
// 3. Verse (Più solo non sarò)
|
||||||
|
expect(sections.length).toBe(3);
|
||||||
|
expect(sections[0].type).toBe('verse');
|
||||||
|
expect(sections[0].lines[0].text).toBe('Intro ');
|
||||||
|
expect(sections[0].lines[0].segments[0].text).toBe('Intro ');
|
||||||
|
expect(sections[0].lines[0].segments[0].chord).toBeUndefined();
|
||||||
|
expect(sections[0].lines[0].segments[1].chord).toBe('RE');
|
||||||
|
expect(sections[0].lines[1].text).toBe('Guardami Signor');
|
||||||
|
expect(sections[1].type).toBe('chorus');
|
||||||
|
expect(sections[2].type).toBe('verse');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -116,11 +116,12 @@ export class LyricsParserService {
|
|||||||
currentLines = [];
|
currentLines = [];
|
||||||
currentRawLines = [];
|
currentRawLines = [];
|
||||||
currentAction = null;
|
currentAction = null;
|
||||||
|
currentType = 'verse';
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip structural tags (already handled above)
|
// Skip structural tags (already handled above)
|
||||||
if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
|
if (trimmed.startsWith('{') && trimmed.endsWith('}') && !trimmed.startsWith('{c:') && !trimmed.startsWith('{comment:')) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,6 +149,11 @@ export class LyricsParserService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clean comment tags: {c:Text} or {comment:Text} -> Text (removing trailing colon if any)
|
||||||
|
resolvedLine = resolvedLine.replace(/\{(?:c|comment):([^}]+)\}/g, (match, p1) => {
|
||||||
|
return p1.trim().replace(/:$/, '');
|
||||||
|
});
|
||||||
|
|
||||||
currentRawLines.push(line);
|
currentRawLines.push(line);
|
||||||
|
|
||||||
// Parse line
|
// Parse line
|
||||||
@@ -246,6 +252,11 @@ export class LyricsParserService {
|
|||||||
transposeChord(chord: string, semitones: number): string {
|
transposeChord(chord: string, semitones: number): string {
|
||||||
if (!chord) return chord;
|
if (!chord) return chord;
|
||||||
|
|
||||||
|
// Convert Italian chords ending in 'M' or 'N' (e.g. LAM -> LAm, LAN -> LAm, LAN7 -> LAm7) to lowercase 'm'
|
||||||
|
chord = chord.replace(/\b(DO|RE|MI|FA|SOL|LA|SI)(#|b|♭)?[mMnN](\d*)\b/g, (match, p1, p2, p3) => {
|
||||||
|
return p1 + (p2 || '') + 'm' + (p3 || '');
|
||||||
|
});
|
||||||
|
|
||||||
// Handle slash chords (e.g., DO/SOL)
|
// Handle slash chords (e.g., DO/SOL)
|
||||||
if (chord.includes('/')) {
|
if (chord.includes('/')) {
|
||||||
return chord.split('/')
|
return chord.split('/')
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Injectable, inject } from '@angular/core';
|
|||||||
import { CantiService } from './canti.service';
|
import { CantiService } from './canti.service';
|
||||||
import { MyCantiService } from './my-canti.service';
|
import { MyCantiService } from './my-canti.service';
|
||||||
import { ComunitaService } from './comunita.service';
|
import { ComunitaService } from './comunita.service';
|
||||||
|
import { PlaylistService } from './playlist.service';
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
@@ -10,6 +11,7 @@ export class MediaSessionService {
|
|||||||
private cantiService = inject(CantiService);
|
private cantiService = inject(CantiService);
|
||||||
private myCantiService = inject(MyCantiService);
|
private myCantiService = inject(MyCantiService);
|
||||||
private comunitaService = inject(ComunitaService);
|
private comunitaService = inject(ComunitaService);
|
||||||
|
private playlistService = inject(PlaylistService);
|
||||||
|
|
||||||
public updateMetadata(cantoId: string) {
|
public updateMetadata(cantoId: string) {
|
||||||
if (!('mediaSession' in navigator)) return;
|
if (!('mediaSession' in navigator)) return;
|
||||||
@@ -21,6 +23,12 @@ export class MediaSessionService {
|
|||||||
if (!canto) {
|
if (!canto) {
|
||||||
canto = this.comunitaService.comunitaCantiPersonali().find(c => c.id === cantoId);
|
canto = this.comunitaService.comunitaCantiPersonali().find(c => c.id === cantoId);
|
||||||
}
|
}
|
||||||
|
if (!canto) {
|
||||||
|
canto = this.playlistService.remoteCustomSongs().find(c => c.id === cantoId || String(c.id_canti) === cantoId);
|
||||||
|
}
|
||||||
|
if (!canto) {
|
||||||
|
canto = this.playlistService.remoteShareCanti().find(c => c.id === cantoId || String(c.id_canti) === cantoId);
|
||||||
|
}
|
||||||
if (!canto) return;
|
if (!canto) return;
|
||||||
|
|
||||||
const thumb = this.cantiService.getYoutubeThumb(canto.link_youtube) || 'assets/icons/icon-512x512.png';
|
const thumb = this.cantiService.getYoutubeThumb(canto.link_youtube) || 'assets/icons/icon-512x512.png';
|
||||||
|
|||||||
@@ -386,6 +386,34 @@ export class PlaylistService {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.remoteCustomSongs().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: any) => String(id)) || [],
|
||||||
|
periodi: [] as string[],
|
||||||
|
testo: c.testo || ''
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.comunitaService.comunitaCantiPersonali().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: any) => String(id)) || [],
|
||||||
|
periodi: [] as string[],
|
||||||
|
testo: c.testo || ''
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const customSongs = Array.from(mergedSongsMap.values());
|
const customSongs = Array.from(mergedSongsMap.values());
|
||||||
|
|
||||||
const playlistSongs = this.playlists().map(pl => ({
|
const playlistSongs = this.playlists().map(pl => ({
|
||||||
@@ -570,12 +598,22 @@ export class PlaylistService {
|
|||||||
const blob = await res.blob();
|
const blob = await res.blob();
|
||||||
const file = new File([blob], fileName, { type: 'image/png' });
|
const file = new File([blob], fileName, { type: 'image/png' });
|
||||||
|
|
||||||
if (navigator.share && navigator.canShare && navigator.canShare({ files: [file] })) {
|
const isMac = /Macintosh|MacIntel|MacPPC|Mac68K/.test(navigator.userAgent) ||
|
||||||
|
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||||
|
|
||||||
|
if (!isMac && navigator.share && navigator.canShare && navigator.canShare({ files: [file] })) {
|
||||||
await navigator.share({
|
await navigator.share({
|
||||||
files: [file],
|
files: [file],
|
||||||
title: 'Playlist CantiCristiani',
|
title: 'Playlist CantiCristiani',
|
||||||
text: `Ecco la playlist: ${name}\n\nClicca qui per aprirla subito: ${shareLink}`
|
text: `Ecco la playlist: ${name}\n\nClicca qui per aprirla subito: ${shareLink}`
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
// Fallback to simple share text or copy/download
|
||||||
|
if (navigator.share) {
|
||||||
|
await navigator.share({
|
||||||
|
title: 'Playlist CantiCristiani',
|
||||||
|
text: `Ecco la playlist: ${name}\n\nClicca qui per aprirla: ${shareLink}`
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
// Fallback: copia il link negli appunti e scarica l'immagine del QR
|
// Fallback: copia il link negli appunti e scarica l'immagine del QR
|
||||||
try {
|
try {
|
||||||
@@ -597,6 +635,7 @@ export class PlaylistService {
|
|||||||
link.download = fileName;
|
link.download = fileName;
|
||||||
link.click();
|
link.click();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Share failed', err);
|
console.error('Share failed', err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export class SettingsService {
|
|||||||
public karaokePageScrollMode = signal<boolean>(false);
|
public karaokePageScrollMode = signal<boolean>(false);
|
||||||
|
|
||||||
/** Vista orizzontale per proiezione: true = attiva layout landscape per proiezione */
|
/** Vista orizzontale per proiezione: true = attiva layout landscape per proiezione */
|
||||||
public landscapeProjectionEnabled = signal<boolean>(true);
|
public landscapeProjectionEnabled = signal<boolean>(false);
|
||||||
|
|
||||||
/** Identificativo utente univoco per la gestione delle comunità */
|
/** Identificativo utente univoco per la gestione delle comunità */
|
||||||
public userUuid = signal<string>('');
|
public userUuid = signal<string>('');
|
||||||
@@ -251,7 +251,7 @@ export class SettingsService {
|
|||||||
if (savedLandscapeProjectionEnabled !== null) {
|
if (savedLandscapeProjectionEnabled !== null) {
|
||||||
this.landscapeProjectionEnabled.set(savedLandscapeProjectionEnabled === 'true');
|
this.landscapeProjectionEnabled.set(savedLandscapeProjectionEnabled === 'true');
|
||||||
} else {
|
} else {
|
||||||
this.landscapeProjectionEnabled.set(true);
|
this.landscapeProjectionEnabled.set(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync browser fullscreen state with listeners (supporting vendor prefixes)
|
// Sync browser fullscreen state with listeners (supporting vendor prefixes)
|
||||||
@@ -464,10 +464,10 @@ export class SettingsService {
|
|||||||
localStorage.setItem('user-name', trimmed);
|
localStorage.setItem('user-name', trimmed);
|
||||||
}
|
}
|
||||||
|
|
||||||
async installPwa() {
|
async installPwa(): Promise<string | undefined> {
|
||||||
const promptEvent = this.deferredPrompt();
|
const promptEvent = this.deferredPrompt();
|
||||||
if (!promptEvent) {
|
if (!promptEvent) {
|
||||||
return;
|
return undefined;
|
||||||
}
|
}
|
||||||
// Show the install prompt
|
// Show the install prompt
|
||||||
promptEvent.prompt();
|
promptEvent.prompt();
|
||||||
@@ -477,5 +477,6 @@ export class SettingsService {
|
|||||||
// We've used the prompt, and can't use it again, discard it
|
// We've used the prompt, and can't use it again, discard it
|
||||||
this.deferredPrompt.set(null);
|
this.deferredPrompt.set(null);
|
||||||
this.showInstallButton.set(false);
|
this.showInstallButton.set(false);
|
||||||
|
return outcome;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { MediaSessionService } from './media-session.service';
|
|||||||
import { MyCantiService } from './my-canti.service';
|
import { MyCantiService } from './my-canti.service';
|
||||||
import { ConnectivityService } from './connectivity.service';
|
import { ConnectivityService } from './connectivity.service';
|
||||||
import { ComunitaService } from './comunita.service';
|
import { ComunitaService } from './comunita.service';
|
||||||
|
import { PlaylistService } from './playlist.service';
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
@@ -16,6 +17,7 @@ export class YoutubePlayerService {
|
|||||||
private myCantiService = inject(MyCantiService);
|
private myCantiService = inject(MyCantiService);
|
||||||
private connectivityService = inject(ConnectivityService);
|
private connectivityService = inject(ConnectivityService);
|
||||||
private comunitaService = inject(ComunitaService);
|
private comunitaService = inject(ComunitaService);
|
||||||
|
private playlistService = inject(PlaylistService);
|
||||||
|
|
||||||
public isPlayerSupported = computed<boolean>(() => {
|
public isPlayerSupported = computed<boolean>(() => {
|
||||||
// Check if offline
|
// Check if offline
|
||||||
@@ -127,6 +129,12 @@ export class YoutubePlayerService {
|
|||||||
if (!canto) {
|
if (!canto) {
|
||||||
canto = this.comunitaService.comunitaCantiPersonali().find(c => c.id === cantoId);
|
canto = this.comunitaService.comunitaCantiPersonali().find(c => c.id === cantoId);
|
||||||
}
|
}
|
||||||
|
if (!canto) {
|
||||||
|
canto = this.playlistService.remoteCustomSongs().find(c => c.id === cantoId || String(c.id_canti) === cantoId);
|
||||||
|
}
|
||||||
|
if (!canto) {
|
||||||
|
canto = this.playlistService.remoteShareCanti().find(c => c.id === cantoId || String(c.id_canti) === cantoId);
|
||||||
|
}
|
||||||
if (!canto) return;
|
if (!canto) return;
|
||||||
|
|
||||||
const videoId = this.cantiService.getYoutubeId(canto.link_youtube);
|
const videoId = this.cantiService.getYoutubeId(canto.link_youtube);
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
export const VERSION = '2026.06.17.0049';
|
export const VERSION = '2026.06.18.0234';
|
||||||
|
|||||||
Vendored
+31
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user