XPlor/app/exportmanager.cpp
njohnson d7488c5fa9 Add comprehensive export system with format-specific dialogs
Implement a unified export system for extracting data from parsed files:

ExportManager (singleton):
- Centralized export handling for all content types
- Content type detection (image, audio, video, text, binary)
- Batch export support with progress tracking

Format-specific export dialogs:
- ImageExportDialog: PNG, JPEG, BMP, TGA with quality options
- AudioExportDialog: WAV, MP3, OGG with FFmpeg integration
- BinaryExportDialog: Raw data export with optional decompression
- BatchExportDialog: Recursive export with filtering options
- Base ExportDialog class for common functionality

Settings additions:
- FFmpeg path configuration with auto-detection
- Search common install locations and PATH

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 20:54:38 -05:00

765 lines
25 KiB
C++

#include "exportmanager.h"
#include "settings.h"
#include "imageexportdialog.h"
#include "audioexportdialog.h"
#include "binaryexportdialog.h"
#include "batchexportdialog.h"
#include "../libs/dsl/dslkeys.h"
#include <QFileDialog>
#include <QMessageBox>
#include <QClipboard>
#include <QApplication>
#include <QMimeData>
#include <QProcess>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QStandardPaths>
#include <QTemporaryFile>
#include <QImageReader>
ExportManager& ExportManager::instance() {
static ExportManager instance;
return instance;
}
ExportManager::ExportManager(QObject* parent)
: QObject(parent)
{
}
ExportManager::ContentType ExportManager::detectContentType(const QVariantMap& vars) const {
// Check for viewer type in vars
if (DslKeys::contains(vars, DslKey::Viewer)) {
QString viewer = DslKeys::get(vars, DslKey::Viewer).toString();
if (viewer == "image") return Image;
if (viewer == "audio") return Audio;
if (viewer == "text") return Text;
if (viewer == "hex") return Binary;
}
return Unknown;
}
ExportManager::ContentType ExportManager::detectContentType(const QByteArray& data, const QString& filename) const {
QString ext = QFileInfo(filename).suffix().toLower();
// Check by extension
QStringList imageExts = {"png", "jpg", "jpeg", "bmp", "tga", "dds", "tiff", "tif", "webp", "gif"};
QStringList audioExts = {"wav", "mp3", "ogg", "flac", "aiff", "wma", "m4a"};
QStringList textExts = {"txt", "xml", "json", "csv", "ini", "cfg", "log", "md", "htm", "html"};
if (imageExts.contains(ext)) return Image;
if (audioExts.contains(ext)) return Audio;
if (textExts.contains(ext)) return Text;
// Check by magic bytes
if (data.size() >= 4) {
// PNG
if (data.startsWith("\x89PNG")) return Image;
// JPEG
if (data.startsWith("\xFF\xD8\xFF")) return Image;
// BMP
if (data.startsWith("BM")) return Image;
// GIF
if (data.startsWith("GIF8")) return Image;
// RIFF (WAV)
if (data.startsWith("RIFF") && data.size() >= 12 && data.mid(8, 4) == "WAVE") return Audio;
// OGG
if (data.startsWith("OggS")) return Audio;
// FLAC
if (data.startsWith("fLaC")) return Audio;
// ID3 (MP3)
if (data.startsWith("ID3") || (data.size() >= 2 && (uchar)data[0] == 0xFF && ((uchar)data[1] & 0xE0) == 0xE0)) return Audio;
}
// Check if it looks like text
bool looksLikeText = true;
int checkLen = qMin(data.size(), 1024);
for (int i = 0; i < checkLen && looksLikeText; ++i) {
char c = data[i];
if (c < 0x09 || (c > 0x0D && c < 0x20 && c != 0x1B)) {
looksLikeText = false;
}
}
if (looksLikeText && !data.isEmpty()) return Text;
return Binary;
}
bool ExportManager::exportRawData(const QByteArray& data, const QString& suggestedName, QWidget* parent) {
QString baseName = QFileInfo(suggestedName).baseName();
if (baseName.isEmpty()) baseName = "export";
QString dir = lastExportDir("raw");
if (dir.isEmpty()) dir = QStandardPaths::writableLocation(QStandardPaths::DocumentsLocation);
QString savePath = QFileDialog::getSaveFileName(parent, "Export Raw Data",
dir + "/" + baseName + ".bin",
"Binary Files (*.bin *.dat);;All Files (*)");
if (savePath.isEmpty()) return false;
setLastExportDir("raw", QFileInfo(savePath).path());
QFile file(savePath);
if (!file.open(QIODevice::WriteOnly)) {
emit exportError(QString("Failed to open file for writing: %1").arg(file.errorString()));
return false;
}
file.write(data);
file.close();
emit exportCompleted(savePath, true);
return true;
}
bool ExportManager::exportImage(const QImage& image, const QString& format, const QString& suggestedName, QWidget* parent) {
if (image.isNull()) {
emit exportError("Cannot export null image");
return false;
}
QString ext = format.toLower();
QString filter = getFilterForFormat(ext);
QString baseName = QFileInfo(suggestedName).baseName();
if (baseName.isEmpty()) baseName = "image";
QString dir = lastExportDir("image");
if (dir.isEmpty()) dir = QStandardPaths::writableLocation(QStandardPaths::PicturesLocation);
QString savePath = QFileDialog::getSaveFileName(parent, "Export Image",
dir + "/" + baseName + "." + ext, filter);
if (savePath.isEmpty()) return false;
setLastExportDir("image", QFileInfo(savePath).path());
bool success = false;
if (ext == "tga") {
success = saveTGA(image, savePath);
} else {
// Qt handles png, jpg, bmp, tiff natively
success = image.save(savePath, ext.toUpper().toUtf8().constData());
}
if (!success) {
emit exportError(QString("Failed to save image as %1").arg(ext.toUpper()));
return false;
}
emit exportCompleted(savePath, true);
return true;
}
bool ExportManager::exportAudio(const QByteArray& wavData, const QString& format, const QString& suggestedName, QWidget* parent) {
if (wavData.isEmpty()) {
emit exportError("No audio data to export");
return false;
}
QString ext = format.toLower();
QString baseName = QFileInfo(suggestedName).baseName();
if (baseName.isEmpty()) baseName = "audio";
QString dir = lastExportDir("audio");
if (dir.isEmpty()) dir = QStandardPaths::writableLocation(QStandardPaths::MusicLocation);
QString filter;
if (ext == "wav") filter = "WAV Files (*.wav)";
else if (ext == "mp3") filter = "MP3 Files (*.mp3)";
else if (ext == "ogg") filter = "OGG Files (*.ogg)";
else if (ext == "flac") filter = "FLAC Files (*.flac)";
else filter = "All Files (*)";
QString savePath = QFileDialog::getSaveFileName(parent, "Export Audio",
dir + "/" + baseName + "." + ext, filter);
if (savePath.isEmpty()) return false;
setLastExportDir("audio", QFileInfo(savePath).path());
if (ext == "wav") {
// Direct write for WAV
QFile file(savePath);
if (!file.open(QIODevice::WriteOnly)) {
emit exportError(QString("Failed to open file: %1").arg(file.errorString()));
return false;
}
file.write(wavData);
file.close();
emit exportCompleted(savePath, true);
return true;
}
// Need FFmpeg for MP3/OGG/FLAC conversion
if (!hasFFmpeg()) {
QMessageBox::warning(parent, "FFmpeg Required",
QString("FFmpeg is required to export audio as %1.\n\n"
"Install FFmpeg and ensure it's in your system PATH,\n"
"or configure the path in Edit > Preferences > Tools.").arg(ext.toUpper()));
return false;
}
// Write temp WAV file
QTemporaryFile tempWav;
tempWav.setFileTemplate(QDir::temp().filePath("xplor_XXXXXX.wav"));
if (!tempWav.open()) {
emit exportError("Failed to create temporary file");
return false;
}
tempWav.write(wavData);
QString tempPath = tempWav.fileName();
tempWav.close();
bool success = convertWithFFmpeg(tempPath, savePath, ext);
// Cleanup temp file
QFile::remove(tempPath);
if (success) {
emit exportCompleted(savePath, true);
}
return success;
}
bool ExportManager::exportText(const QByteArray& data, const QString& suggestedName, QWidget* parent) {
QString baseName = QFileInfo(suggestedName).baseName();
if (baseName.isEmpty()) baseName = "text";
QString dir = lastExportDir("text");
if (dir.isEmpty()) dir = QStandardPaths::writableLocation(QStandardPaths::DocumentsLocation);
QString savePath = QFileDialog::getSaveFileName(parent, "Export Text",
dir + "/" + baseName + ".txt",
"Text Files (*.txt);;All Files (*)");
if (savePath.isEmpty()) return false;
setLastExportDir("text", QFileInfo(savePath).path());
QFile file(savePath);
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
emit exportError(QString("Failed to open file: %1").arg(file.errorString()));
return false;
}
file.write(data);
file.close();
emit exportCompleted(savePath, true);
return true;
}
bool ExportManager::exportWithDialog(const QByteArray& data, const QString& name,
ContentType type, QWidget* parent) {
switch (type) {
case Image: {
QImage image;
if (image.loadFromData(data)) {
return exportImageWithDialog(image, name, parent);
}
// Fall back to binary export
break;
}
case Audio: {
AudioExportDialog dialog(parent);
dialog.setData(data, name);
if (dialog.exec() == QDialog::Accepted) {
QString format = dialog.selectedFormat();
QString path = dialog.outputPath();
if (dialog.rememberSettings()) {
Settings::instance().setDefaultAudioExportFormat(format);
Settings::instance().setAudioMp3Bitrate(dialog.mp3Bitrate());
Settings::instance().setAudioOggQuality(dialog.oggQuality());
Settings::instance().setAudioFlacCompression(dialog.flacCompression());
}
// Export based on format
if (format == "wav") {
QFile file(path);
if (file.open(QIODevice::WriteOnly)) {
file.write(data);
file.close();
emit exportCompleted(path, true);
return true;
}
} else {
// Need FFmpeg conversion
QTemporaryFile tempWav;
tempWav.setFileTemplate(QDir::temp().filePath("xplor_XXXXXX.wav"));
if (tempWav.open()) {
tempWav.write(data);
QString tempPath = tempWav.fileName();
tempWav.close();
bool success = convertWithFFmpeg(tempPath, path, format);
QFile::remove(tempPath);
if (success) {
emit exportCompleted(path, true);
return true;
}
}
}
}
return false;
}
case Text:
return exportText(data, name, parent);
case Binary:
case Unknown:
default:
break;
}
// Binary export dialog
BinaryExportDialog dialog(parent);
dialog.setData(data, name);
if (dialog.exec() == QDialog::Accepted) {
QString path = dialog.outputPath();
QFile file(path);
if (file.open(QIODevice::WriteOnly)) {
file.write(data);
file.close();
emit exportCompleted(path, true);
return true;
}
}
return false;
}
bool ExportManager::exportImageWithDialog(const QImage& image, const QString& name, QWidget* parent) {
if (image.isNull()) {
emit exportError("Cannot export null image");
return false;
}
ImageExportDialog dialog(parent);
dialog.setImage(image, name);
if (dialog.exec() == QDialog::Accepted) {
QString format = dialog.selectedFormat();
QString path = dialog.outputPath();
if (dialog.rememberSettings()) {
Settings::instance().setDefaultImageExportFormat(format);
Settings::instance().setImageJpegQuality(dialog.jpegQuality());
Settings::instance().setImagePngCompression(dialog.pngCompression());
}
setLastExportDir("image", QFileInfo(path).path());
bool success = false;
if (format == "tga") {
success = saveTGA(image, path);
} else if (format == "jpg" || format == "jpeg") {
success = image.save(path, "JPEG", dialog.jpegQuality());
} else if (format == "png") {
// Qt doesn't support PNG compression level directly in save()
// We'd need to use QImageWriter for that
success = image.save(path, "PNG");
} else {
success = image.save(path, format.toUpper().toUtf8().constData());
}
if (success) {
emit exportCompleted(path, true);
return true;
} else {
emit exportError(QString("Failed to save image as %1").arg(format.toUpper()));
}
}
return false;
}
bool ExportManager::quickExport(const QByteArray& data, const QString& name,
ContentType type, QWidget* parent) {
QString baseName = QFileInfo(name).baseName();
if (baseName.isEmpty()) baseName = "export";
QString dir, format, filter;
switch (type) {
case Image:
dir = lastExportDir("image");
if (dir.isEmpty()) dir = QStandardPaths::writableLocation(QStandardPaths::PicturesLocation);
format = Settings::instance().defaultImageExportFormat();
filter = getFilterForFormat(format);
break;
case Audio:
dir = lastExportDir("audio");
if (dir.isEmpty()) dir = QStandardPaths::writableLocation(QStandardPaths::MusicLocation);
format = Settings::instance().defaultAudioExportFormat();
filter = QString("%1 Files (*.%2)").arg(format.toUpper()).arg(format);
break;
case Text:
dir = lastExportDir("text");
if (dir.isEmpty()) dir = QStandardPaths::writableLocation(QStandardPaths::DocumentsLocation);
format = "txt";
filter = "Text Files (*.txt)";
break;
default:
dir = lastExportDir("raw");
if (dir.isEmpty()) dir = QStandardPaths::writableLocation(QStandardPaths::DocumentsLocation);
format = "bin";
filter = "Binary Files (*.bin)";
break;
}
QString savePath = QFileDialog::getSaveFileName(parent, "Quick Export",
dir + "/" + baseName + "." + format, filter);
if (savePath.isEmpty()) return false;
// Handle type-specific export
switch (type) {
case Image: {
QImage image;
if (image.loadFromData(data)) {
setLastExportDir("image", QFileInfo(savePath).path());
QString ext = QFileInfo(savePath).suffix().toLower();
if (ext == "tga") {
return saveTGA(image, savePath);
}
int quality = (ext == "jpg" || ext == "jpeg") ?
Settings::instance().imageJpegQuality() : -1;
if (image.save(savePath, ext.toUpper().toUtf8().constData(), quality)) {
emit exportCompleted(savePath, true);
return true;
}
}
break;
}
case Audio: {
setLastExportDir("audio", QFileInfo(savePath).path());
QString ext = QFileInfo(savePath).suffix().toLower();
if (ext == "wav") {
QFile file(savePath);
if (file.open(QIODevice::WriteOnly)) {
file.write(data);
file.close();
emit exportCompleted(savePath, true);
return true;
}
} else {
// FFmpeg conversion
QTemporaryFile tempWav;
tempWav.setFileTemplate(QDir::temp().filePath("xplor_XXXXXX.wav"));
if (tempWav.open()) {
tempWav.write(data);
QString tempPath = tempWav.fileName();
tempWav.close();
bool success = convertWithFFmpeg(tempPath, savePath, ext);
QFile::remove(tempPath);
if (success) {
emit exportCompleted(savePath, true);
return true;
}
}
}
break;
}
default: {
setLastExportDir("raw", QFileInfo(savePath).path());
QFile file(savePath);
if (file.open(QIODevice::WriteOnly)) {
file.write(data);
file.close();
emit exportCompleted(savePath, true);
return true;
}
break;
}
}
return false;
}
bool ExportManager::quickExportImage(const QImage& image, const QString& name, QWidget* parent) {
if (image.isNull()) return false;
QString baseName = QFileInfo(name).baseName();
if (baseName.isEmpty()) baseName = "image";
QString dir = lastExportDir("image");
if (dir.isEmpty()) dir = QStandardPaths::writableLocation(QStandardPaths::PicturesLocation);
QString format = Settings::instance().defaultImageExportFormat();
QString filter = getFilterForFormat(format);
QString savePath = QFileDialog::getSaveFileName(parent, "Quick Export Image",
dir + "/" + baseName + "." + format, filter);
if (savePath.isEmpty()) return false;
setLastExportDir("image", QFileInfo(savePath).path());
QString ext = QFileInfo(savePath).suffix().toLower();
if (ext == "tga") {
if (saveTGA(image, savePath)) {
emit exportCompleted(savePath, true);
return true;
}
return false;
}
int quality = (ext == "jpg" || ext == "jpeg") ?
Settings::instance().imageJpegQuality() : -1;
if (image.save(savePath, ext.toUpper().toUtf8().constData(), quality)) {
emit exportCompleted(savePath, true);
return true;
}
return false;
}
bool ExportManager::batchExport(const QList<BatchExportItem>& items, QWidget* parent) {
if (items.isEmpty()) return false;
BatchExportDialog dialog(parent);
dialog.setItems(items);
if (dialog.exec() != QDialog::Accepted) return false;
QList<BatchExportItem> selected = dialog.selectedItems();
if (selected.isEmpty()) return false;
QString outputDir = dialog.outputDirectory();
bool preserveStructure = dialog.preserveStructure();
QString conflictResolution = dialog.conflictResolution();
QString imageFormat = dialog.imageFormat();
QString audioFormat = dialog.audioFormat();
int succeeded = 0, failed = 0, skipped = 0;
for (int i = 0; i < selected.size(); ++i) {
const BatchExportItem& item = selected[i];
emit batchExportProgress(i + 1, selected.size(), item.name);
// Determine output path
QString relativePath = preserveStructure ? item.path : item.name;
QString ext;
switch (item.contentType) {
case Image: ext = imageFormat; break;
case Audio: ext = audioFormat; break;
case Text: ext = "txt"; break;
default: ext = "bin"; break;
}
QString baseName = QFileInfo(relativePath).baseName();
QString subDir = preserveStructure ? QFileInfo(relativePath).path() : "";
QString targetDir = subDir.isEmpty() ? outputDir : outputDir + "/" + subDir;
QString targetPath = targetDir + "/" + baseName + "." + ext;
// Ensure directory exists
QDir().mkpath(targetDir);
// Handle conflicts
if (QFile::exists(targetPath)) {
if (conflictResolution == "skip") {
skipped++;
continue;
} else if (conflictResolution == "number") {
int num = 1;
QString newPath;
do {
newPath = targetDir + "/" + baseName + QString("_%1.").arg(num++) + ext;
} while (QFile::exists(newPath) && num < 1000);
targetPath = newPath;
}
// "overwrite" - just proceed
}
// Export based on type
bool success = false;
switch (item.contentType) {
case Image: {
QImage image;
if (image.loadFromData(item.data)) {
if (ext == "tga") {
success = saveTGA(image, targetPath);
} else {
int quality = (ext == "jpg") ? Settings::instance().imageJpegQuality() : -1;
success = image.save(targetPath, ext.toUpper().toUtf8().constData(), quality);
}
}
break;
}
case Audio:
if (ext == "wav") {
QFile file(targetPath);
if (file.open(QIODevice::WriteOnly)) {
file.write(item.data);
file.close();
success = true;
}
} else {
QTemporaryFile tempWav;
tempWav.setFileTemplate(QDir::temp().filePath("xplor_XXXXXX.wav"));
if (tempWav.open()) {
tempWav.write(item.data);
QString tempPath = tempWav.fileName();
tempWav.close();
success = convertWithFFmpeg(tempPath, targetPath, ext);
QFile::remove(tempPath);
}
}
break;
default: {
QFile file(targetPath);
if (file.open(QIODevice::WriteOnly)) {
file.write(item.data);
file.close();
success = true;
}
break;
}
}
if (success) {
succeeded++;
} else {
failed++;
}
}
emit batchExportCompleted(succeeded, failed, skipped);
return failed == 0;
}
QStringList ExportManager::supportedImageFormats() const {
return {"png", "jpg", "bmp", "tiff", "tga"};
}
QStringList ExportManager::supportedAudioFormats() const {
return {"wav", "mp3", "ogg", "flac"};
}
bool ExportManager::hasFFmpeg() const {
if (!m_ffmpegChecked) {
m_cachedFFmpegPath = findFFmpegPath();
m_ffmpegChecked = true;
}
return !m_cachedFFmpegPath.isEmpty();
}
void ExportManager::copyImageToClipboard(const QImage& image) {
if (image.isNull()) return;
QApplication::clipboard()->setImage(image);
}
void ExportManager::copyTextToClipboard(const QString& text) {
QApplication::clipboard()->setText(text);
}
void ExportManager::copyBinaryAsHex(const QByteArray& data) {
QString hex = data.toHex(' ').toUpper();
QApplication::clipboard()->setText(hex);
}
QString ExportManager::lastExportDir(const QString& category) const {
return m_lastDirs.value(category);
}
void ExportManager::setLastExportDir(const QString& category, const QString& dir) {
m_lastDirs[category] = dir;
}
bool ExportManager::saveTGA(const QImage& image, const QString& path) {
QFile file(path);
if (!file.open(QIODevice::WriteOnly)) return false;
QImage img = image.convertToFormat(QImage::Format_ARGB32);
// TGA Header (18 bytes)
QByteArray header(18, 0);
header[2] = 2; // Uncompressed true-color
header[12] = img.width() & 0xFF;
header[13] = (img.width() >> 8) & 0xFF;
header[14] = img.height() & 0xFF;
header[15] = (img.height() >> 8) & 0xFF;
header[16] = 32; // 32 bits per pixel
header[17] = 0x20; // Top-left origin
file.write(header);
// Write BGRA pixels (TGA uses BGRA order)
for (int y = 0; y < img.height(); ++y) {
for (int x = 0; x < img.width(); ++x) {
QRgb pixel = img.pixel(x, y);
char bgra[4] = {
static_cast<char>(qBlue(pixel)),
static_cast<char>(qGreen(pixel)),
static_cast<char>(qRed(pixel)),
static_cast<char>(qAlpha(pixel))
};
file.write(bgra, 4);
}
}
return true;
}
QString ExportManager::getFilterForFormat(const QString& format) const {
QString ext = format.toLower();
if (ext == "png") return "PNG Images (*.png)";
if (ext == "jpg" || ext == "jpeg") return "JPEG Images (*.jpg *.jpeg)";
if (ext == "bmp") return "BMP Images (*.bmp)";
if (ext == "tiff" || ext == "tif") return "TIFF Images (*.tiff *.tif)";
if (ext == "tga") return "TGA Images (*.tga)";
if (ext == "webp") return "WebP Images (*.webp)";
return "All Files (*)";
}
bool ExportManager::convertWithFFmpeg(const QString& inputPath, const QString& outputPath, const QString& format) {
QString ffmpegPath = m_cachedFFmpegPath;
if (ffmpegPath.isEmpty()) {
ffmpegPath = findFFmpegPath();
}
if (ffmpegPath.isEmpty()) {
emit exportError("FFmpeg not found");
return false;
}
QProcess ffmpeg;
QStringList args;
args << "-y"; // Overwrite output
args << "-i" << inputPath;
// Format-specific encoding settings
if (format == "mp3") {
args << "-codec:a" << "libmp3lame" << "-qscale:a" << "2";
} else if (format == "ogg") {
args << "-codec:a" << "libvorbis" << "-qscale:a" << "5";
} else if (format == "flac") {
args << "-codec:a" << "flac";
}
args << outputPath;
ffmpeg.start(ffmpegPath, args);
if (!ffmpeg.waitForFinished(30000)) {
emit exportError("FFmpeg conversion timed out");
return false;
}
if (ffmpeg.exitCode() != 0) {
QString errorOutput = QString::fromUtf8(ffmpeg.readAllStandardError());
emit exportError(QString("FFmpeg conversion failed: %1").arg(errorOutput.left(200)));
return false;
}
return true;
}
QString ExportManager::findFFmpegPath() const {
// Use Settings' ffmpegPath which handles auto-detection
return Settings::instance().ffmpegPath();
}