Initial release: DictIA v0.8.14-alpha (fork de Speakr, AGPL-3.0)

This commit is contained in:
InnovA AI
2026-03-16 21:47:37 +00:00
commit 42772a31ed
365 changed files with 103572 additions and 0 deletions

View File

@@ -0,0 +1,484 @@
/**
* Transcription editing composable
* Handles ASR editor, text editor, and segment management
*/
export function useTranscription(state, utils) {
const {
showTextEditorModal, showAsrEditorModal, selectedRecording,
editingTranscriptionContent, editingSegments, availableSpeakers,
recordings, dropdownPositions, openAsrDropdownIndex
} = state;
const { showToast, setGlobalError, nextTick } = utils;
// =========================================
// Text Editor Modal
// =========================================
const openTranscriptionEditor = () => {
if (!selectedRecording.value || !selectedRecording.value.transcription) {
return;
}
// Check if transcription is JSON (ASR format)
try {
const parsed = JSON.parse(selectedRecording.value.transcription);
if (Array.isArray(parsed)) {
openAsrEditorModal();
} else {
openTextEditorModal();
}
} catch (e) {
// Not JSON, use text editor
openTextEditorModal();
}
};
const openTextEditorModal = () => {
if (!selectedRecording.value) return;
editingTranscriptionContent.value = selectedRecording.value.transcription || '';
showTextEditorModal.value = true;
};
const closeTextEditorModal = () => {
showTextEditorModal.value = false;
editingTranscriptionContent.value = '';
};
const saveTranscription = async () => {
if (!selectedRecording.value) return;
await saveTranscriptionContent(editingTranscriptionContent.value);
closeTextEditorModal();
};
// =========================================
// ASR Editor Modal
// =========================================
// Helper to pause outer audio player when opening modals with their own player
const pauseOuterAudioPlayer = () => {
const outerAudio = document.querySelector('#rightMainColumn audio') || document.querySelector('#rightMainColumn video') ||
document.querySelector('.detail-view audio:not(.fixed audio)') || document.querySelector('.detail-view video:not(.fixed video)');
if (outerAudio && !outerAudio.paused) {
outerAudio.pause();
}
};
const openAsrEditorModal = async () => {
if (!selectedRecording.value) return;
// Pause outer audio player to avoid conflicts with modal's player
pauseOuterAudioPlayer();
try {
const segments = JSON.parse(selectedRecording.value.transcription);
// Populate available speakers from THIS recording only
const speakersInTranscript = [...new Set(segments.map(s => s.speaker))].sort();
availableSpeakers.value = speakersInTranscript;
editingSegments.value = segments.map((s, i) => ({
...s,
id: i,
showSuggestions: false,
filteredSpeakers: [...speakersInTranscript]
}));
showAsrEditorModal.value = true;
// Reset virtual scroll state for fresh modal render
if (utils.resetAsrEditorScroll) {
utils.resetAsrEditorScroll();
}
} catch (e) {
console.error("Could not parse transcription as JSON for ASR editor:", e);
setGlobalError("This transcription is not in the correct format for the ASR editor.");
}
};
const closeAsrEditorModal = () => {
// Pause any playing modal audio before closing
const modalAudio = document.querySelector('.fixed.z-50 audio') || document.querySelector('.fixed.z-50 video');
if (modalAudio) {
modalAudio.pause();
}
// Reset modal audio state (keep main player independent)
if (utils.resetModalAudioState) {
utils.resetModalAudioState();
}
showAsrEditorModal.value = false;
editingSegments.value = [];
};
const saveAsrTranscription = async () => {
if (!selectedRecording.value) return;
// Remove extra UI fields and save the rest
const contentToSave = JSON.stringify(editingSegments.value.map(({ id, showSuggestions, filteredSpeakers, ...rest }) => rest));
await saveTranscriptionContent(contentToSave);
closeAsrEditorModal();
};
// =========================================
// Segment Management
// =========================================
const adjustTime = (index, field, amount) => {
if (editingSegments.value[index]) {
editingSegments.value[index][field] = Math.max(0,
editingSegments.value[index][field] + amount
);
}
};
const filterSpeakerSuggestions = (index) => {
const segment = editingSegments.value[index];
if (segment) {
const query = segment.speaker?.toLowerCase() || '';
if (query === '') {
segment.filteredSpeakers = [...availableSpeakers.value];
} else {
segment.filteredSpeakers = availableSpeakers.value.filter(
speaker => speaker.toLowerCase().includes(query)
);
}
}
};
// O(1) dropdown management using single ref instead of O(n) forEach
const openSpeakerSuggestions = (index) => {
if (editingSegments.value[index]) {
// Simply set the open index - O(1) instead of O(n) forEach
openAsrDropdownIndex.value = index;
filterSpeakerSuggestions(index);
updateDropdownPosition(index);
}
};
const closeSpeakerSuggestions = (index) => {
// Only close if this index is currently open
if (openAsrDropdownIndex.value === index) {
openAsrDropdownIndex.value = null;
}
};
const closeAllSpeakerSuggestions = () => {
// O(1) instead of O(n) - just set to null
openAsrDropdownIndex.value = null;
};
// Helper to check if a dropdown is open (for template v-if)
const isDropdownOpen = (index) => {
return openAsrDropdownIndex.value === index;
};
const getDropdownPosition = (index) => {
const pos = dropdownPositions.value[index];
if (pos) {
const style = {
left: pos.left + 'px',
width: pos.width + 'px'
};
// When opening upward, anchor from bottom so dropdown grows upward
if (pos.openUpward) {
style.bottom = pos.bottom + 'px';
style.top = 'auto';
} else {
style.top = pos.top + 'px';
style.bottom = 'auto';
}
// Apply calculated max height
if (pos.maxHeight) {
style.maxHeight = pos.maxHeight + 'px';
}
return style;
}
return { top: '0px', left: '0px' };
};
const updateDropdownPosition = (index) => {
nextTick(() => {
// Find row by data attribute to work correctly with virtual scrolling
const row = document.querySelector(`.asr-editor-table tbody tr[data-segment-index="${index}"]`);
if (row) {
const cell = row.querySelector('td:first-child');
if (cell) {
const rect = cell.getBoundingClientRect();
const viewportHeight = window.innerHeight;
// Calculate available space above and below
const spaceBelow = viewportHeight - rect.bottom - 10;
const spaceAbove = rect.top - 10;
// Determine max height based on available space (cap at 192px which is max-h-48)
const maxDropdownHeight = 192;
let top, bottom, openUpward, maxHeight;
if (spaceBelow >= maxDropdownHeight || spaceBelow >= spaceAbove) {
// Open downward
top = rect.bottom + 2;
bottom = null;
openUpward = false;
maxHeight = Math.min(spaceBelow, maxDropdownHeight);
} else {
// Open upward - anchor from bottom so dropdown grows upward
openUpward = true;
maxHeight = Math.min(spaceAbove, maxDropdownHeight);
// Bottom is distance from viewport bottom to the top of the cell
bottom = viewportHeight - rect.top + 2;
top = null;
}
dropdownPositions.value[index] = {
top: top,
bottom: bottom,
left: rect.left,
width: rect.width,
openUpward: openUpward,
maxHeight: maxHeight
};
}
}
});
};
const selectSpeaker = (index, speaker) => {
if (editingSegments.value[index]) {
editingSegments.value[index].speaker = speaker;
closeSpeakerSuggestions(index);
}
};
const addSegment = () => {
const lastSegment = editingSegments.value[editingSegments.value.length - 1];
const newStart = lastSegment ? lastSegment.end_time : 0;
editingSegments.value.push({
speaker: availableSpeakers.value[0] || 'Speaker 1',
start_time: newStart,
end_time: newStart + 5,
sentence: '',
id: editingSegments.value.length,
showSuggestions: false,
filteredSpeakers: [...availableSpeakers.value]
});
};
const removeSegment = (index) => {
editingSegments.value.splice(index, 1);
// Re-index segments
editingSegments.value.forEach((seg, i) => {
seg.id = i;
});
};
const addSegmentBelow = (index) => {
const currentSegment = editingSegments.value[index];
const nextSegment = editingSegments.value[index + 1];
const newStart = currentSegment.end_time;
const newEnd = nextSegment ? nextSegment.start_time : newStart + 5;
editingSegments.value.splice(index + 1, 0, {
speaker: currentSegment.speaker,
start_time: newStart,
end_time: newEnd,
sentence: '',
id: index + 1,
showSuggestions: false,
filteredSpeakers: [...availableSpeakers.value]
});
// Re-index segments
editingSegments.value.forEach((seg, i) => {
seg.id = i;
});
};
const seekToSegmentTime = (time) => {
// Find audio elements and use the one in a visible modal (z-50)
const mediaElements = document.querySelectorAll('.fixed.z-50 audio, .fixed.z-50 video');
const audioElement = mediaElements.length > 0 ? mediaElements[mediaElements.length - 1] : null;
if (audioElement) {
audioElement.currentTime = time;
audioElement.play();
}
};
const autoResizeTextarea = (event) => {
const textarea = event.target;
textarea.style.height = 'auto';
textarea.style.height = textarea.scrollHeight + 'px';
};
// =========================================
// Save Transcription Content
// =========================================
const saveTranscriptionContent = async (content) => {
if (!selectedRecording.value) return;
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const response = await fetch(`/recording/${selectedRecording.value.id}/update_transcription`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ transcription: content })
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Failed to update transcription');
// Update recording
selectedRecording.value.transcription = content;
const index = recordings.value.findIndex(r => r.id === selectedRecording.value.id);
if (index !== -1) {
recordings.value[index].transcription = content;
}
showToast('Transcription updated successfully!', 'fa-check-circle');
} catch (error) {
setGlobalError(`Failed to save transcription: ${error.message}`);
}
};
// =========================================
// Save Summary
// =========================================
const saveSummary = async (summary) => {
if (!selectedRecording.value) return;
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const payload = {
id: selectedRecording.value.id,
title: selectedRecording.value.title,
participants: selectedRecording.value.participants,
notes: selectedRecording.value.notes,
summary: summary,
meeting_date: selectedRecording.value.meeting_date
};
const response = await fetch('/save', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify(payload)
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Failed to update summary');
// Update recording
selectedRecording.value.summary = summary;
const index = recordings.value.findIndex(r => r.id === selectedRecording.value.id);
if (index !== -1) {
recordings.value[index].summary = summary;
}
showToast('Summary saved!', 'fa-check-circle');
} catch (error) {
setGlobalError(`Failed to save summary: ${error.message}`);
}
};
// =========================================
// Save Notes
// =========================================
const saveNotes = async (notes) => {
if (!selectedRecording.value) return;
// Handle incognito recordings - save to sessionStorage only
if (selectedRecording.value.incognito) {
selectedRecording.value.notes = notes;
// Update sessionStorage
try {
const stored = sessionStorage.getItem('speakr_incognito_recording');
if (stored) {
const data = JSON.parse(stored);
data.notes = notes;
sessionStorage.setItem('speakr_incognito_recording', JSON.stringify(data));
}
} catch (e) {
console.error('[Incognito] Failed to save notes to sessionStorage:', e);
}
showToast('Notes saved (in browser only)', 'fa-check-circle');
return;
}
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const response = await fetch(`/api/recordings/${selectedRecording.value.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ notes })
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Failed to update notes');
// Update recording
selectedRecording.value.notes = notes;
const index = recordings.value.findIndex(r => r.id === selectedRecording.value.id);
if (index !== -1) {
recordings.value[index].notes = notes;
}
showToast('Notes saved!', 'fa-check-circle');
} catch (error) {
setGlobalError(`Failed to save notes: ${error.message}`);
}
};
return {
// Text editor
openTranscriptionEditor,
openTextEditorModal,
closeTextEditorModal,
saveTranscription,
// ASR editor
openAsrEditorModal,
closeAsrEditorModal,
saveAsrTranscription,
// Segment management
adjustTime,
filterSpeakerSuggestions,
openSpeakerSuggestions,
closeSpeakerSuggestions,
closeAllSpeakerSuggestions,
isDropdownOpen,
getDropdownPosition,
updateDropdownPosition,
selectSpeaker,
addSegment,
removeSegment,
addSegmentBelow,
seekToSegmentTime,
autoResizeTextarea,
// Save
saveTranscriptionContent,
saveSummary,
saveNotes
};
}