Kaynağa Gözat

export: support custom export

Le Tan 7 yıl önce
ebeveyn
işleme
d3f9ec48eb

+ 316 - 42
src/dialog/vexportdialog.cpp

@@ -174,11 +174,13 @@ void VExportDialog::setupUI()
     m_generalSettings = setupGeneralAdvancedSettings();
     m_htmlSettings = setupHTMLAdvancedSettings();
     m_pdfSettings = setupPDFAdvancedSettings();
+    m_customSettings = setupCustomAdvancedSettings();
 
     QVBoxLayout *advLayout = new QVBoxLayout();
     advLayout->addWidget(m_generalSettings);
     advLayout->addWidget(m_htmlSettings);
     advLayout->addWidget(m_pdfSettings);
+    advLayout->addWidget(m_customSettings);
 
     m_settingBox->setLayout(advLayout);
 
@@ -235,41 +237,41 @@ QWidget *VExportDialog::setupPDFAdvancedSettings()
     // wkhtmltopdf Path.
     m_wkPathEdit = new VLineEdit();
     m_wkPathEdit->setToolTip(tr("Tell VNote where to find wkhtmltopdf tool"));
-    m_wkPathEdit->setEnabled(m_wkhtmltopdfCB->isChecked());
+    m_wkPathEdit->setEnabled(false);
 
     m_wkPathBrowseBtn = new QPushButton(tr("&Browse"));
-    m_wkPathBrowseBtn->setEnabled(m_wkhtmltopdfCB->isChecked());
     connect(m_wkPathBrowseBtn, &QPushButton::clicked,
             this, &VExportDialog::handleWkPathBrowseBtnClicked);
+    m_wkPathBrowseBtn->setEnabled(false);
 
     m_wkTitleEdit = new VLineEdit();
-    m_wkTitleEdit->setPlaceholderText(tr("Use the name of the first source note"));
+    m_wkTitleEdit->setPlaceholderText(tr("Empty to use the name of the first source file"));
     m_wkTitleEdit->setToolTip(tr("Title of the generated PDF file"));
-    m_wkTitleEdit->setEnabled(m_wkhtmltopdfCB->isChecked());
+    m_wkTitleEdit->setEnabled(false);
 
     m_wkTargetFileNameEdit = new VLineEdit();
-    m_wkTargetFileNameEdit->setPlaceholderText(tr("Use the name of the first source note"));
+    m_wkTargetFileNameEdit->setPlaceholderText(tr("Empty to use the name of the first source file"));
     m_wkTargetFileNameEdit->setToolTip(tr("Name of the generated PDF file"));
     QValidator *validator = new QRegExpValidator(QRegExp(VUtils::c_fileNameRegExp),
                                                  m_wkTargetFileNameEdit);
     m_wkTargetFileNameEdit->setValidator(validator);
-    m_wkTargetFileNameEdit->setEnabled(m_wkhtmltopdfCB->isChecked());
+    m_wkTargetFileNameEdit->setEnabled(false);
 
     // wkhtmltopdf enable background.
     m_wkBackgroundCB = new QCheckBox(tr("Enable background"));
     m_wkBackgroundCB->setToolTip(tr("Enable background when printing"));
-    m_wkBackgroundCB->setEnabled(m_wkhtmltopdfCB->isChecked());
+    m_wkBackgroundCB->setEnabled(false);
 
     // wkhtmltopdf page number.
     m_wkPageNumberCB = VUtils::getComboBox();
     m_wkPageNumberCB->setToolTip(tr("Append page number as footer"));
-    m_wkPageNumberCB->setEnabled(m_wkhtmltopdfCB->isChecked());
+    m_wkPageNumberCB->setEnabled(false);
 
     // wkhtmltopdf extra argumnets.
     m_wkExtraArgsEdit = new VLineEdit();
     m_wkExtraArgsEdit->setToolTip(tr("Additional global options passed to wkhtmltopdf"));
     m_wkExtraArgsEdit->setPlaceholderText(tr("Use \" to enclose options containing spaces"));
-    m_wkExtraArgsEdit->setEnabled(m_wkhtmltopdfCB->isChecked());
+    m_wkExtraArgsEdit->setEnabled(false);
 
     QGridLayout *advLayout = new QGridLayout();
     advLayout->addWidget(new QLabel(tr("Page layout:")), 0, 0);
@@ -387,6 +389,7 @@ void VExportDialog::initUIFields(MarkdownConverterType p_renderer)
     m_formatCB->addItem(tr("HTML"), (int)ExportFormat::HTML);
     m_formatCB->addItem(tr("PDF"), (int)ExportFormat::PDF);
     m_formatCB->addItem(tr("PDF (All In One)"), (int)ExportFormat::OnePDF);
+    m_formatCB->addItem(tr("Custom"), (int)ExportFormat::Custom);
     m_formatCB->setCurrentIndex(m_formatCB->findData((int)s_opt.m_format));
 
     // Markdown renderer.
@@ -451,6 +454,22 @@ void VExportDialog::initUIFields(MarkdownConverterType p_renderer)
     m_wkPageNumberCB->setCurrentIndex(m_wkPageNumberCB->findData((int)s_opt.m_pdfOpt.m_wkPageNumber));
 
     m_wkExtraArgsEdit->setText(g_config->getWkhtmltopdfArgs());
+
+    // Custom export.
+    // Read from config every time.
+    ExportCustomOption customOpt(g_config->getCustomExport());
+
+    m_customSrcFormatCB->addItem(tr("Markdown"), (int)ExportCustomOption::SourceFormat::Markdown);
+    m_customSrcFormatCB->addItem(tr("HTML"), (int)ExportCustomOption::SourceFormat::HTML);
+    m_customSrcFormatCB->setCurrentIndex(m_customSrcFormatCB->findData((int)customOpt.m_srcFormat));
+
+    m_customSuffixEdit->setText(customOpt.m_outputSuffix);
+
+    m_customCmdEdit->setPlainText(customOpt.m_cmd);
+
+    m_customAllInOneCB->setChecked(s_opt.m_customOpt.m_allInOne);
+
+    m_customFolderSepEdit->setText(s_opt.m_customOpt.m_folderSep);
 }
 
 bool VExportDialog::checkWkhtmltopdfExecutable(const QString &p_file)
@@ -493,11 +512,14 @@ void VExportDialog::startExport()
 
     QString outputFolder = QDir::cleanPath(QDir(getOutputDirectory()).absolutePath());
 
+    QString renderStyle = m_renderStyleCB->currentData().toString();
+    QString cssUrl = g_config->getCssStyleUrl(renderStyle);
+
     s_opt = ExportOption(currentSource(),
                          currentFormat(),
                          (MarkdownConverterType)m_rendererCB->currentData().toInt(),
                          m_renderBgCB->currentData().toString(),
-                         m_renderStyleCB->currentData().toString(),
+                         renderStyle,
                          m_renderCodeBlockStyleCB->currentData().toString(),
                          m_subfolderCB->isChecked(),
                          ExportPDFOption(&m_pageLayout,
@@ -507,11 +529,20 @@ void VExportDialog::startExport()
                                          m_tableOfContentsCB->isChecked(),
                                          m_wkTitleEdit->text(),
                                          m_wkTargetFileNameEdit->text(),
-                                         (ExportPageNumber)m_wkPageNumberCB->currentData().toInt(),
+                                         (ExportPageNumber)
+                                         m_wkPageNumberCB->currentData().toInt(),
                                          m_wkExtraArgsEdit->text()),
                          ExportHTMLOption(m_embedStyleCB->isChecked(),
                                           m_completeHTMLCB->isChecked(),
-                                          m_mimeHTMLCB->isChecked()));
+                                          m_mimeHTMLCB->isChecked()),
+                         ExportCustomOption((ExportCustomOption::SourceFormat)
+                                            m_customSrcFormatCB->currentData().toInt(),
+                                            m_customSuffixEdit->text(),
+                                            m_customCmdEdit->toPlainText(),
+                                            cssUrl,
+                                            m_customAllInOneCB->isChecked(),
+                                            m_customFolderSepEdit->text(),
+                                            m_customTargetFileNameEdit->text()));
 
     m_consoleEdit->clear();
     appendLogLine(tr("Export to %1.").arg(outputFolder));
@@ -521,7 +552,6 @@ void VExportDialog::startExport()
         || s_opt.m_format == ExportFormat::HTML) {
         if (s_opt.m_format != ExportFormat::OnePDF) {
             s_opt.m_pdfOpt.m_wkTitle.clear();
-            s_opt.m_pdfOpt.m_wkTargetFileName.clear();
         }
 
         if ((s_opt.m_format == ExportFormat::PDF
@@ -539,6 +569,24 @@ void VExportDialog::startExport()
         }
 
         m_exporter->prepareExport(s_opt);
+    } else if (s_opt.m_format == ExportFormat::Custom) {
+        const ExportCustomOption &opt = s_opt.m_customOpt;
+        if (opt.m_srcFormat == ExportCustomOption::HTML) {
+            m_exporter->prepareExport(s_opt);
+        }
+
+        // Save it to config.
+        g_config->setCustomExport(opt.toConfig());
+
+        if (opt.m_outputSuffix.isEmpty()
+            || opt.m_cmd.isEmpty()
+            || opt.m_allInOne && opt.m_folderSep.isEmpty()) {
+            appendLogLine(tr("Invalid configurations for custom export."));
+            m_inExport = false;
+            m_exportBtn->setEnabled(true);
+            m_proBar->hide();
+            return;
+        }
     }
 
     int ret = 0;
@@ -546,39 +594,13 @@ void VExportDialog::startExport()
 
     if (s_opt.m_format == ExportFormat::OnePDF) {
         QList<QString> files;
-
         // Output HTMLs to a tmp folder.
         QTemporaryDir tmpDir;
         if (!tmpDir.isValid()) {
             goto exit;
         }
 
-        qDebug() << "output HTMLs to temporary dir" << tmpDir.path();
-
-        s_opt.m_format = ExportFormat::HTML;
-        switch (s_opt.m_source) {
-        case ExportSource::CurrentNote:
-            ret = doExport(m_file, s_opt, tmpDir.path(), &msg, &files);
-            break;
-
-        case ExportSource::CurrentFolder:
-            ret = doExport(m_directory, s_opt, tmpDir.path(), &msg, &files);
-            break;
-
-        case ExportSource::CurrentNotebook:
-            ret = doExport(m_notebook, s_opt, tmpDir.path(), &msg, &files);
-            break;
-
-        case ExportSource::Cart:
-            ret = doExport(m_cart, s_opt, tmpDir.path(), &msg, &files);
-            break;
-
-        default:
-            break;
-        }
-
-        s_opt.m_format = ExportFormat::OnePDF;
-
+        ret = outputAsHTML(tmpDir.path(), &msg, &files);
         if (m_askedToStop) {
             ret = 0;
             goto exit;
@@ -588,6 +610,31 @@ void VExportDialog::startExport()
         if (!files.isEmpty()) {
             ret = doExportPDFAllInOne(files, s_opt, outputFolder, &msg);
         }
+    } else if (s_opt.m_format == ExportFormat::Custom
+               && s_opt.m_customOpt.m_allInOne) {
+        QList<QString> files;
+        QTemporaryDir tmpDir;
+        if (!tmpDir.isValid()) {
+            goto exit;
+        }
+
+        if (s_opt.m_customOpt.m_srcFormat == ExportCustomOption::HTML) {
+            // Output HTMLs to a tmp folder.
+            ret = outputAsHTML(tmpDir.path(), &msg, &files);
+            if (m_askedToStop) {
+                ret = 0;
+                goto exit;
+            }
+
+            Q_ASSERT(ret == files.size());
+        } else {
+            // Collect all markdown files.
+            files = collectFiles(&msg);
+        }
+
+        if (!files.isEmpty()) {
+            ret = doExportCustomAllInOne(files, s_opt, outputFolder, &msg);
+        }
     } else {
         switch (s_opt.m_source) {
         case ExportSource::CurrentNote:
@@ -735,6 +782,10 @@ int VExportDialog::doExport(VFile *p_file,
         ret = doExportHTML(p_file, p_opt, p_outputFolder, p_errMsg, p_outputFiles);
         break;
 
+    case ExportFormat::Custom:
+        ret = doExportCustom(p_file, p_opt, p_outputFolder, p_errMsg, p_outputFiles);
+        break;
+
     default:
         break;
     }
@@ -1011,6 +1062,45 @@ int VExportDialog::doExportHTML(VFile *p_file,
     }
 }
 
+int VExportDialog::doExportCustom(VFile *p_file,
+                                  const ExportOption &p_opt,
+                                  const QString &p_outputFolder,
+                                  QString *p_errMsg,
+                                  QList<QString> *p_outputFiles)
+{
+    Q_UNUSED(p_opt);
+
+    QString srcFilePath(p_file->fetchPath());
+
+    if (p_file->getDocType() != DocType::Markdown) {
+        LOGERR(tr("Skip exporting non-Markdown file %1.").arg(srcFilePath));
+        return 0;
+    }
+
+    if (!VUtils::makePath(p_outputFolder)) {
+        LOGERR(tr("Fail to create directory %1.").arg(p_outputFolder));
+        return 0;
+    }
+
+    // Get output file.
+    QString suffix = "." + p_opt.m_customOpt.m_outputSuffix;
+    QString name = VUtils::getFileNameWithSequence(p_outputFolder,
+                                                   QFileInfo(p_file->getName()).completeBaseName() + suffix);
+    QString outputPath = QDir(p_outputFolder).filePath(name);
+
+    if (m_exporter->exportCustom(p_file, p_opt, outputPath, p_errMsg)) {
+        if (p_outputFiles) {
+            p_outputFiles->append(outputPath);
+        }
+
+        appendLogLine(tr("Note %1 exported to %2.").arg(srcFilePath).arg(outputPath));
+        return 1;
+    } else {
+        appendLogLine(tr("Fail to export note %1.").arg(srcFilePath));
+        return 0;
+    }
+}
+
 bool VExportDialog::checkUserAction()
 {
     if (m_askedToStop) {
@@ -1054,6 +1144,7 @@ void VExportDialog::handleCurrentFormatChanged(int p_index)
     bool pdfEnabled = false;
     bool htmlEnabled = false;
     bool pdfTitleNameEnabled = false;
+    bool customEnabled = false;
 
     if (p_index >= 0) {
         switch (currentFormat()) {
@@ -1073,6 +1164,10 @@ void VExportDialog::handleCurrentFormatChanged(int p_index)
             m_wkhtmltopdfCB->setEnabled(false);
             break;
 
+        case ExportFormat::Custom:
+            customEnabled = true;
+            break;
+
         default:
             break;
         }
@@ -1080,6 +1175,7 @@ void VExportDialog::handleCurrentFormatChanged(int p_index)
 
     m_pdfSettings->setVisible(pdfEnabled);
     m_htmlSettings->setVisible(htmlEnabled);
+    m_customSettings->setVisible(customEnabled);
 
     m_wkTitleEdit->setEnabled(pdfTitleNameEnabled);
     m_wkTargetFileNameEdit->setEnabled(pdfTitleNameEnabled);
@@ -1129,8 +1225,6 @@ int VExportDialog::doExportPDFAllInOne(const QList<QString> &p_files,
 
     QString outputPath = QDir(p_outputFolder).filePath(name);
 
-    qDebug() << "output" << p_files.size() << "HTML files as PDF to" << outputPath;
-
     int ret = m_exporter->exportPDFInOne(p_files, p_opt, outputPath, p_errMsg);
     if (ret > 0) {
         appendLogLine(tr("%1 notes exported to %2.").arg(ret).arg(outputPath));
@@ -1140,3 +1234,183 @@ int VExportDialog::doExportPDFAllInOne(const QList<QString> &p_files,
 
     return ret;
 }
+
+int VExportDialog::doExportCustomAllInOne(const QList<QString> &p_files,
+                                          const ExportOption &p_opt,
+                                          const QString &p_outputFolder,
+                                          QString *p_errMsg)
+{
+    if (p_files.isEmpty()) {
+        return 0;
+    }
+
+    if (!VUtils::makePath(p_outputFolder)) {
+        LOGERR(tr("Fail to create directory %1.").arg(p_outputFolder));
+        return 0;
+    }
+
+    // Get output file.
+    QString suffix = "." + p_opt.m_customOpt.m_outputSuffix;
+    QString name = p_opt.m_customOpt.m_targetFileName;
+    if (name.isEmpty()) {
+        name = VUtils::getFileNameWithSequence(p_outputFolder,
+                                               QFileInfo(p_files.first()).completeBaseName() + suffix);
+    } else if (!name.endsWith(suffix)) {
+        name += suffix;
+    }
+
+    QString outputPath = QDir(p_outputFolder).filePath(name);
+
+    int ret = m_exporter->exportCustomInOne(p_files, p_opt, outputPath, p_errMsg);
+    if (ret > 0) {
+        appendLogLine(tr("%1 notes exported to %2.").arg(ret).arg(outputPath));
+    } else {
+        appendLogLine(tr("Fail to export %1 notes in one.").arg(p_files.size()));
+    }
+
+    return ret;
+}
+
+QWidget *VExportDialog::setupCustomAdvancedSettings()
+{
+    // Source format.
+    m_customSrcFormatCB = VUtils::getComboBox();
+    m_customSrcFormatCB->setToolTip(tr("Choose format of the input"));
+
+    // Output suffix.
+    m_customSuffixEdit = new VLineEdit();
+    m_customSuffixEdit->setPlaceholderText(tr("Without the preceding dot"));
+    m_customSuffixEdit->setToolTip(tr("Suffix of the output file without the preceding dot"));
+    QValidator *validator = new QRegExpValidator(QRegExp(VUtils::c_fileNameRegExp),
+                                                 m_customSuffixEdit);
+    m_customSuffixEdit->setValidator(validator);
+
+    QLabel *tipsLabel = new QLabel(tr("<span><span style=\"font-weight:bold;\">%0</span> for the input file; "
+                                      "<span style=\"font-weight:bold;\">%1</span> for the output file; "
+                                      "<span style=\"font-weight:bold;\">%2</span> for the rendering CSS style file; "
+                                      "<span style=\"font-weight:bold;\">%3</span> for the input file directory.</span>"));
+    tipsLabel->setWordWrap(true);
+
+    // Enable All In One.
+    m_customAllInOneCB = new QCheckBox(tr("Enable All In One"));
+    m_customAllInOneCB->setToolTip(tr("Pass a list of input files to the custom command"));
+    connect(m_customAllInOneCB, &QCheckBox::stateChanged,
+            this, [this](int p_state) {
+                bool checked = p_state == Qt::Checked;
+                m_customFolderSepEdit->setEnabled(checked);
+                m_customTargetFileNameEdit->setEnabled(checked);
+            });
+
+    // Input directory separator.
+    m_customFolderSepEdit = new VLineEdit();
+    m_customFolderSepEdit->setPlaceholderText(tr("Separator to concatenate input files directories"));
+    m_customFolderSepEdit->setToolTip(tr("Separator to concatenate input files directories"));
+    m_customFolderSepEdit->setEnabled(false);
+
+    // Target file name for all in one.
+    m_customTargetFileNameEdit = new VLineEdit();
+    m_customTargetFileNameEdit->setPlaceholderText(tr("Empty to use the name of the first source file"));
+    m_customTargetFileNameEdit->setToolTip(tr("Name of the generated All-In-One file"));
+    validator = new QRegExpValidator(QRegExp(VUtils::c_fileNameRegExp),
+                                                 m_customTargetFileNameEdit);
+    m_customTargetFileNameEdit->setValidator(validator);
+    m_customTargetFileNameEdit->setEnabled(false);
+
+    // Cmd edit.
+    m_customCmdEdit = new QPlainTextEdit();
+    m_customCmdEdit->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Minimum);
+    QString cmdExamp("pandoc --resource-path=.:\"%3\" --css=\"%2\" -s -o \"%1\" \"%0\"");
+    m_customCmdEdit->setPlaceholderText(cmdExamp);
+    m_customCmdEdit->setToolTip(tr("Custom command to be executed"));
+    m_customCmdEdit->setProperty("LineEdit", true);
+
+    QGridLayout *advLayout = new QGridLayout();
+    advLayout->addWidget(new QLabel(tr("Source format:")), 0, 0);
+    advLayout->addWidget(m_customSrcFormatCB, 0, 1, 1, 2);
+
+    advLayout->addWidget(new QLabel(tr("Output suffix:")), 0, 3);
+    advLayout->addWidget(m_customSuffixEdit, 0, 4, 1, 2);
+
+    advLayout->addWidget(m_customAllInOneCB, 1, 1, 1, 2);
+
+    advLayout->addWidget(new QLabel(tr("Output file name:")), 2, 0);
+    advLayout->addWidget(m_customTargetFileNameEdit, 2, 1, 1, 2);
+
+    advLayout->addWidget(new QLabel(tr("Input directories separator:")), 2, 3);
+    advLayout->addWidget(m_customFolderSepEdit, 2, 4, 1, 2);
+
+    advLayout->addWidget(tipsLabel, 3, 0, 1, 6);
+
+    advLayout->addWidget(m_customCmdEdit, 4, 0, 1, 6);
+
+    advLayout->setContentsMargins(0, 0, 0, 0);
+
+    QWidget *wid = new QWidget();
+    wid->setLayout(advLayout);
+
+    m_customCmdEdit->setMaximumHeight(100);
+
+    return wid;
+}
+
+int VExportDialog::outputAsHTML(QString &p_outputFolder,
+                                QString *p_errMsg,
+                                QList<QString> *p_outputFiles)
+{
+    int ret = 0;
+    ExportFormat fmt = s_opt.m_format;
+    s_opt.m_format = ExportFormat::HTML;
+    switch (s_opt.m_source) {
+    case ExportSource::CurrentNote:
+        ret = doExport(m_file, s_opt, p_outputFolder, p_errMsg, p_outputFiles);
+        break;
+
+    case ExportSource::CurrentFolder:
+        ret = doExport(m_directory, s_opt, p_outputFolder, p_errMsg, p_outputFiles);
+        break;
+
+    case ExportSource::CurrentNotebook:
+        ret = doExport(m_notebook, s_opt, p_outputFolder, p_errMsg, p_outputFiles);
+        break;
+
+    case ExportSource::Cart:
+        ret = doExport(m_cart, s_opt, p_outputFolder, p_errMsg, p_outputFiles);
+        break;
+
+    default:
+        break;
+    }
+
+    s_opt.m_format = fmt;
+
+    return ret;
+}
+
+QList<QString> VExportDialog::collectFiles(QString *p_errMsg)
+{
+    Q_UNUSED(p_errMsg);
+
+    QList<QString> files;
+    switch (s_opt.m_source) {
+    case ExportSource::CurrentNote:
+        files.append(m_file->fetchPath());
+        break;
+
+    case ExportSource::CurrentFolder:
+        files = m_directory->collectFiles();
+        break;
+
+    case ExportSource::CurrentNotebook:
+        files = m_notebook->collectFiles();
+        break;
+
+    case ExportSource::Cart:
+        files = m_cart->getFiles().toList();
+        break;
+
+    default:
+        break;
+    }
+
+    return files;
+}

+ 123 - 3
src/dialog/vexportdialog.h

@@ -38,7 +38,8 @@ enum class ExportFormat
     Markdown = 0,
     HTML,
     PDF,
-    OnePDF
+    OnePDF,
+    Custom
 };
 
 
@@ -119,6 +120,87 @@ struct ExportPDFOption
 };
 
 
+struct ExportCustomOption
+{
+#if defined(Q_OS_WIN)
+    #define DEFAULT_SEP ";"
+#else
+    #define DEFAULT_SEP ":"
+#endif
+
+    enum SourceFormat
+    {
+        Markdown = 0,
+        HTML
+    };
+
+    ExportCustomOption()
+        : m_srcFormat(SourceFormat::Markdown),
+          m_allInOne(false),
+          m_folderSep(DEFAULT_SEP)
+    {
+    }
+
+    ExportCustomOption(const QStringList &p_config)
+        : m_srcFormat(SourceFormat::Markdown),
+          m_allInOne(false),
+          m_folderSep(DEFAULT_SEP)
+    {
+        if (p_config.size() < 3) {
+            return;
+        }
+
+        if (p_config.at(0).trimmed() != "0") {
+            m_srcFormat = SourceFormat::HTML;
+        }
+
+        m_outputSuffix = p_config.at(1).trimmed();
+
+        m_cmd = p_config.at(2).trimmed();
+    }
+
+    ExportCustomOption(ExportCustomOption::SourceFormat p_srcFormat,
+                       const QString &p_outputSuffix,
+                       const QString &p_cmd,
+                       const QString &p_cssUrl,
+                       bool p_allInOne,
+                       const QString &p_folderSep,
+                       const QString &p_targetFileName)
+        : m_srcFormat(p_srcFormat),
+          m_outputSuffix(p_outputSuffix),
+          m_cssUrl(p_cssUrl),
+          m_allInOne(p_allInOne),
+          m_folderSep(p_folderSep),
+          m_targetFileName(p_targetFileName)
+    {
+        QStringList cmds = p_cmd.split('\n');
+        if (!cmds.isEmpty()) {
+            m_cmd = cmds.first();
+        }
+    }
+
+    QStringList toConfig() const
+    {
+        QStringList config;
+        config << QString::number((int)m_srcFormat);
+        config << m_outputSuffix;
+        config << m_cmd;
+
+        return config;
+    }
+
+    SourceFormat m_srcFormat;
+    QString m_outputSuffix;
+    QString m_cmd;
+
+    QString m_cssUrl;
+    bool m_allInOne;
+
+    QString m_folderSep;
+    QString m_targetFileName;
+};
+
+
 struct ExportOption
 {
     ExportOption()
@@ -137,7 +219,8 @@ struct ExportOption
                  const QString &p_renderCodeBlockStyle,
                  bool p_processSubfolders,
                  const ExportPDFOption &p_pdfOpt,
-                 const ExportHTMLOption &p_htmlOpt)
+                 const ExportHTMLOption &p_htmlOpt,
+                 const ExportCustomOption &p_customOpt)
         : m_source(p_source),
           m_format(p_format),
           m_renderer(p_renderer),
@@ -146,7 +229,8 @@ struct ExportOption
           m_renderCodeBlockStyle(p_renderCodeBlockStyle),
           m_processSubfolders(p_processSubfolders),
           m_pdfOpt(p_pdfOpt),
-          m_htmlOpt(p_htmlOpt)
+          m_htmlOpt(p_htmlOpt),
+          m_customOpt(p_customOpt)
     {
     }
 
@@ -166,6 +250,8 @@ struct ExportOption
     ExportPDFOption m_pdfOpt;
 
     ExportHTMLOption m_htmlOpt;
+
+    ExportCustomOption m_customOpt;
 };
 
 
@@ -204,6 +290,8 @@ private:
 
     QWidget *setupGeneralAdvancedSettings();
 
+    QWidget *setupCustomAdvancedSettings();
+
     void initUIFields(MarkdownConverterType p_renderer);
 
     QString getOutputDirectory() const;
@@ -258,6 +346,17 @@ private:
                             const QString &p_outputFolder,
                             QString *p_errMsg = NULL);
 
+    int doExportCustomAllInOne(const QList<QString> &p_files,
+                               const ExportOption &p_opt,
+                               const QString &p_outputFolder,
+                               QString *p_errMsg = NULL);
+
+    int doExportCustom(VFile *p_file,
+                       const ExportOption &p_opt,
+                       const QString &p_outputFolder,
+                       QString *p_errMsg = NULL,
+                       QList<QString> *p_outputFiles = NULL);
+
     // Return false if we could not continue.
     bool checkUserAction();
 
@@ -269,6 +368,13 @@ private:
 
     ExportFormat currentFormat() const;
 
+    int outputAsHTML(QString &p_outputFolder,
+                     QString *p_errMsg = NULL,
+                     QList<QString> *p_outputFiles = NULL);
+
+    // Collect files to be handled.
+    QList<QString> collectFiles(QString *p_errMsg = NULL);
+
     QComboBox *m_srcCB;
 
     QComboBox *m_formatCB;
@@ -293,6 +399,8 @@ private:
 
     QWidget *m_generalSettings;
 
+    QWidget *m_customSettings;
+
     QPlainTextEdit *m_consoleEdit;
 
     QDialogButtonBox *m_btnBox;
@@ -329,6 +437,18 @@ private:
 
     QCheckBox *m_subfolderCB;
 
+    QComboBox *m_customSrcFormatCB;
+
+    VLineEdit *m_customSuffixEdit;
+
+    QCheckBox *m_customAllInOneCB;
+
+    QPlainTextEdit *m_customCmdEdit;
+
+    VLineEdit *m_customFolderSepEdit;
+
+    VLineEdit *m_customTargetFileNameEdit;
+
     VNotebook *m_notebook;
 
     VDirectory *m_directory;

+ 7 - 0
src/resources/vnote.ini

@@ -209,6 +209,13 @@ wkhtmltopdf=wkhtmltopdf
 ; Double quotes to enclose arguments with spaces
 wkhtmltopdfArgs=
 
+; A string list separated by ,
+; SourceFormat,OutputSuffix,CMD
+; SourceFormat: 0 for Markdown, 1 for HTML
+; OutputSuffix: suffix WITHOUT the preceding dot
+; CMD: command to execute, %0 for the input file, %1 for the output file
+custom_export=
+
 [web]
 ; Location and configuration for Mathjax
 mathjax_javascript=https://cdn.bootcss.com/mathjax/2.7.2/MathJax.js?config=TeX-MML-AM_HTMLorMML

+ 14 - 0
src/vconfigmanager.h

@@ -463,6 +463,9 @@ public:
     bool getEnableFlashAnchor() const;
     void setEnableFlashAnchor(bool p_enabled);
 
+    QStringList getCustomExport() const;
+    void setCustomExport(const QStringList &p_exp);
+
 private:
     // Look up a config from user and default settings.
     QVariant getConfigFromSettings(const QString &section, const QString &key) const;
@@ -2132,4 +2135,15 @@ inline void VConfigManager::setEnableFlashAnchor(bool p_enabled)
     m_enableFlashAnchor = p_enabled;
     setConfigToSettings("web", "enable_flash_anchor", m_enableFlashAnchor);
 }
+
+inline QStringList VConfigManager::getCustomExport() const
+{
+    return getConfigFromSettings("export",
+                                 "custom_export").toStringList();
+}
+
+inline void VConfigManager::setCustomExport(const QStringList &p_exp)
+{
+    setConfigToSettings("export", "custom_export", p_exp);
+}
 #endif // VCONFIGMANAGER_H

+ 26 - 0
src/vdirectory.cpp

@@ -716,3 +716,29 @@ bool VDirectory::sortSubDirectories(const QVector<int> &p_sortedIdx)
 
     return ret;
 }
+
+QList<QString> VDirectory::collectFiles()
+{
+    QList<QString> files;
+    bool opened = isOpened();
+    if (!opened && !open()) {
+        qWarning() << "fail to open directory" << fetchPath();
+        return files;
+    }
+
+    // Files.
+    for (auto const & file : m_files) {
+        files.append(file->fetchPath());
+    }
+
+    // Subfolders.
+    for (auto const & dir : m_subDirs) {
+        files.append(dir->collectFiles());
+    }
+
+    if (!opened) {
+        close();
+    }
+
+    return files;
+}

+ 3 - 0
src/vdirectory.h

@@ -114,6 +114,9 @@ public:
     // Reorder sub-directories in m_subDirs by index.
     bool sortSubDirectories(const QVector<int> &p_sortedIdx);
 
+    // Return path of files in this directory recursively.
+    QList<QString> collectFiles();
+
     // Delete directory @p_dir.
     static bool deleteDirectory(VDirectory *p_dir,
                                 bool p_skipRecycleBin = false,

+ 263 - 62
src/vexporter.cpp

@@ -40,7 +40,8 @@ static QString marginToStrMM(qreal p_margin)
 void VExporter::prepareExport(const ExportOption &p_opt)
 {
     bool isPdf = p_opt.m_format == ExportFormat::PDF
-                 || p_opt.m_format == ExportFormat::OnePDF;
+                 || p_opt.m_format == ExportFormat::OnePDF
+                 || p_opt.m_format == ExportFormat::Custom;
     bool extraToc = isPdf
                     && !p_opt.m_pdfOpt.m_wkhtmltopdf
                     && p_opt.m_pdfOpt.m_enableTableOfContents;
@@ -178,6 +179,69 @@ bool VExporter::exportHTML(VFile *p_file,
     return exportViaWebView(p_file, p_opt, p_outputFile, p_errMsg);
 }
 
+static void replaceArgument(QString &p_cmd, const QString &p_arg, const QString &p_val)
+{
+    if (p_val.startsWith("\"")) {
+        // Check if the arg has been already surrounded by ".
+        int pos = 0;
+        while (pos < p_cmd.size()) {
+            int idx = p_cmd.indexOf(p_arg, pos);
+            if (idx == -1) {
+                break;
+            }
+
+            int len = p_arg.size();
+            int nidx = idx;
+            if (idx > 0 && p_cmd[idx - 1] == '"') {
+                --nidx;
+                len += 1;
+            }
+
+            if (idx + p_arg.size() < p_cmd.size()
+                && p_cmd[idx + p_arg.size()] == '"') {
+                len += 1;
+            }
+
+            p_cmd.replace(nidx, len, p_val);
+            pos = nidx + p_val.size() - len;
+        }
+    } else {
+        p_cmd.replace(p_arg, p_val);
+    }
+}
+
+static QString evaluateCommand(const ExportCustomOption &p_opt,
+                               const QString &p_input,
+                               const QString &p_inputFolder,
+                               const QString &p_output)
+{
+    QString cssStyle = QDir::toNativeSeparators(p_opt.m_cssUrl);
+
+    QString cmd(p_opt.m_cmd);
+    replaceArgument(cmd, "%0", p_input);
+    replaceArgument(cmd, "%1", p_output);
+    replaceArgument(cmd, "%2", cssStyle);
+    replaceArgument(cmd, "%3", p_inputFolder);
+
+    return cmd;
+}
+
+bool VExporter::exportCustom(VFile *p_file,
+                             const ExportOption &p_opt,
+                             const QString &p_outputFile,
+                             QString *p_errMsg)
+{
+    const ExportCustomOption &customOpt = p_opt.m_customOpt;
+    if (customOpt.m_srcFormat == ExportCustomOption::Markdown) {
+        // Use Markdown file as input.
+        QList<QString> files;
+        files.append(QDir::toNativeSeparators(p_file->fetchPath()));
+        return convertFilesViaCustom(files, p_outputFile, customOpt, p_errMsg);
+    } else {
+        return exportViaWebView(p_file, p_opt, p_outputFile, p_errMsg);
+    }
+}
+
 void VExporter::initWebViewer(VFile *p_file, const ExportOption &p_opt)
 {
     Q_ASSERT(!m_webViewer);
@@ -308,49 +372,88 @@ bool VExporter::exportToPDFViaWK(VDocument *p_webDocument,
                 }
 
                 QString htmlPath = tmpDir.filePath("vnote_tmp.html");
-
-                QFile file(htmlPath);
-                if (!file.open(QFile::WriteOnly)) {
+                if (!outputToHTMLFile(htmlPath,
+                                      p_headContent,
+                                      p_styleContent,
+                                      p_bodyContent,
+                                      true,
+                                      true)) {
                     pdfExported = -1;
                     return;
                 }
 
-                QString resFolder = QFileInfo(htmlPath).completeBaseName() + "_files";
-                QString resFolderPath = QDir(VUtils::basePathFromPath(htmlPath)).filePath(resFolder);
+                // Convert via wkhtmltopdf.
+                QList<QString> files;
+                files.append(htmlPath);
+                if (!htmlsToPDFViaWK(files, p_filePath, p_opt, p_errMsg)) {
+                    pdfExported = -1;
+                } else {
+                    pdfExported = 1;
+                }
+            });
 
-                qDebug() << "temp HTML files folder" << resFolderPath;
+    p_webDocument->getHtmlContentAsync();
 
-                QString html(m_exportHtmlTemplate);
-                if (!p_styleContent.isEmpty()) {
-                    QString content(p_styleContent);
-                    fixStyleResources(resFolderPath, content);
-                    html.replace(HtmlHolder::c_styleHolder, content);
-                }
+    while (pdfExported == 0) {
+        VUtils::sleepWait(100);
+
+        if (m_state == ExportState::Cancelled) {
+            break;
+        }
+    }
+
+    return pdfExported == 1;
+}
+
+bool VExporter::exportToCustom(VDocument *p_webDocument,
+                               const ExportCustomOption &p_opt,
+                               const QString &p_filePath,
+                               QString *p_errMsg)
+{
+    int exported = 0;
 
-                if (!p_headContent.isEmpty()) {
-                    html.replace(HtmlHolder::c_headHolder, p_headContent);
+    connect(p_webDocument, &VDocument::htmlContentFinished,
+            this, [&, this](const QString &p_headContent,
+                            const QString &p_styleContent,
+                            const QString &p_bodyContent) {
+                if (p_bodyContent.isEmpty() || this->m_state == ExportState::Cancelled) {
+                    exported = -1;
+                    return;
                 }
 
-                QString content(p_bodyContent);
-                fixBodyResources(m_baseUrl, resFolderPath, content);
-                html.replace(HtmlHolder::c_bodyHolder, content);
+                Q_ASSERT(!p_filePath.isEmpty());
 
-                file.write(html.toUtf8());
-                file.close();
+                // Save HTML to a temp dir.
+                QTemporaryDir tmpDir;
+                if (!tmpDir.isValid()) {
+                    exported = -1;
+                    return;
+                }
 
-                // Convert via wkhtmltopdf.
+                QString htmlPath = tmpDir.filePath("vnote_tmp.html");
+                if (!outputToHTMLFile(htmlPath,
+                                      p_headContent,
+                                      p_styleContent,
+                                      p_bodyContent,
+                                      true,
+                                      true)) {
+                    exported = -1;
+                    return;
+                }
+
+                // Convert via custom command.
                 QList<QString> files;
                 files.append(htmlPath);
-                if (!htmlsToPDFViaWK(files, p_filePath, p_opt, p_errMsg)) {
-                    pdfExported = -1;
+                if (!convertFilesViaCustom(files, p_filePath, p_opt, p_errMsg)) {
+                    exported = -1;
                 } else {
-                    pdfExported = 1;
+                    exported = 1;
                 }
             });
 
     p_webDocument->getHtmlContentAsync();
 
-    while (pdfExported == 0) {
+    while (exported == 0) {
         VUtils::sleepWait(100);
 
         if (m_state == ExportState::Cancelled) {
@@ -358,7 +461,7 @@ bool VExporter::exportToPDFViaWK(VDocument *p_webDocument,
         }
     }
 
-    return pdfExported == 1;
+    return exported == 1;
 }
 
 bool VExporter::exportViaWebView(VFile *p_file,
@@ -440,6 +543,13 @@ bool VExporter::exportViaWebView(VFile *p_file,
 
         break;
 
+    case ExportFormat::Custom:
+        exportRet = exportToCustom(m_webDocument,
+                                   p_opt.m_customOpt,
+                                   p_outputFile,
+                                   p_errMsg);
+        break;
+
     default:
         break;
     }
@@ -487,46 +597,16 @@ bool VExporter::exportToHTML(VDocument *p_webDocument,
 
                 Q_ASSERT(!p_filePath.isEmpty());
 
-                QFile file(p_filePath);
-                if (!file.open(QFile::WriteOnly)) {
+                if (!outputToHTMLFile(p_filePath,
+                                      p_headContent,
+                                      p_styleContent,
+                                      p_bodyContent,
+                                      p_opt.m_embedCssStyle,
+                                      p_opt.m_completeHTML)) {
                     htmlExported = -1;
                     return;
                 }
 
-                QString resFolder = QFileInfo(p_filePath).completeBaseName() + "_files";
-                QString resFolderPath = QDir(VUtils::basePathFromPath(p_filePath)).filePath(resFolder);
-
-                qDebug() << "HTML files folder" << resFolderPath;
-
-                QString html(m_exportHtmlTemplate);
-                if (!p_styleContent.isEmpty() && p_opt.m_embedCssStyle) {
-                    QString content(p_styleContent);
-                    fixStyleResources(resFolderPath, content);
-                    html.replace(HtmlHolder::c_styleHolder, content);
-                }
-
-                if (!p_headContent.isEmpty()) {
-                    html.replace(HtmlHolder::c_headHolder, p_headContent);
-                }
-
-                if (p_opt.m_completeHTML) {
-                    QString content(p_bodyContent);
-                    fixBodyResources(m_baseUrl, resFolderPath, content);
-                    html.replace(HtmlHolder::c_bodyHolder, content);
-                } else {
-                    html.replace(HtmlHolder::c_bodyHolder, p_bodyContent);
-                }
-
-                file.write(html.toUtf8());
-                file.close();
-
-                // Delete empty resource folder.
-                QDir dir(resFolderPath);
-                if (dir.isEmpty()) {
-                    dir.cdUp();
-                    dir.rmdir(resFolder);
-                }
-
                 htmlExported = 1;
             });
 
@@ -711,6 +791,56 @@ bool VExporter::htmlsToPDFViaWK(const QList<QString> &p_htmlFiles,
     return ret == 0;
 }
 
+bool VExporter::convertFilesViaCustom(const QList<QString> &p_files,
+                                      const QString &p_filePath,
+                                      const ExportCustomOption &p_opt,
+                                      QString *p_errMsg)
+{
+    QString input;
+    QString inputFolder;
+    for (auto const & it : p_files) {
+        if (!input.isEmpty()) {
+            input += " ";
+        }
+
+        if (!inputFolder.isEmpty()) {
+            inputFolder += p_opt.m_folderSep;
+        }
+
+        QString tmp = QDir::toNativeSeparators(it);
+        input += ("\"" + tmp + "\"");
+        inputFolder += ("\"" + VUtils::basePathFromPath(tmp) + "\"");
+    }
+
+    QString output = QDir::toNativeSeparators(p_filePath);
+    QString cmd = evaluateCommand(p_opt,
+                                  input,
+                                  inputFolder,
+                                  output);
+    emit outputLog(cmd);
+    qDebug() << "custom cmd:" << cmd;
+    int ret = startProcess(cmd);
+    qDebug() << "custom cmd returned" << ret;
+    if (m_askedToStop) {
+        return ret == 0;
+    }
+
+    switch (ret) {
+    case -2:
+        VUtils::addErrMsg(p_errMsg, tr("Fail to start custom command (%1).").arg(cmd));
+        break;
+
+    case -1:
+        VUtils::addErrMsg(p_errMsg, tr("Custom command crashed (%1).").arg(cmd));
+        break;
+
+    default:
+        break;
+    }
+
+    return ret == 0;
+}
+
 int VExporter::exportPDFInOne(const QList<QString> &p_htmlFiles,
                               const ExportOption &p_opt,
                               const QString &p_outputFile,
@@ -796,3 +926,74 @@ int VExporter::startProcess(const QString &p_program, const QStringList &p_args)
 
     return ret;
 }
+
+int VExporter::startProcess(const QString &p_cmd)
+{
+    QStringList args = parseCombinedArgString(p_cmd);
+    if (args.isEmpty()) {
+        return -2;
+    }
+
+    return startProcess(args.first(), args.mid(1));
+}
+
+bool VExporter::outputToHTMLFile(const QString &p_file,
+                                 const QString &p_headContent,
+                                 const QString &p_styleContent,
+                                 const QString &p_bodyContent,
+                                 bool p_embedCssStyle,
+                                 bool p_completeHTML)
+{
+    QFile file(p_file);
+    if (!file.open(QFile::WriteOnly)) {
+        return false;
+    }
+
+    QString resFolder = QFileInfo(p_file).completeBaseName() + "_files";
+    QString resFolderPath = QDir(VUtils::basePathFromPath(p_file)).filePath(resFolder);
+
+    qDebug() << "HTML files folder" << resFolderPath;
+
+    QString html(m_exportHtmlTemplate);
+    if (!p_styleContent.isEmpty() && p_embedCssStyle) {
+        QString content(p_styleContent);
+        fixStyleResources(resFolderPath, content);
+        html.replace(HtmlHolder::c_styleHolder, content);
+    }
+
+    if (!p_headContent.isEmpty()) {
+        html.replace(HtmlHolder::c_headHolder, p_headContent);
+    }
+
+    if (p_completeHTML) {
+        QString content(p_bodyContent);
+        fixBodyResources(m_baseUrl, resFolderPath, content);
+        html.replace(HtmlHolder::c_bodyHolder, content);
+    } else {
+        html.replace(HtmlHolder::c_bodyHolder, p_bodyContent);
+    }
+
+    file.write(html.toUtf8());
+    file.close();
+
+    // Delete empty resource folder.
+    QDir dir(resFolderPath);
+    if (dir.isEmpty()) {
+        dir.cdUp();
+        dir.rmdir(resFolder);
+    }
+
+    return true;
+}
+
+int VExporter::exportCustomInOne(const QList<QString> &p_files,
+                                 const ExportOption &p_opt,
+                                 const QString &p_outputFile,
+                                 QString *p_errMsg)
+{
+    if (!convertFilesViaCustom(p_files, p_outputFile, p_opt.m_customOpt, p_errMsg)) {
+        return 0;
+    }
+
+    return p_files.size();
+}

+ 29 - 0
src/vexporter.h

@@ -31,11 +31,21 @@ public:
                     const QString &p_outputFile,
                     QString *p_errMsg = NULL);
 
+    bool exportCustom(VFile *p_file,
+                      const ExportOption &p_opt,
+                      const QString &p_outputFile,
+                      QString *p_errMsg = NULL);
+
     int exportPDFInOne(const QList<QString> &p_htmlFiles,
                        const ExportOption &p_opt,
                        const QString &p_outputFile,
                        QString *p_errMsg = NULL);
 
+    int exportCustomInOne(const QList<QString> &p_files,
+                          const ExportOption &p_opt,
+                          const QString &p_outputFile,
+                          QString *p_errMsg = NULL);
+
     void setAskedToStop(bool p_askedToStop);
 
 signals:
@@ -94,6 +104,11 @@ private:
                           const QString &p_filePath,
                           QString *p_errMsg = NULL);
 
+    bool exportToCustom(VDocument *p_webDocument,
+                        const ExportCustomOption &p_opt,
+                        const QString &p_filePath,
+                        QString *p_errMsg = NULL);
+
     bool exportToHTML(VDocument *p_webDocument,
                       const ExportHTMLOption &p_opt,
                       const QString &p_filePath);
@@ -107,10 +122,24 @@ private:
                          const ExportPDFOption &p_opt,
                          QString *p_errMsg = NULL);
 
+    bool convertFilesViaCustom(const QList<QString> &p_files,
+                               const QString &p_filePath,
+                               const ExportCustomOption &p_opt,
+                               QString *p_errMsg = NULL);
+
     void prepareWKArguments(const ExportPDFOption &p_opt);
 
     int startProcess(const QString &p_program, const QStringList &p_args);
 
+    int startProcess(const QString &p_cmd);
+
+    bool outputToHTMLFile(const QString &p_file,
+                          const QString &p_headContent,
+                          const QString &p_styleContent,
+                          const QString &p_bodyContent,
+                          bool p_embedCssStyle,
+                          bool p_completeHTML);
+
     // Fix @p_html's resources like url("...") with "file" or "qrc" schema.
     // Copy the resource to @p_folder and fix the url string.
     static bool fixStyleResources(const QString &p_folder,

+ 19 - 0
src/vnotebook.cpp

@@ -367,3 +367,22 @@ QString VNotebook::getRecycleBinFolderPath() const
         return QDir(m_path).filePath(m_recycleBinFolder);
     }
 }
+
+QList<QString> VNotebook::collectFiles()
+{
+    QList<QString> files;
+
+    bool opened = isOpened();
+    if (!opened && !open()) {
+        qWarning() << "fail to open notebook %1" << m_path;
+        return files;
+    }
+
+    files = m_rootDir->collectFiles();
+
+    if (!opened) {
+        close();
+    }
+
+    return files;
+}

+ 2 - 0
src/vnotebook.h

@@ -95,6 +95,8 @@ public:
 
     bool isValid() const;
 
+    QList<QString> collectFiles();
+
 private:
     // Serialize current instance to json.
     QJsonObject toConfigJson() const;