1765 lines
62 KiB
TypeScript
1765 lines
62 KiB
TypeScript
import { Component, OnInit, OnDestroy, ViewChild, ElementRef, inject } from '@angular/core';
|
|
import { CommonModule } from '@angular/common';
|
|
import { FormsModule } from '@angular/forms';
|
|
import { IonicModule, ToastController, IonTextarea, PopoverController, NavController, AlertController } from '@ionic/angular';
|
|
import { createWorker } from 'tesseract.js';
|
|
import { CantiService } from '../../services/canti.service';
|
|
import { MyCantiService } from '../../services/my-canti.service';
|
|
import { PlaylistService } from '../../services/playlist.service';
|
|
import { ThemeService } from '../../services/theme.service';
|
|
import { ActivatedRoute, RouterModule, Router } from '@angular/router';
|
|
import { LyricsParserService, ParsedSection } from '../../services/lyrics-parser.service';
|
|
import { YoutubePlayerService } from '../../services/youtube-player.service';
|
|
|
|
@Component({
|
|
selector: 'app-propose-canto',
|
|
templateUrl: './propose-canto.page.html',
|
|
styleUrls: ['./propose-canto.page.scss'],
|
|
standalone: true,
|
|
imports: [CommonModule, FormsModule, IonicModule, RouterModule]
|
|
})
|
|
export class ProposeCantoPage implements OnInit, OnDestroy {
|
|
@ViewChild('nativeTextarea', { static: false }) nativeTextarea!: ElementRef<HTMLTextAreaElement>;
|
|
|
|
@ViewChild('cameraInput', { static: false }) cameraInput!: ElementRef;
|
|
@ViewChild('fileInput', { static: false }) fileInput!: ElementRef;
|
|
|
|
public cantiService = inject(CantiService);
|
|
private myCantiService = inject(MyCantiService);
|
|
private playlistService = inject(PlaylistService);
|
|
private navCtrl = inject(NavController);
|
|
public themeService = inject(ThemeService);
|
|
private route = inject(ActivatedRoute);
|
|
private router = inject(Router);
|
|
public lyricsParser = inject(LyricsParserService);
|
|
private alertCtrl = inject(AlertController);
|
|
public youtubePlayerService = inject(YoutubePlayerService);
|
|
|
|
showChordsPreview: boolean = true;
|
|
activeTab: string = 'editor';
|
|
transposeAmount: number = 0;
|
|
|
|
get highlightedHtml(): string {
|
|
if (!this.content) return '';
|
|
|
|
let escaped = this.content
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>');
|
|
|
|
escaped = escaped.replace(/\[([^\]]+)\]/g, (match, chord) => {
|
|
const transposed = this.transposeAmount !== 0
|
|
? this.lyricsParser.transposeChord(chord, this.transposeAmount)
|
|
: chord;
|
|
return `<span class="editor-chord">[${transposed}]</span>`;
|
|
});
|
|
|
|
const lines = escaped.split('\n');
|
|
let htmlLines: string[] = [];
|
|
let inChorus = false;
|
|
let inVerse = false;
|
|
let inVerseNum = false;
|
|
|
|
for (const line of lines) {
|
|
const trimmed = line.trim();
|
|
|
|
let currentInChorus = inChorus;
|
|
let currentInVerse = inVerse;
|
|
let currentInVerseNum = inVerseNum;
|
|
|
|
let isTagLine = false;
|
|
let processedContent = line;
|
|
|
|
if (trimmed === '{start_chorus}' || trimmed === '{soc}') {
|
|
inChorus = true;
|
|
currentInChorus = true;
|
|
isTagLine = true;
|
|
processedContent = `<span class="editor-tag">${line}</span>`;
|
|
} else if (trimmed === '{end_chorus}' || trimmed === '{eoc}') {
|
|
inChorus = false;
|
|
currentInChorus = true;
|
|
isTagLine = true;
|
|
processedContent = `<span class="editor-tag">${line}</span>`;
|
|
} else if (trimmed === '{start_verse}' || trimmed === '{sov}') {
|
|
inVerse = true;
|
|
currentInVerse = true;
|
|
isTagLine = true;
|
|
processedContent = `<span class="editor-tag">${line}</span>`;
|
|
} else if (trimmed === '{end_verse}' || trimmed === '{eov}') {
|
|
inVerse = false;
|
|
currentInVerse = true;
|
|
isTagLine = true;
|
|
processedContent = `<span class="editor-tag">${line}</span>`;
|
|
} else if (trimmed === '{start_verse_num}') {
|
|
inVerseNum = true;
|
|
currentInVerseNum = true;
|
|
isTagLine = true;
|
|
processedContent = `<span class="editor-tag">${line}</span>`;
|
|
} else if (trimmed === '{end_verse_num}') {
|
|
inVerseNum = false;
|
|
currentInVerseNum = true;
|
|
isTagLine = true;
|
|
processedContent = `<span class="editor-tag">${line}</span>`;
|
|
} else if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
|
|
isTagLine = true;
|
|
processedContent = `<span class="editor-tag">${line}</span>`;
|
|
}
|
|
|
|
let classes = ['editor-line'];
|
|
if (currentInChorus) classes.push('chorus');
|
|
if (currentInVerse) classes.push('verse');
|
|
if (currentInVerseNum) classes.push('verse-num');
|
|
if (isTagLine) classes.push('tag-line');
|
|
|
|
const finalContent = processedContent || '​';
|
|
htmlLines.push(`<div class="${classes.join(' ')}">${finalContent}</div>`);
|
|
}
|
|
|
|
return htmlLines.join('\n');
|
|
}
|
|
|
|
|
|
|
|
get parsedSections(): ParsedSection[] {
|
|
const sections = this.lyricsParser.parseAccordi(this.content);
|
|
if (this.showChordsPreview && this.transposeAmount !== 0) {
|
|
return this.lyricsParser.transposeSections(sections, this.transposeAmount);
|
|
}
|
|
return sections;
|
|
}
|
|
|
|
transposeUp() {
|
|
this.transposeAmount = (this.transposeAmount + 1) > 12 ? -11 : this.transposeAmount + 1;
|
|
}
|
|
|
|
transposeDown() {
|
|
this.transposeAmount = (this.transposeAmount - 1) < -12 ? 11 : this.transposeAmount - 1;
|
|
}
|
|
|
|
setDefaultTonalita() {
|
|
if (this.transposeAmount === 0 || !this.content) return;
|
|
const chordRegex = /\[([^\]]+)\]/g;
|
|
this.content = this.content.replace(chordRegex, (match, chord) => {
|
|
const transposed = this.lyricsParser.transposeChord(chord, this.transposeAmount);
|
|
return `[${transposed}]`;
|
|
});
|
|
this.transposeAmount = 0;
|
|
}
|
|
|
|
isAudioLoaded(): boolean {
|
|
return !!this.youtubeLink && this.cantiService.getYoutubeId(this.youtubeLink) !== null;
|
|
}
|
|
|
|
loadedYoutubeLink: string = '';
|
|
|
|
loadEditorAudio() {
|
|
if (!this.youtubeLink) return;
|
|
const videoId = this.cantiService.getYoutubeId(this.youtubeLink);
|
|
if (videoId) {
|
|
this.youtubePlayerService.initPlayer(this.editId || 'editing_song', 0, undefined, undefined, this.youtubeLink);
|
|
this.loadedYoutubeLink = this.youtubeLink;
|
|
}
|
|
}
|
|
|
|
togglePlayPause() {
|
|
const currentId = this.editId || 'editing_song';
|
|
const isLoaded = this.youtubePlayerService.currentCantoId() === currentId && this.loadedYoutubeLink === this.youtubeLink;
|
|
if (!isLoaded && this.youtubeLink) {
|
|
this.loadEditorAudio();
|
|
} else {
|
|
this.youtubePlayerService.togglePlayPause();
|
|
}
|
|
}
|
|
|
|
onSeek(event: any) {
|
|
const value = event.detail.value;
|
|
this.youtubePlayerService.seekTo(value);
|
|
}
|
|
|
|
formatSeconds(seconds: number): string {
|
|
if (isNaN(seconds)) return '0:00';
|
|
const mins = Math.floor(seconds / 60);
|
|
const secs = Math.floor(seconds % 60);
|
|
return `${mins}:${secs < 10 ? '0' : ''}${secs}`;
|
|
}
|
|
|
|
toggleChordsPreview() {
|
|
this.showChordsPreview = !this.showChordsPreview;
|
|
}
|
|
|
|
title: string = '';
|
|
author: string = '';
|
|
youtubeLink: string = '';
|
|
selectedLiturgico: number[] = [];
|
|
selectedTematico: number[] = [];
|
|
durata: string = '';
|
|
bpm: number | null = null;
|
|
|
|
get sortedLiturgico() {
|
|
return [...this.cantiService.indiceLiturgico()].sort((a, b) => a.tag_name.localeCompare(b.tag_name));
|
|
}
|
|
|
|
get sortedTematico() {
|
|
return [...this.cantiService.indiceTematico()].sort((a, b) => a.tag_name.localeCompare(b.tag_name));
|
|
}
|
|
|
|
editId: string | null = null;
|
|
|
|
private _content: string = '';
|
|
get content(): string { return this._content; }
|
|
set content(val: string) {
|
|
if (this._content !== val) {
|
|
this.saveToUndoStack(this._content);
|
|
this._content = val;
|
|
}
|
|
}
|
|
|
|
undoStack: string[] = [];
|
|
isProcessingOCR: boolean = false;
|
|
ocrProgress: number = 0;
|
|
isDraggingOver: boolean = false;
|
|
get isHighContrast(): boolean { return this.themeService.highContrast(); }
|
|
|
|
groupedChords = [
|
|
{
|
|
root: 'DO',
|
|
chords: ['DO', 'DO-', 'DO#', 'DO#-', 'DO7', 'DO-7', 'DOmaj7', 'DO4', 'DOdim', 'DOm7']
|
|
},
|
|
{
|
|
root: 'RE',
|
|
chords: ['RE', 'RE-', 'RE#', 'RE#-', 'RE7', 'RE-7', 'REmaj7', 'RE4', 'REdim', 'REm7']
|
|
},
|
|
{
|
|
root: 'MI',
|
|
chords: ['MI', 'MI-', 'MI7', 'MI-7', 'MImaj7', 'MI4', 'MIdim']
|
|
},
|
|
{
|
|
root: 'FA',
|
|
chords: ['FA', 'FA-', 'FA#', 'FA#-', 'FA7', 'FAmaj7', 'FA4', 'FAdim', 'FAm7']
|
|
},
|
|
{
|
|
root: 'SOL',
|
|
chords: ['SOL', 'SOL-', 'SOL#', 'SOL#-', 'SOL7', 'SOLmaj7', 'SOL4', 'SOLdim', 'SOLm7']
|
|
},
|
|
{
|
|
root: 'LA',
|
|
chords: ['LA', 'LA-', 'LA#', 'LA#-', 'LA7', 'LA-7', 'LAmaj7', 'LA4', 'LAdim', 'LAm7']
|
|
},
|
|
{
|
|
root: 'SI',
|
|
chords: ['SI', 'SI-', 'SI7', 'SI-7', 'SImaj7', 'SI4', 'SIdim']
|
|
}
|
|
];
|
|
|
|
selectedRootChord: string | null = null;
|
|
|
|
selectRoot(root: string) {
|
|
if (this.selectedRootChord === root) {
|
|
this.selectedRootChord = null;
|
|
} else {
|
|
this.selectedRootChord = root;
|
|
}
|
|
}
|
|
|
|
getVariations(): string[] {
|
|
if (!this.selectedRootChord) return [];
|
|
const group = this.groupedChords.find(g => g.root === this.selectedRootChord);
|
|
return group ? group.chords : [];
|
|
}
|
|
|
|
commonTags = [
|
|
{ label: 'Ritornello', start: '{start_chorus}', end: '{end_chorus}' },
|
|
{ label: 'Strofa', start: '{start_verse}', end: '{end_verse}' },
|
|
{ label: 'Strofa Num.', start: '{start_verse_num}', end: '{end_verse_num}' },
|
|
{ label: 'Inciso', start: '{start_bridge}', end: '{end_bridge}' },
|
|
{ label: 'Intro Accordi', start: '{start_chord}', end: '{end_chord}' },
|
|
{ label: 'Commento', start: '{c: ', end: '}' },
|
|
{ label: 'ChordPro Strofa', start: '{sov}', end: '{eov}' },
|
|
{ label: 'ChordPro Rit.', start: '{soc}', end: '{eoc}' }
|
|
];
|
|
|
|
constructor(private toastController: ToastController, private popoverController: PopoverController) { }
|
|
|
|
ngOnInit() {
|
|
this.route.queryParams.subscribe(params => {
|
|
const editId = params['editId'];
|
|
if (editId) {
|
|
this.editId = editId;
|
|
// Find the song in standard canti, personal canti, or remote custom canti list
|
|
const song = [
|
|
...this.cantiService.canti(),
|
|
...this.myCantiService.myCanti(),
|
|
...this.playlistService.remoteCustomSongs(),
|
|
...this.playlistService.remoteShareCanti()
|
|
].find(c => c.id === editId);
|
|
|
|
if (song) {
|
|
this.title = song.titolo;
|
|
this.author = song.autore || '';
|
|
this.youtubeLink = song.link_youtube || '';
|
|
this.content = song.accordi || song.testo || '';
|
|
this.durata = song.durata || '';
|
|
this.bpm = song.bpm !== undefined ? song.bpm : null;
|
|
|
|
// Pre-populate liturgico and tematico lists
|
|
const litIds = this.cantiService.indiceLiturgico().map(m => m.id);
|
|
const temIds = this.cantiService.indiceTematico().map(m => m.id);
|
|
|
|
this.selectedLiturgico = song.id_momenti?.filter((id: number) => litIds.includes(id)) || [];
|
|
this.selectedTematico = song.id_momenti?.filter((id: number) => temIds.includes(id)) || [];
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
ngOnDestroy() {
|
|
this.youtubePlayerService.stop();
|
|
}
|
|
|
|
async insertText(tag: string) {
|
|
const input = this.nativeTextarea.nativeElement;
|
|
const start = input.selectionStart || 0;
|
|
const end = input.selectionEnd || 0;
|
|
|
|
this.content = this.content.substring(0, start) + tag + this.content.substring(end);
|
|
|
|
setTimeout(() => {
|
|
input.focus();
|
|
input.setSelectionRange(start + tag.length, start + tag.length);
|
|
}, 10);
|
|
}
|
|
|
|
insertChord(chord: string) {
|
|
this.insertText(`[${chord}]`);
|
|
}
|
|
|
|
undo() {
|
|
if (this.undoStack.length > 0) {
|
|
const previous = this.undoStack.pop();
|
|
if (previous !== undefined) {
|
|
this._content = previous;
|
|
}
|
|
}
|
|
}
|
|
|
|
private saveToUndoStack(val: string) {
|
|
if (this.undoStack.length >= 30) {
|
|
this.undoStack.shift();
|
|
}
|
|
this.undoStack.push(val);
|
|
}
|
|
|
|
takePhoto() {
|
|
this.cameraInput.nativeElement.click();
|
|
}
|
|
|
|
chooseFile() {
|
|
this.fileInput.nativeElement.click();
|
|
}
|
|
|
|
async deduceChords() {
|
|
if (!this.content) return;
|
|
|
|
// Salva nello stack degli undo
|
|
this.saveToUndoStack(this.content);
|
|
|
|
const lines = this.content.split('\n');
|
|
|
|
interface SectionLine {
|
|
originalIndex: number;
|
|
text: string;
|
|
}
|
|
|
|
interface Section {
|
|
type: 'verse' | 'chorus' | 'other';
|
|
startTag: string | null;
|
|
endTag: string | null;
|
|
lines: SectionLine[];
|
|
hasChords: boolean;
|
|
}
|
|
|
|
const sections: Section[] = [];
|
|
let currentSection: Section = { type: 'other', startTag: null, endTag: null, lines: [], hasChords: false };
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
const trimmed = line.trim();
|
|
|
|
const startMatch = trimmed.match(/^\{(start_verse|start_chorus|start_verse_num|sov|soc)\}/);
|
|
const endMatch = trimmed === '{end_verse}' || trimmed === '{eov}' || trimmed === '{end_chorus}' || trimmed === '{eoc}' || trimmed === '{end_verse_num}';
|
|
|
|
if (startMatch) {
|
|
if (currentSection.lines.length > 0 || currentSection.startTag) {
|
|
sections.push(currentSection);
|
|
}
|
|
let type: 'verse' | 'chorus' | 'other' = 'other';
|
|
const tag = startMatch[1];
|
|
if (tag === 'start_verse' || tag === 'start_verse_num' || tag === 'sov') {
|
|
type = 'verse';
|
|
} else if (tag === 'start_chorus' || tag === 'soc') {
|
|
type = 'chorus';
|
|
}
|
|
currentSection = { type, startTag: line, endTag: null, lines: [], hasChords: false };
|
|
} else if (endMatch) {
|
|
currentSection.endTag = line;
|
|
sections.push(currentSection);
|
|
currentSection = { type: 'other', startTag: null, endTag: null, lines: [], hasChords: false };
|
|
} else {
|
|
if (trimmed === '' && !currentSection.startTag) {
|
|
if (currentSection.lines.length > 0) {
|
|
sections.push(currentSection);
|
|
}
|
|
sections.push({ type: 'other', startTag: null, endTag: null, lines: [{ originalIndex: i, text: '' }], hasChords: false });
|
|
currentSection = { type: 'other', startTag: null, endTag: null, lines: [], hasChords: false };
|
|
} else {
|
|
const hasChords = /\[[^\]]+\]/.test(line);
|
|
if (hasChords) {
|
|
currentSection.hasChords = true;
|
|
}
|
|
if (currentSection.lines.length === 0 && !currentSection.startTag && trimmed !== '') {
|
|
currentSection.type = 'verse';
|
|
}
|
|
const isComment = trimmed.startsWith('{c:') || trimmed.startsWith('{comment:');
|
|
if (!isComment) {
|
|
currentSection.lines.push({ originalIndex: i, text: line });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (currentSection.lines.length > 0 || currentSection.startTag) {
|
|
sections.push(currentSection);
|
|
}
|
|
|
|
const templateVerse = sections.find(s => s.type === 'verse' && s.hasChords);
|
|
const templateChorus = sections.find(s => s.type === 'chorus' && s.hasChords);
|
|
|
|
let deducedCount = 0;
|
|
|
|
const getWords = (text: string) => {
|
|
const words: { text: string; start: number; end: number }[] = [];
|
|
const regex = /\S+/g;
|
|
let match;
|
|
while ((match = regex.exec(text)) !== null) {
|
|
words.push({
|
|
text: match[0],
|
|
start: match.index,
|
|
end: match.index + match[0].length
|
|
});
|
|
}
|
|
return words;
|
|
};
|
|
|
|
const alignChords = (templateLine: string, targetLine: string): string => {
|
|
if (/\[[^\]]+\]/.test(targetLine)) {
|
|
return targetLine;
|
|
}
|
|
|
|
const parsedTemplate = this.lyricsParser.parseChordLine(templateLine);
|
|
const templateClean = parsedTemplate.text;
|
|
const targetClean = targetLine.replace(/\[[^\]]*\]/g, '');
|
|
|
|
if (!templateClean.trim() || !targetClean.trim()) {
|
|
return targetLine;
|
|
}
|
|
|
|
const chords: { chord: string; charIndex: number }[] = [];
|
|
let charAcc = 0;
|
|
parsedTemplate.segments.forEach(seg => {
|
|
if (seg.chord) {
|
|
chords.push({ chord: seg.chord, charIndex: charAcc });
|
|
}
|
|
charAcc += seg.text.length;
|
|
});
|
|
|
|
if (chords.length === 0) {
|
|
return targetLine;
|
|
}
|
|
|
|
const templateWords = getWords(templateClean);
|
|
const targetWords = getWords(targetClean);
|
|
|
|
const insertions: { chord: string; index: number }[] = [];
|
|
|
|
chords.forEach(c => {
|
|
const ratio = c.charIndex / Math.max(1, templateClean.length);
|
|
const wordIdx = templateWords.findIndex(w => Math.abs(w.start - c.charIndex) <= 1);
|
|
|
|
let targetIndex = 0;
|
|
if (wordIdx !== -1 && templateWords.length > 1 && targetWords.length > 1) {
|
|
const wRatio = wordIdx / (templateWords.length - 1);
|
|
const targetWIdx = Math.round(wRatio * (targetWords.length - 1));
|
|
targetIndex = targetWords[targetWIdx].start;
|
|
} else {
|
|
targetIndex = Math.round(ratio * targetClean.length);
|
|
}
|
|
|
|
insertions.push({ chord: c.chord, index: targetIndex });
|
|
});
|
|
|
|
insertions.sort((a, b) => b.index - a.index);
|
|
|
|
let result = targetClean;
|
|
insertions.forEach(ins => {
|
|
result = result.substring(0, ins.index) + `[${ins.chord}]` + result.substring(ins.index);
|
|
});
|
|
|
|
return result;
|
|
};
|
|
|
|
const newLines = [...lines];
|
|
|
|
sections.forEach(sec => {
|
|
if (!sec.hasChords && sec.type !== 'other') {
|
|
const template = sec.type === 'verse' ? templateVerse : templateChorus;
|
|
if (template) {
|
|
for (let j = 0; j < sec.lines.length; j++) {
|
|
const targetSecLine = sec.lines[j];
|
|
if (j < template.lines.length) {
|
|
const templateSecLine = template.lines[j];
|
|
const aligned = alignChords(templateSecLine.text, targetSecLine.text);
|
|
if (aligned !== targetSecLine.text) {
|
|
newLines[targetSecLine.originalIndex] = aligned;
|
|
deducedCount++;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
if (deducedCount > 0) {
|
|
this.content = newLines.join('\n');
|
|
const toast = await this.toastController.create({
|
|
message: `Accordi dedotti con successo in ${deducedCount} righe!`,
|
|
duration: 3000,
|
|
color: 'success'
|
|
});
|
|
toast.present();
|
|
} else {
|
|
const toast = await this.toastController.create({
|
|
message: 'Nessuna strofa compatibile trovata o accordi già presenti.',
|
|
duration: 3000,
|
|
color: 'warning'
|
|
});
|
|
toast.present();
|
|
}
|
|
}
|
|
|
|
onDragOver(event: DragEvent) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
// Mostra l'overlay solo se si sta trascinando un file
|
|
if (event.dataTransfer?.types.includes('Files')) {
|
|
this.isDraggingOver = true;
|
|
}
|
|
}
|
|
|
|
onDragLeave(event: DragEvent) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
this.isDraggingOver = false;
|
|
}
|
|
|
|
async onDrop(event: DragEvent) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
this.isDraggingOver = false;
|
|
|
|
if (event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length > 0) {
|
|
const file = event.dataTransfer.files[0];
|
|
const isPdf = file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf');
|
|
if (file.type.indexOf('image') !== -1 || isPdf) {
|
|
await this.processFile(file);
|
|
} else {
|
|
const toast = await this.toastController.create({
|
|
message: 'Per favore, trascina un file immagine o PDF valido.',
|
|
duration: 3000,
|
|
color: 'warning'
|
|
});
|
|
toast.present();
|
|
}
|
|
}
|
|
}
|
|
|
|
async onFileSelected(event: any, isCamera: boolean) {
|
|
const file = event.target.files[0];
|
|
if (!file) {
|
|
console.log('[OCR-Capture] Nessun file selezionato.');
|
|
return;
|
|
}
|
|
await this.processFile(file);
|
|
event.target.value = '';
|
|
}
|
|
|
|
async onPaste(event: ClipboardEvent) {
|
|
const items = event.clipboardData?.items;
|
|
if (!items) return;
|
|
|
|
let hasImage = false;
|
|
for (let i = 0; i < items.length; i++) {
|
|
if (items[i].type.indexOf('image') !== -1) {
|
|
hasImage = true;
|
|
event.preventDefault(); // Prevent pasting the image representation as text
|
|
const blob = items[i].getAsFile();
|
|
if (blob) {
|
|
const file = new File([blob], 'pasted-image.png', { type: blob.type });
|
|
await this.processFile(file);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!hasImage) {
|
|
const pastedText = event.clipboardData?.getData('text');
|
|
if (pastedText && this.looksLikeChordSheetOrSong(pastedText)) {
|
|
event.preventDefault();
|
|
const parsed = this.parsePastedChordSheet(pastedText);
|
|
this.insertText(parsed);
|
|
}
|
|
}
|
|
}
|
|
|
|
isTextLineChords(line: string, isItalian: boolean): boolean {
|
|
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.ocrProgress = 0;
|
|
|
|
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...');
|
|
const compressedBlob = await this.resizeImage(file);
|
|
const compressedFile = new File([compressedBlob], file.name, { type: 'image/jpeg' });
|
|
console.log(`[OCR-Capture] Dimensioni dopo compressione: ${(compressedFile.size / 1024).toFixed(1)} KB`);
|
|
|
|
console.log('[OCR-Capture] Avvio motore Tesseract OCR local...');
|
|
const extractedText = await this.processImageOCR(compressedFile);
|
|
|
|
if (extractedText) {
|
|
console.log('[OCR-Capture] Testo estratto con successo! Inserimento nell\'editor...');
|
|
this.content += (this.content ? '\n\n' : '') + extractedText;
|
|
const toast = await this.toastController.create({
|
|
message: 'Scansione completata!',
|
|
duration: 2000,
|
|
color: 'success'
|
|
});
|
|
toast.present();
|
|
} else {
|
|
console.warn('[OCR-Capture] Nessun testo estratto dal file.');
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('[OCR-Capture] Errore durante l\'elaborazione del file:', error);
|
|
const errorToast = await this.toastController.create({
|
|
message: 'Errore durante la scansione del file.',
|
|
duration: 3000,
|
|
color: 'danger'
|
|
});
|
|
errorToast.present();
|
|
} finally {
|
|
this.isProcessingOCR = false;
|
|
this.ocrProgress = 0;
|
|
}
|
|
}
|
|
|
|
async resizeImage(file: File): Promise<Blob> {
|
|
console.log('[OCR-Capture] Caricamento immagine in memoria...');
|
|
return new Promise((resolve, reject) => {
|
|
const img = new Image();
|
|
img.onload = () => {
|
|
console.log(`[OCR-Capture] Immagine caricata in memoria. Dimensioni originali: ${img.width}x${img.height}`);
|
|
const canvas = document.createElement('canvas');
|
|
const maxDim = 1200;
|
|
let width = img.width;
|
|
let height = img.height;
|
|
|
|
if (width > maxDim || height > maxDim) {
|
|
if (width > height) {
|
|
height = Math.round((height * maxDim) / width);
|
|
width = maxDim;
|
|
} else {
|
|
width = Math.round((width * maxDim) / height);
|
|
height = maxDim;
|
|
}
|
|
}
|
|
|
|
console.log(`[OCR-Capture] Ridimensionamento a: ${width}x${height}`);
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
const ctx = canvas.getContext('2d');
|
|
if (ctx) {
|
|
ctx.drawImage(img, 0, 0, width, height);
|
|
canvas.toBlob(blob => {
|
|
if (blob) {
|
|
console.log('[OCR-Capture] Compressione in JPEG completata con successo.');
|
|
resolve(blob);
|
|
} else {
|
|
reject(new Error('Canvas toBlob failed'));
|
|
}
|
|
}, 'image/jpeg', 0.85);
|
|
} else {
|
|
reject(new Error('Canvas getContext 2d failed'));
|
|
}
|
|
};
|
|
img.onerror = (err) => {
|
|
console.error('[OCR-Capture] Impossibile caricare l\'immagine in memoria:', err);
|
|
reject(err);
|
|
};
|
|
img.src = URL.createObjectURL(file);
|
|
});
|
|
}
|
|
|
|
async processImageOCR(file: File): Promise<string> {
|
|
console.log('[OCR-Capture] Inizializzazione Worker Tesseract.js...');
|
|
const worker = await createWorker('ita', 1, {
|
|
logger: m => {
|
|
if (m.status === 'recognizing text') {
|
|
this.ocrProgress = m.progress;
|
|
console.log(`[OCR-Capture] Progresso OCR: ${(m.progress * 100).toFixed(0)}%`);
|
|
}
|
|
}
|
|
});
|
|
console.log('[OCR-Capture] Avvio riconoscimento caratteri (OCR) con blocks abilitato...');
|
|
const { data } = await worker.recognize(file, {}, { blocks: true });
|
|
console.log('[OCR-Capture] Riconoscimento caratteri terminato. Spegnimento worker...');
|
|
await worker.terminate();
|
|
console.log('[OCR-Capture] Spegnimento worker completato. Estrazione parole...');
|
|
|
|
// Flatten blocks hierarchy to get a flat words list
|
|
const words: any[] = [];
|
|
if (data && (data as any).blocks) {
|
|
const blocks = (data as any).blocks;
|
|
blocks.forEach((block: any) => {
|
|
if (block.paragraphs) {
|
|
block.paragraphs.forEach((paragraph: any) => {
|
|
if (paragraph.lines) {
|
|
paragraph.lines.forEach((line: any) => {
|
|
if (line.words) {
|
|
line.words.forEach((word: any) => {
|
|
words.push(word);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
console.log(`[OCR-Capture] Parole estratte dal blocco gerarchico: ${words.length}`);
|
|
return this.parseSongSpatially(words);
|
|
}
|
|
|
|
sanitizeOcrChord(text: string): string {
|
|
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('/')) {
|
|
return text.split('/').map(part => this.sanitizeOcrChord(part.trim())).join('/');
|
|
}
|
|
let cleaned = text;
|
|
// Replace D0/d0 with DO/do
|
|
cleaned = cleaned.replace(/^D0/gi, 'DO');
|
|
// Replace S0L/s0l with SOL/sol
|
|
cleaned = cleaned.replace(/^S0L/gi, 'SOL');
|
|
// Clean H/sharp mismatches:
|
|
cleaned = cleaned.replace(/H#/gi, '#');
|
|
cleaned = cleaned.replace(/#H/gi, '#');
|
|
cleaned = cleaned.replace(/([CDEFGAB]|DO|RE|MI|FA|SOL|LA|SI)H/gi, '$1#');
|
|
// Clean duplicate sharps (e.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;
|
|
}
|
|
|
|
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, allowLowercase: boolean = false): boolean {
|
|
if (!text || text.length === 0) return false;
|
|
const lower = text.toLowerCase();
|
|
// Skip common valid words written purely in lowercase unless allowLowercase is true
|
|
if (!allowLowercase && 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.includes('/')) {
|
|
return chord.split('/').map(part => this.convertEnglishChordToItalian(part.trim(), isItalian)).join('/');
|
|
}
|
|
const upper = chord.toUpperCase();
|
|
if (upper.startsWith('DO')) {
|
|
return chord;
|
|
}
|
|
if (upper.startsWith('FA')) {
|
|
if (upper.startsWith('FAUG') || upper.startsWith('FADD') || upper.startsWith('FALT')) {
|
|
return 'FA' + chord.slice(1);
|
|
}
|
|
return chord;
|
|
}
|
|
if (upper.startsWith('C')) {
|
|
return 'DO' + chord.slice(1);
|
|
}
|
|
if (upper.startsWith('D')) {
|
|
return 'RE' + chord.slice(1);
|
|
}
|
|
if (upper.startsWith('E')) {
|
|
return 'MI' + chord.slice(1);
|
|
}
|
|
if (upper.startsWith('F')) {
|
|
return 'FA' + chord.slice(1);
|
|
}
|
|
if (upper.startsWith('G')) {
|
|
return 'SOL' + chord.slice(1);
|
|
}
|
|
if (upper.startsWith('A')) {
|
|
return 'LA' + chord.slice(1);
|
|
}
|
|
if (upper.startsWith('B')) {
|
|
return 'SI' + chord.slice(1);
|
|
}
|
|
return chord;
|
|
}
|
|
|
|
isLabelLine(text: string): boolean {
|
|
return /^(Intro|Strofa|Rit|Special|Coro|Bridge|RIT|CHORUS|VERSE)/i.test(text.trim());
|
|
}
|
|
|
|
parseSongSpatially(words: any[]): string {
|
|
if (!words || words.length === 0) {
|
|
console.warn('[OCR-Capture] Nessuna parola ricevuta dall\'OCR.');
|
|
return '';
|
|
}
|
|
|
|
const isItalian = this.isItalianNotation(words);
|
|
console.log(`[OCR-Capture] Rilevata notazione italiana: ${isItalian}`);
|
|
|
|
// Preprocess words to split run-together chords like BA
|
|
const preprocessedWords: any[] = [];
|
|
words.forEach(w => {
|
|
if (!w.text) return;
|
|
const match = w.text.match(/^([ABCDEFG])([ABCDEFG])$/i);
|
|
if (match && !(match[1].toUpperCase() === 'F' && match[2].toUpperCase() === 'A')) {
|
|
const charWidth = (w.bbox.x1 - w.bbox.x0) / 2;
|
|
preprocessedWords.push({
|
|
text: match[1],
|
|
bbox: { ...w.bbox, x1: w.bbox.x0 + charWidth }
|
|
});
|
|
preprocessedWords.push({
|
|
text: match[2],
|
|
bbox: { ...w.bbox, x0: w.bbox.x0 + charWidth }
|
|
});
|
|
} else {
|
|
preprocessedWords.push(w);
|
|
}
|
|
});
|
|
words = preprocessedWords;
|
|
|
|
console.log(`[OCR-Capture] Parole totali ricevute dall'OCR: ${words.length}`);
|
|
const validWords = words.filter(w => w.text && w.text.trim().length > 0);
|
|
console.log(`[OCR-Capture] Parole valide dopo filtraggio: ${validWords.length}`);
|
|
if (validWords.length === 0) return '';
|
|
|
|
// Calculate average word height to set vertical tolerance
|
|
const heights = validWords.map(w => w.bbox.y1 - w.bbox.y0);
|
|
const avgHeight = heights.reduce((sum, h) => sum + h, 0) / heights.length;
|
|
const verticalTolerance = avgHeight * 1.0;
|
|
console.log(`[OCR-Capture] Altezza media carattere: ${avgHeight.toFixed(1)}px, tolleranza verticale: ${verticalTolerance.toFixed(1)}px`);
|
|
|
|
// 2. Group words into horizontal lines
|
|
const lines: any[][] = [];
|
|
validWords.forEach(word => {
|
|
const wordYCenter = (word.bbox.y0 + word.bbox.y1) / 2;
|
|
let added = false;
|
|
for (const line of lines) {
|
|
const avgLineYCenter = line.reduce((sum, w) => sum + (w.bbox.y0 + w.bbox.y1) / 2, 0) / line.length;
|
|
if (Math.abs(wordYCenter - avgLineYCenter) < verticalTolerance) {
|
|
line.push(word);
|
|
added = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!added) {
|
|
lines.push([word]);
|
|
}
|
|
});
|
|
|
|
// Sort words horizontally in each line
|
|
lines.forEach(line => line.sort((a, b) => a.bbox.x0 - b.bbox.x0));
|
|
|
|
// Sort all lines vertically by average y0
|
|
lines.sort((a, b) => {
|
|
const avgA = a.reduce((sum, w) => sum + w.bbox.y0, 0) / a.length;
|
|
const avgB = b.reduce((sum, w) => sum + w.bbox.y0, 0) / b.length;
|
|
return avgA - avgB;
|
|
});
|
|
|
|
// 3. Classify lines as Chords vs. Text
|
|
const chordRegex = this.getChordRegex(isItalian);
|
|
|
|
const classifiedLines = lines.map(line => {
|
|
let chordCount = 0;
|
|
let hasLongNonChord = false;
|
|
|
|
line.forEach(w => {
|
|
if (this.isChordWord(w.text, isItalian, true)) {
|
|
chordCount++;
|
|
} else {
|
|
const clean = w.text.replace(/[.,:;!\?]/g, '').trim();
|
|
if (clean.length > 5) {
|
|
hasLongNonChord = true;
|
|
}
|
|
}
|
|
});
|
|
|
|
const ratio = line.length > 0 ? chordCount / line.length : 0;
|
|
let isChords = false;
|
|
|
|
if (ratio >= 0.4 && line.length <= 10) {
|
|
if (!hasLongNonChord || ratio >= 0.75) {
|
|
isChords = true;
|
|
}
|
|
}
|
|
|
|
return {
|
|
words: line,
|
|
isChords: isChords,
|
|
yCenter: line.reduce((sum, w) => sum + (w.bbox.y0 + w.bbox.y1)/2, 0) / line.length
|
|
};
|
|
});
|
|
|
|
// 4. Merge chords and text lines
|
|
const processedLines: string[] = [];
|
|
let inChorus = false;
|
|
let inVerse = false;
|
|
const chorusStartRegex = /^(R:|Rit\.|Rit|Ritornello|Coro)/i;
|
|
|
|
for (let i = 0; i < classifiedLines.length; i++) {
|
|
const current = classifiedLines[i];
|
|
|
|
if (current.isChords) {
|
|
const next = (i + 1 < classifiedLines.length) ? classifiedLines[i + 1] : null;
|
|
const nextText = next ? next.words.map(w => w.text).join(' ') : '';
|
|
if (next && !next.isChords && !this.isLabelLine(nextText)) {
|
|
// Merge spatially!
|
|
const merged = this.mergeChordsAndLyrics(current.words, next.words, isItalian);
|
|
const lineText = next.words.map(w => w.text).join(' ');
|
|
const isChorus = chorusStartRegex.test(lineText);
|
|
|
|
if (isChorus) {
|
|
if (inVerse) { processedLines.push('{end_verse}'); inVerse = false; }
|
|
if (!inChorus) { processedLines.push('{start_chorus}'); inChorus = true; }
|
|
} else if (!inChorus && !inVerse && lineText.length > 5) {
|
|
processedLines.push('{start_verse}');
|
|
inVerse = true;
|
|
}
|
|
|
|
processedLines.push(merged);
|
|
i++; // Skip next line because we consumed it!
|
|
} else {
|
|
// Chord line but no text below it: just wrap and print
|
|
const multiChordRegex = this.getMultiChordRegex(isItalian);
|
|
const expandedChords: string[] = [];
|
|
current.words.forEach(w => {
|
|
const parts = w.text.split(/(_)/);
|
|
const processedParts = parts.map((part: string) => {
|
|
if (part === '_') return ' _ ';
|
|
if (!part.trim()) return part;
|
|
|
|
let cleanText = part.toUpperCase().replace(/\s+/g, '');
|
|
cleanText = cleanText.replace(/\((.*?)\)/g, '/$1');
|
|
cleanText = cleanText.replace(/[\.\,]$/g, '');
|
|
cleanText = this.sanitizeOcrChord(cleanText);
|
|
|
|
if (this.isChordWord(cleanText, isItalian)) {
|
|
return `[${this.convertEnglishChordToItalian(cleanText, isItalian)}]`;
|
|
} else {
|
|
const matches = [...cleanText.matchAll(multiChordRegex)];
|
|
const fullMatchStr = matches.map(m => m[0]).join('');
|
|
if (matches.length > 0 && fullMatchStr === cleanText) {
|
|
return matches.map((m: any) => `[${this.convertEnglishChordToItalian(m[0].toUpperCase(), isItalian)}]`).join(' ');
|
|
} else {
|
|
return part;
|
|
}
|
|
}
|
|
});
|
|
expandedChords.push(processedParts.join(''));
|
|
});
|
|
const wrapped = expandedChords.join(' ');
|
|
if (wrapped) {
|
|
processedLines.push(wrapped);
|
|
}
|
|
}
|
|
} else {
|
|
const lineText = current.words.map(w => w.text).join(' ');
|
|
const isChorus = chorusStartRegex.test(lineText);
|
|
|
|
if (isChorus) {
|
|
if (inVerse) { processedLines.push('{end_verse}'); inVerse = false; }
|
|
if (!inChorus) { processedLines.push('{start_chorus}'); inChorus = true; }
|
|
} else if (!inChorus && !inVerse && lineText.length > 5) {
|
|
processedLines.push('{start_verse}');
|
|
inVerse = true;
|
|
}
|
|
|
|
processedLines.push(this.wrapChords(lineText, chordRegex, isItalian));
|
|
}
|
|
|
|
// If we see a large vertical gap, close open blocks
|
|
const lastProcessedLine = classifiedLines[i];
|
|
const currentNext = (i + 1 < classifiedLines.length) ? classifiedLines[i + 1] : null;
|
|
if (currentNext && lastProcessedLine) {
|
|
const gap = currentNext.yCenter - lastProcessedLine.yCenter;
|
|
if (gap > avgHeight * 3.5) {
|
|
if (inChorus) { processedLines.push('{end_chorus}'); inChorus = false; }
|
|
if (inVerse) { processedLines.push('{end_verse}'); inVerse = false; }
|
|
processedLines.push('');
|
|
}
|
|
}
|
|
}
|
|
|
|
if (inChorus) processedLines.push('{end_chorus}');
|
|
if (inVerse) processedLines.push('{end_verse}');
|
|
|
|
return processedLines.join('\n');
|
|
}
|
|
|
|
mergeChordsAndLyrics(chordWords: any[], textWords: any[], isItalian: boolean = false): string {
|
|
// Pre-process chordWords to merge fragmented bass notes like 'Re', '(f', 'fa#)'
|
|
let mergedChordWords: any[] = [];
|
|
for (let i = 0; i < chordWords.length; i++) {
|
|
let cw = chordWords[i];
|
|
if (cw.text.startsWith('(') && mergedChordWords.length > 0) {
|
|
let prev = mergedChordWords[mergedChordWords.length - 1];
|
|
prev.text += cw.text;
|
|
prev.bbox.x1 = Math.max(prev.bbox.x1, cw.bbox.x1);
|
|
if (!prev.text.includes(')')) {
|
|
let j = i + 1;
|
|
while (j < chordWords.length) {
|
|
prev.text += chordWords[j].text;
|
|
prev.bbox.x1 = Math.max(prev.bbox.x1, chordWords[j].bbox.x1);
|
|
if (chordWords[j].text.includes(')')) {
|
|
i = j;
|
|
break;
|
|
}
|
|
j++;
|
|
}
|
|
}
|
|
} else {
|
|
let text = cw.text;
|
|
let bbox = { ...cw.bbox };
|
|
if (text.includes('(') && !text.includes(')')) {
|
|
let j = i + 1;
|
|
while (j < chordWords.length) {
|
|
text += chordWords[j].text;
|
|
bbox.x1 = Math.max(bbox.x1, chordWords[j].bbox.x1);
|
|
if (chordWords[j].text.includes(')')) {
|
|
i = j;
|
|
break;
|
|
}
|
|
j++;
|
|
}
|
|
}
|
|
mergedChordWords.push({ text, bbox });
|
|
}
|
|
}
|
|
|
|
// Sanitize common OCR errors in chords (e.g., 'Re(f fa#)' -> 'Re(fa#)')
|
|
mergedChordWords.forEach(cw => {
|
|
cw.text = cw.text.replace(/f\s*fa#/gi, 'fa#');
|
|
cw.text = cw.text.replace(/ff/gi, 'f');
|
|
cw.text = cw.text.replace(/m\s*mi/gi, 'mi');
|
|
cw.text = cw.text.replace(/mm/gi, 'm');
|
|
});
|
|
|
|
if (!textWords || textWords.length === 0) {
|
|
return mergedChordWords.map(c => {
|
|
const parts = c.text.split(/(_)/);
|
|
return parts.map((part: string) => {
|
|
if (part === '_') return ' _ ';
|
|
if (!part.trim()) return part;
|
|
let clean = part.toUpperCase().replace(/\s+/g, '').replace(/\((.*?)\)/g, '/$1').replace(/[\.\,\[\]]/g, '');
|
|
clean = this.sanitizeOcrChord(clean);
|
|
if (this.isChordWord(part, isItalian, true)) {
|
|
return `[${this.convertEnglishChordToItalian(clean, isItalian)}]`;
|
|
} else {
|
|
return part;
|
|
}
|
|
}).join('');
|
|
}).join(' ');
|
|
}
|
|
|
|
const multiChordRegex = this.getMultiChordRegex(isItalian);
|
|
|
|
const expandedChordWords: any[] = [];
|
|
mergedChordWords.forEach(chord => {
|
|
const parts = chord.text.split(/(_)/);
|
|
let currentX = chord.bbox.x0;
|
|
const totalLen = chord.text.length || 1;
|
|
const widthPerChar = (chord.bbox.x1 - chord.bbox.x0) / totalLen;
|
|
|
|
parts.forEach((part: string) => {
|
|
const partLen = part.length;
|
|
const partWidth = partLen * widthPerChar;
|
|
const partX0 = currentX;
|
|
const partX1 = currentX + partWidth;
|
|
currentX = partX1;
|
|
|
|
if (part === '_' || !part.trim()) {
|
|
return;
|
|
}
|
|
|
|
let originalText = part.toUpperCase().replace(/\s+/g, '').replace(/\((.*?)\)/g, '/$1').replace(/[\.\,\[\]]/g, '');
|
|
originalText = this.sanitizeOcrChord(originalText);
|
|
const matches = [...originalText.matchAll(multiChordRegex)];
|
|
|
|
if (matches.length === 0) {
|
|
expandedChordWords.push({
|
|
text: originalText,
|
|
bbox: { ...chord.bbox, x0: partX0, x1: partX1 }
|
|
});
|
|
} else {
|
|
const fullMatchStr = matches.map(m => m[0]).join('');
|
|
if (fullMatchStr === originalText) {
|
|
const matchCharWidth = (partX1 - partX0) / Math.max(1, originalText.length);
|
|
matches.forEach(match => {
|
|
const matchIndex = match.index!;
|
|
const matchLength = match[0].length;
|
|
const newX0 = partX0 + matchIndex * matchCharWidth;
|
|
const newX1 = partX0 + (matchIndex + matchLength) * matchCharWidth;
|
|
|
|
expandedChordWords.push({
|
|
text: match[0],
|
|
bbox: { ...chord.bbox, x0: newX0, x1: newX1 }
|
|
});
|
|
});
|
|
} else {
|
|
expandedChordWords.push({
|
|
text: originalText,
|
|
bbox: { ...chord.bbox, x0: partX0, x1: partX1 }
|
|
});
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
const chordAssignments = new Map<any, any[]>();
|
|
|
|
expandedChordWords.forEach(chord => {
|
|
const chordX = (chord.bbox.x0 + chord.bbox.x1) / 2;
|
|
let closestWord: any = null;
|
|
let minDistance = Infinity;
|
|
|
|
textWords.forEach(textWord => {
|
|
let dist = 0;
|
|
if (chordX < textWord.bbox.x0) {
|
|
dist = textWord.bbox.x0 - chordX;
|
|
} else if (chordX > textWord.bbox.x1) {
|
|
dist = chordX - textWord.bbox.x1;
|
|
}
|
|
|
|
if (dist < minDistance) {
|
|
minDistance = dist;
|
|
closestWord = textWord;
|
|
}
|
|
});
|
|
|
|
if (closestWord) {
|
|
if (!chordAssignments.has(closestWord)) {
|
|
chordAssignments.set(closestWord, []);
|
|
}
|
|
chordAssignments.get(closestWord)!.push(chord);
|
|
}
|
|
});
|
|
|
|
let result = '';
|
|
|
|
textWords.forEach((textWord, index) => {
|
|
const assignedChords = chordAssignments.get(textWord) || [];
|
|
assignedChords.sort((a, b) => a.bbox.x0 - b.bbox.x0);
|
|
|
|
const wordText = textWord.text;
|
|
let charWidth = (textWord.bbox.x1 - textWord.bbox.x0) / Math.max(1, wordText.length);
|
|
if (charWidth <= 0) charWidth = 6; // safe fallback
|
|
|
|
let lastCharIndex = 0;
|
|
let wordResult = '';
|
|
|
|
assignedChords.forEach(chord => {
|
|
const chordX = (chord.bbox.x0 + chord.bbox.x1) / 2;
|
|
let charIndex = Math.round((chordX - textWord.bbox.x0) / charWidth);
|
|
|
|
if (charIndex < 0) charIndex = 0;
|
|
if (charIndex > wordText.length) charIndex = wordText.length;
|
|
|
|
let cleanChord = chord.text.replace(/[\(\)\[\]]/g, '').toUpperCase();
|
|
cleanChord = this.sanitizeOcrChord(cleanChord);
|
|
if (this.isChordWord(chord.text, isItalian, true)) {
|
|
wordResult += wordText.substring(lastCharIndex, charIndex) + `[${this.convertEnglishChordToItalian(cleanChord, isItalian)}]`;
|
|
} else {
|
|
wordResult += wordText.substring(lastCharIndex, charIndex);
|
|
}
|
|
lastCharIndex = charIndex;
|
|
});
|
|
|
|
wordResult += wordText.substring(lastCharIndex);
|
|
result += wordResult;
|
|
|
|
if (index < textWords.length - 1) {
|
|
result += ' ';
|
|
}
|
|
});
|
|
|
|
let finalResult = result.replace(/\]\[/g, '] [');
|
|
finalResult = finalResult.replace(/\]_\[/g, '] _ [');
|
|
finalResult = finalResult.replace(/\]_/g, '] _ ');
|
|
finalResult = finalResult.replace(/_\[/g, ' _ [');
|
|
// Normalize any duplicate spaces around underscores:
|
|
finalResult = finalResult.replace(/\s*_\s*/g, ' _ ');
|
|
|
|
console.log(`[OCR-Debug] Linea generata: ${finalResult}`);
|
|
|
|
return finalResult;
|
|
}
|
|
|
|
|
|
|
|
smartProcessOCR(text: string): string {
|
|
let lines = text.split('\n');
|
|
let processedLines: string[] = [];
|
|
|
|
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 inVerse = false;
|
|
|
|
lines.forEach((line) => {
|
|
let trimmed = line.trim();
|
|
if (!trimmed) {
|
|
if (inChorus) { processedLines.push('{end_chorus}'); inChorus = false; }
|
|
if (inVerse) { processedLines.push('{end_verse}'); inVerse = false; }
|
|
processedLines.push('');
|
|
return;
|
|
}
|
|
|
|
const isChorusMarker = /^(Rit|Ritornello|Chorus|CORO)/i.test(trimmed);
|
|
if (isChorusMarker && !inChorus) {
|
|
if (inVerse) { processedLines.push('{end_verse}'); inVerse = false; }
|
|
processedLines.push('{start_chorus}');
|
|
inChorus = true;
|
|
if (trimmed.length > 12) processedLines.push(this.wrapChords(trimmed, chordRegex, isItalian));
|
|
} else {
|
|
if (!inVerse && !inChorus && trimmed.length > 5) {
|
|
processedLines.push('{start_verse}');
|
|
inVerse = true;
|
|
}
|
|
processedLines.push(this.wrapChords(trimmed, chordRegex, isItalian));
|
|
}
|
|
});
|
|
|
|
if (inChorus) processedLines.push('{end_chorus}');
|
|
if (inVerse) processedLines.push('{end_verse}');
|
|
return processedLines.join('\n');
|
|
}
|
|
|
|
private wrapChords(line: string, regex: RegExp, isItalian: boolean): string {
|
|
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(/(_)/);
|
|
return parts.map((part: string) => {
|
|
if (part === '_') return ' _ ';
|
|
return part.replace(globalRegex, (match) => {
|
|
const lower = match.toLowerCase();
|
|
if (['a', 'e', 'o', 'i'].includes(lower)) {
|
|
return match;
|
|
}
|
|
if (['la', 'mi', 're', 'do', 'si', 'fa', 'sol'].includes(lower)) {
|
|
return match;
|
|
}
|
|
const sanitized = this.sanitizeOcrChord(match.toUpperCase());
|
|
return `[${this.convertEnglishChordToItalian(sanitized, isItalian)}]`;
|
|
});
|
|
}).join('');
|
|
}
|
|
|
|
|
|
|
|
async saveToMyCanti() {
|
|
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
|
|
const id_momenti = [...this.selectedLiturgico, ...this.selectedTematico];
|
|
|
|
const activePlaylistId = this.playlistService.activePlaylistId();
|
|
const isRemotePlaylist = activePlaylistId && activePlaylistId.startsWith('remote_');
|
|
|
|
if (isRemotePlaylist) {
|
|
// 1. Clone the song (generate a brand new my_... ID)
|
|
const savedCanto = await this.myCantiService.saveCanto({
|
|
titolo: this.title,
|
|
autore: this.author,
|
|
link_youtube: this.youtubeLink,
|
|
testo: this.content,
|
|
accordi: this.content,
|
|
id_momenti: id_momenti,
|
|
durata: this.durata || undefined,
|
|
bpm: this.bpm !== null && this.bpm !== undefined && !isNaN(Number(this.bpm)) ? Number(this.bpm) : undefined
|
|
});
|
|
|
|
// 2. Clone/convert remote playlist to local personal playlist
|
|
const remotePl = this.playlistService.remotePlaylist();
|
|
if (remotePl) {
|
|
const originalIds = remotePl.ids || [];
|
|
const updatedIds = originalIds.map((id: string) => id === this.editId ? savedCanto.id : id);
|
|
|
|
const songSettings = { ...(remotePl.songSettings || {}) };
|
|
if (this.editId && songSettings[this.editId]) {
|
|
songSettings[savedCanto.id] = { ...songSettings[this.editId] };
|
|
delete songSettings[this.editId];
|
|
}
|
|
|
|
const localName = remotePl.name.replace('[Remote] ', '');
|
|
|
|
// Force save as a new local playlist
|
|
this.playlistService.activePlaylistId.set(null);
|
|
await this.playlistService.savePlaylist(localName, updatedIds, songSettings);
|
|
}
|
|
|
|
// Navigate to the player with the new cloned song ID immediately
|
|
this.router.navigate(['/player'], { queryParams: { id: savedCanto.id }, replaceUrl: true });
|
|
} else {
|
|
// Standard path
|
|
const savedCanto = await this.myCantiService.saveCanto({
|
|
id: this.editId || undefined,
|
|
titolo: this.title,
|
|
autore: this.author,
|
|
link_youtube: this.youtubeLink,
|
|
testo: this.content,
|
|
accordi: this.content, // Save to both fields for compatibility
|
|
id_momenti: id_momenti,
|
|
durata: this.durata || undefined,
|
|
bpm: this.bpm !== null && this.bpm !== undefined && !isNaN(Number(this.bpm)) ? Number(this.bpm) : undefined
|
|
});
|
|
|
|
if (this.editId && !this.editId.startsWith('my_') && savedCanto && savedCanto.id) {
|
|
await this.playlistService.replaceSongIdInPlaylists(this.editId, savedCanto.id);
|
|
}
|
|
|
|
if (this.editId && this.editId.startsWith('remote_share_')) {
|
|
await this.playlistService.deleteRemoteShareCanto(this.editId);
|
|
}
|
|
|
|
if (this.editId && savedCanto && savedCanto.id && this.editId !== savedCanto.id) {
|
|
this.router.navigate(['/player'], { queryParams: { id: savedCanto.id }, replaceUrl: true });
|
|
} else {
|
|
this.navCtrl.back();
|
|
}
|
|
}
|
|
}
|
|
}
|