import { format } from '../utilities/export.js';
import { SCRIPT_DIALOGUE_INDENT } from '../utilities/constants.js';
/**
* Modal dialog for configuring and initiating a transcript export.
* Presents file type, export style, save path controls, and a live format preview
* generated from the provided Transcript via the shared export formatters.
*/
export class ExportPanel {
/**
* @param {Transcript} transcript - The transcript to preview and export.
* @param {Object.<string, Speaker>} speakers - Speaker id → Speaker map.
* @param {object} callbacks
* @param {function} [callbacks.onExport] - Called with { fileType, style, filename, path, text }
* when Export is clicked. `text` is the fully formatted output string. `path` is the full
* filesystem path if the user used the browse dialog, otherwise null.
* @param {function} [callbacks.onDismiss] - Called when the dialog is cancelled or closed.
*/
#transcript = null;
#speakers = {};
#filePath = null;
#defaultDir = null;
#defaultTitle = '';
/**
* @param {Transcript} transcript - The transcript to preview and export.
* @param {Object.<string, Speaker>} speakers - Speaker id → Speaker map.
* @param {object} [options] - Optional callbacks and defaults.
* @param {function} [options.onExport] - Called with export details when Export is clicked.
* @param {function} [options.onDismiss] - Called when the dialog is cancelled or closed.
* @param {string} [options.defaultTitle=''] - Pre-populated value for the title field.
*/
constructor(transcript, speakers, { onExport, onDismiss, defaultTitle = '' } = {}) {
this._onExport = onExport ?? (() => {});
this._onDismiss = onDismiss ?? (() => {});
this.#transcript = transcript;
this.#speakers = speakers ?? {};
this.#defaultTitle = defaultTitle;
this.#buildDialog();
}
/** Builds and appends the modal overlay and its contents to the document body. */
#buildDialog() {
const overlay = document.createElement('div');
overlay.className = 'export-panel-overlay';
const modal = document.createElement('div');
modal.className = 'export-panel-modal';
modal.appendChild(this.#buildHeader(overlay));
const { body, fileTypeSelect, styleSelect, pathInput, speakersCheck, currentOptions, effectiveStyle } = this.#buildBody();
modal.appendChild(body);
modal.appendChild(this.#buildActions(overlay, fileTypeSelect, styleSelect, pathInput, speakersCheck, currentOptions, effectiveStyle));
let _mouseDownOnOverlay = false;
overlay.addEventListener('mousedown', (e) => { _mouseDownOnOverlay = e.target === overlay; });
overlay.addEventListener('click', (e) => {
if (e.target === overlay && _mouseDownOnOverlay) { overlay.remove(); this._onDismiss(); }
});
overlay.appendChild(modal);
document.body.appendChild(overlay);
}
/**
* Builds the modal header bar with a title and close button.
*
* @param {HTMLElement} overlay - The overlay element, used to dismiss the dialog on close.
* @returns {HTMLElement}
*/
#buildHeader(overlay) {
const header = document.createElement('div');
header.className = 'export-panel-header';
header.innerHTML = '<span>Export Transcript</span>';
const closeBtn = document.createElement('button');
closeBtn.className = 'export-panel-close';
closeBtn.innerHTML = '<span class="icon icon-close" style="width:12px;height:12px;"></span>';
closeBtn.addEventListener('click', () => { overlay.remove(); this._onDismiss(); });
header.appendChild(closeBtn);
return header;
}
/**
* Builds the modal body containing the export form and live preview pane.
*
* @returns {{ body: HTMLElement, fileTypeSelect: HTMLSelectElement, styleSelect: HTMLSelectElement,
* pathInput: HTMLInputElement, speakersCheck: HTMLInputElement,
* currentOptions: function(): object, effectiveStyle: function(): string }}
*/
#buildBody() {
const body = document.createElement('div');
body.className = 'export-panel-body';
// ── Form column ──
const form = document.createElement('div');
form.className = 'export-panel-form';
const fileTypeSelect = this.#makeSelect('exportFileType',
[['PDF', 'pdf'], ['DOCX', 'docx'], ['TXT', 'txt'], ['Markdown', 'md'], ['CSV', 'csv']]
);
form.appendChild(this.#makeRow('File Type', fileTypeSelect));
const styleSelect = this.#makeSelect('exportStyle',
[['Script', 'script'], ['Professional', 'professional'], ['Transcript', 'transcript']]
);
const styleRow = this.#makeRow('Export Style', styleSelect);
form.appendChild(styleRow);
const titleInput = document.createElement('input');
titleInput.type = 'text';
titleInput.className = 'export-panel-path-input';
titleInput.placeholder = 'Untitled';
titleInput.value = this.#defaultTitle;
titleInput.addEventListener('keydown', e => e.stopPropagation());
const titleRow = this.#makeRow('Title', titleInput);
form.appendChild(titleRow);
const descInput = document.createElement('textarea');
descInput.className = 'export-panel-textarea';
descInput.rows = 3;
descInput.placeholder = 'Optional description…';
descInput.addEventListener('keydown', e => e.stopPropagation());
const descRow = this.#makeRow('Description', descInput);
form.appendChild(descRow);
const recordingDateInput = document.createElement('input');
recordingDateInput.type = 'text';
recordingDateInput.className = 'export-panel-path-input';
recordingDateInput.placeholder = 'e.g. January 1, 2025';
recordingDateInput.addEventListener('keydown', e => e.stopPropagation());
const recordingDateRow = this.#makeRow('Recording Date', recordingDateInput);
form.appendChild(recordingDateRow);
const optionsSection = document.createElement('div');
optionsSection.className = 'export-panel-options-section';
const optionsLabel = document.createElement('div');
optionsLabel.className = 'export-panel-label';
optionsLabel.textContent = 'Options';
optionsSection.appendChild(optionsLabel);
const speakersCheck = document.createElement('input');
speakersCheck.type = 'checkbox';
speakersCheck.id = 'exportIncludeSpeakers';
speakersCheck.className = 'export-panel-checkbox';
const speakersCheckRow = this.#makeCheckboxRow('Include Speaker List', speakersCheck);
optionsSection.appendChild(speakersCheckRow);
const timestampsCheck = document.createElement('input');
timestampsCheck.type = 'checkbox';
timestampsCheck.id = 'exportIncludeTimestamps';
timestampsCheck.className = 'export-panel-checkbox';
timestampsCheck.checked = true;
const timestampsCheckRow = this.#makeCheckboxRow('Include Timestamps', timestampsCheck);
optionsSection.appendChild(timestampsCheckRow);
const recordingDateCheck = document.createElement('input');
recordingDateCheck.type = 'checkbox';
recordingDateCheck.id = 'exportRecordingDate';
recordingDateCheck.className = 'export-panel-checkbox';
const recordingDateCheckRow = this.#makeCheckboxRow('Display Recording Date', recordingDateCheck);
optionsSection.appendChild(recordingDateCheckRow);
const exportDateCheck = document.createElement('input');
exportDateCheck.type = 'checkbox';
exportDateCheck.id = 'exportExportDate';
exportDateCheck.className = 'export-panel-checkbox';
const exportDateCheckRow = this.#makeCheckboxRow('Display Export Date', exportDateCheck);
optionsSection.appendChild(exportDateCheckRow);
form.appendChild(optionsSection);
const locationDisplay = document.createElement('div');
locationDisplay.className = 'export-panel-location-display';
fetch('/api/dialog/default-dir')
.then(r => r.json())
.then(({ dir }) => {
this.#defaultDir = dir;
if (!this.#filePath) locationDisplay.textContent = dir;
});
const pathWrap = document.createElement('div');
pathWrap.className = 'export-panel-path-wrap';
const pathInput = document.createElement('input');
pathInput.type = 'text';
pathInput.className = 'export-panel-path-input';
pathInput.value = 'transcript.pdf';
const browseBtn = document.createElement('button');
browseBtn.className = 'export-panel-browse-btn';
browseBtn.textContent = '…';
browseBtn.title = 'Choose save location';
browseBtn.addEventListener('click', async () => {
const ext = fileTypeSelect.value;
const params = new URLSearchParams({ filename: pathInput.value, ext });
const resp = await fetch(`/api/dialog/save?${params}`);
const data = await resp.json();
if (data.cancelled) return;
this.#filePath = data.path;
pathInput.value = data.filename;
locationDisplay.textContent = data.dir;
});
pathWrap.appendChild(pathInput);
pathWrap.appendChild(browseBtn);
const pathRow = this.#makeRow('Save Path', locationDisplay);
pathRow.appendChild(pathWrap);
pathRow.style.marginTop = 'auto';
form.appendChild(pathRow);
body.appendChild(form);
// ── Preview column ──
const previewSection = document.createElement('div');
previewSection.className = 'export-panel-preview-section';
const previewLabel = document.createElement('div');
previewLabel.className = 'export-panel-preview-label';
previewLabel.textContent = 'Preview';
previewSection.appendChild(previewLabel);
const previewBox = document.createElement('pre');
previewBox.className = 'export-panel-preview';
previewSection.appendChild(previewBox);
body.appendChild(previewSection);
// Wire up live updates — CSV file type overrides the style select
const effectiveStyle = () => {
const ft = fileTypeSelect.value;
if (ft === 'csv') return 'csv';
if (ft === 'txt') return 'transcript';
if (ft === 'md') return 'md';
return styleSelect.value;
};
const currentOptions = () => ({
includeSpeakers: speakersCheck.checked,
includeTimestamps: timestampsCheck.checked,
displayRecordingDate: recordingDateCheck.checked,
displayExportDate: exportDateCheck.checked,
recordingDate: recordingDateInput.value.trim(),
title: titleInput.value.trim(),
description: descInput.value.trim(),
});
const setHidden = (row, hidden) => {
if (row) row.style.display = hidden ? 'none' : '';
};
const updateVisibility = () => {
const ft = fileTypeSelect.value;
const isCsv = ft === 'csv';
const isMd = ft === 'md';
const noTimestamps = !isCsv && effectiveStyle() === 'professional';
// Hide Script option for Markdown; restore it otherwise
const scriptOpt = styleSelect.querySelector('option[value="script"]');
if (scriptOpt) {
scriptOpt.hidden = isMd;
if (isMd && styleSelect.value === 'script') styleSelect.value = 'professional';
}
setHidden(styleRow, isCsv || ft === 'txt');
setHidden(titleRow, isCsv);
setHidden(descRow, isCsv);
setHidden(recordingDateRow, isCsv);
setHidden(optionsSection, isCsv);
if (!isCsv) setHidden(timestampsCheckRow, noTimestamps);
styleSelect.disabled = isCsv;
speakersCheck.disabled = isCsv;
timestampsCheck.disabled = isCsv || noTimestamps;
recordingDateCheck.disabled = isCsv;
exportDateCheck.disabled = isCsv;
};
const refresh = () => { updateVisibility(); this.#updatePreview(previewBox, effectiveStyle(), currentOptions()); };
styleSelect.addEventListener('change', refresh);
speakersCheck.addEventListener('change', refresh);
timestampsCheck.addEventListener('change', refresh);
recordingDateCheck.addEventListener('change', refresh);
exportDateCheck.addEventListener('change', refresh);
titleInput.addEventListener('input', refresh);
descInput.addEventListener('input', refresh);
recordingDateInput.addEventListener('input', refresh);
fileTypeSelect.addEventListener('change', () => {
const ext = fileTypeSelect.value;
pathInput.value = pathInput.value.replace(/\.[^.]+$/, `.${ext}`);
this.#filePath = null;
locationDisplay.textContent = this.#defaultDir ?? '';
refresh();
});
refresh();
return { body, fileTypeSelect, styleSelect, pathInput, speakersCheck, currentOptions, effectiveStyle };
}
/**
* Builds the Cancel / Export action bar.
*
* @param {HTMLElement} overlay - The overlay element to remove on action.
* @param {HTMLSelectElement} fileTypeSelect - The file type selector.
* @param {HTMLSelectElement} styleSelect - The export style selector.
* @param {HTMLInputElement} pathInput - The filename input.
* @param {HTMLInputElement} speakersCheck - The include-speakers checkbox.
* @param {function(): object} currentOptions - Returns the current form options.
* @param {function(): string} effectiveStyle - Returns the resolved export style key.
* @returns {HTMLElement}
*/
#buildActions(overlay, fileTypeSelect, styleSelect, pathInput, speakersCheck, currentOptions, effectiveStyle) {
const actions = document.createElement('div');
actions.className = 'export-panel-actions';
const cancelBtn = document.createElement('button');
cancelBtn.className = 'sample-btn';
cancelBtn.textContent = 'Cancel';
cancelBtn.style.cssText = 'padding:0.35rem 0.75rem;font-size:var(--fs-label);';
cancelBtn.addEventListener('click', () => { overlay.remove(); this._onDismiss(); });
const exportBtn = document.createElement('button');
exportBtn.className = 'sample-btn export-panel-export-btn';
exportBtn.textContent = 'Export';
exportBtn.style.cssText = 'padding:0.35rem 0.75rem;font-size:var(--fs-label);';
exportBtn.addEventListener('click', () => {
const style = effectiveStyle();
const options = currentOptions();
const text = this.#transcript
? format(style, this.#transcript, this.#speakers, options)
: '';
overlay.remove();
this._onExport({
fileType: fileTypeSelect.value,
style,
filename: pathInput.value,
path: this.#filePath,
text,
...options,
});
});
actions.appendChild(cancelBtn);
actions.appendChild(exportBtn);
return actions;
}
/**
* Creates a labelled checkbox row element.
*
* @param {string} labelText - The label to display beside the checkbox.
* @param {HTMLInputElement} checkbox - The checkbox input element.
* @returns {HTMLLabelElement}
*/
#makeCheckboxRow(labelText, checkbox) {
const row = document.createElement('label');
row.className = 'export-panel-checkbox-row';
row.appendChild(checkbox);
const span = document.createElement('span');
span.textContent = labelText;
row.appendChild(span);
return row;
}
/**
* Creates a labelled form row element containing the given control.
*
* @param {string} labelText - The label to display above the control.
* @param {HTMLElement} control - The form control to include in the row.
* @returns {HTMLDivElement}
*/
#makeRow(labelText, control) {
const row = document.createElement('div');
row.className = 'export-panel-row';
const label = document.createElement('label');
label.className = 'export-panel-label';
label.textContent = labelText;
row.appendChild(label);
row.appendChild(control);
return row;
}
/**
* Creates a styled select element populated with the given options.
*
* @param {string} id - The id attribute for the select element.
* @param {Array.<string[]>} options - Array of [label, value] pairs.
* @returns {HTMLSelectElement}
*/
#makeSelect(id, options) {
const select = document.createElement('select');
select.className = 'export-panel-select';
select.id = id;
options.forEach(([label, value]) => {
const opt = document.createElement('option');
opt.value = value;
opt.textContent = label;
select.appendChild(opt);
});
return select;
}
/**
* Refreshes the preview pane with freshly formatted transcript text.
*
* @param {HTMLPreElement} previewBox - The element to render the preview into.
* @param {string} style - The export style key ('script' | 'professional' | 'transcript' | 'csv' | 'md').
* @param {object} [options={}] - Options passed through to the formatter.
*/
#updatePreview(previewBox, style, options = {}) {
const fonts = {
script: `'Courier New', Courier, monospace`,
professional: `Georgia, 'Palatino Linotype', Palatino, serif`,
transcript: `'IBM Plex Sans', sans-serif`,
csv: `var(--font-mono)`,
};
previewBox.style.fontFamily = fonts[style] ?? `var(--font-mono)`;
previewBox.style.textAlign = (style === 'professional' || style === 'script') ? 'justify' : 'left';
previewBox.style.paddingRight = '';
if (!this.#transcript) {
previewBox.textContent = '';
return;
}
const text = format(style, this.#transcript, this.#speakers, options);
const PREVIEW_LINES = 50;
const truncated = text.split('\n').slice(0, PREVIEW_LINES).join('\n');
if (style === 'professional') {
// Bold any line that exactly matches a speaker's display name
const names = new Set(Object.values(this.#speakers).map(s => s.name));
const escaped = truncated.replace(/&/g, '&').replace(/</g, '<');
previewBox.innerHTML = escaped.replace(/^(.+)$/gm,
(_, line) => names.has(line) ? `<strong>${line}</strong>` : line
);
} else if (style === 'script') {
// Wrap content lines in display:block spans with right margin, but leave
// timecodes and HR lines unindented so they span the full width
const escaped = truncated.replace(/&/g, '&').replace(/</g, '<');
const pr = `${SCRIPT_DIALOGUE_INDENT}ch`;
previewBox.innerHTML = escaped.replace(/^(.+)$/gm, (_, line) => {
const isTimecode = /^\(\d{2}:\d{2}:\d{2}\)/.test(line);
const isHr = /^─+$/.test(line.trim());
return (isTimecode || isHr)
? line
: `<span style="display:block;padding-right:${pr}">${line}</span>`;
});
} else {
previewBox.textContent = truncated;
}
}
}