Initial release: DictIA v0.8.14-alpha (fork de Speakr, AGPL-3.0)
This commit is contained in:
484
static/js/modules/composables/transcription.js
Normal file
484
static/js/modules/composables/transcription.js
Normal 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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user