Commit c10d83da authored by jan.koester's avatar jan.koester
Browse files

url baed website import

parent bcada0c9
Loading
Loading
Loading
Loading
+6 −0
Changes for editor/html/index.html: 6 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -307,6 +307,12 @@
    <dialog id="import-html-dialog">
        <h3 data-i18n="I18N_IMPORT_HTML">HTML importieren</h3>
        <div id="import-html-step1">
            <div class="set-field">
                <input type="url" id="import-html-url" data-i18n-placeholder="I18N_IMPORT_HTML_URL_PLACEHOLDER" placeholder="https://beispiel.de/seite">
                <button type="button" id="btn-import-html-load-url" class="set-save-btn" data-i18n="I18N_IMPORT_HTML_LOAD_URL">Von URL laden</button>
            </div>
            <p id="import-html-url-status" class="set-status" style="display:none"></p>
            <p class="hint" style="text-align:center" data-i18n="I18N_IMPORT_HTML_OR">oder</p>
            <label class="file-upload-label">&#x1F4C2; <span data-i18n="I18N_UPLOAD_FILE">Datei w&auml;hlen</span><input type="file" id="import-html-file" accept=".html,.htm,.txt" style="display:none"></label>
            <textarea id="import-html-content" rows="15" placeholder="HTML hier einf&uuml;gen..."></textarea>
            <div class="set-field">
+11 −1
Changes for editor/html/js/api.js: 11 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -535,12 +535,22 @@ var EditorApi = (function() {
            return request('POST', '/api/document/parse-html', { html: html });
        },

        importHtml: function(html, mode, parentUuid, sections, mediaImport) {
        // Fetches @p url server-side and parses it exactly like parseHtml, but
        // also resolves document-relative resource references (img/link/script/a)
        // against it. Response includes "html" (the fetched page) and "base_url"
        // (echoed back) so the caller can stash both and pass base_url to
        // importHtml unchanged, instead of fetching the page a second time.
        parseHtmlUrl: function(url) {
            return request('POST', '/api/document/parse-html', { url: url });
        },

        importHtml: function(html, mode, parentUuid, sections, mediaImport, baseUrl) {
            var data = { html: html };
            if (mode) data.mode = mode;
            if (parentUuid) data.parent_uuid = parentUuid;
            if (sections) data.sections = sections;
            if (mediaImport) data.media_import = mediaImport;
            if (baseUrl) data.base_url = baseUrl;
            return request('POST', '/api/document/import-html', data);
        },

+65 −2
Changes for editor/html/js/editor.js: 65 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -15,6 +15,7 @@
    var currentDocLastPublishTarget = ''; // target name last successfully published to, see applyPreferredPublishTarget
    var importHtmlPendingSource = ''; // raw HTML staged in the import dialog, once past step 1
    var importHtmlPendingCandidates = []; // last parse-html result for the block picker
    var importHtmlPendingBaseUrl = ''; // set when importHtmlPendingSource was fetched from a URL, see loadImportHtmlFromUrl
    var saveDialogMode = 'save';
    var projectsCache = [];
    var currentProjectId = '';
@@ -533,13 +534,57 @@

    function openImportHtmlDialog() {
        document.getElementById('import-html-content').value = '';
        document.getElementById('import-html-url').value = '';
        setImportHtmlUrlStatus('');
        importHtmlPendingSource = '';
        importHtmlPendingCandidates = [];
        importHtmlPendingBaseUrl = '';
        resetImportHtmlMediaOptions();
        showImportHtmlStep(1);
        document.getElementById('import-html-dialog').showModal();
    }

    function setImportHtmlUrlStatus(text, isError) {
        var el = document.getElementById('import-html-url-status');
        el.textContent = text || '';
        el.style.display = text ? '' : 'none';
        el.className = 'set-status' + (isError ? ' error' : '');
    }

    // Fetches @p url server-side (via EditorApi.parseHtmlUrl) and feeds the
    // result into the same block-picker step used by paste/upload, so the
    // rest of the import flow (candidate tree, media-import options, confirm)
    // doesn't need to know the HTML came from a URL -- only the stashed
    // importHtmlPendingBaseUrl does, and only to be echoed back to the
    // server on confirm so relative resource references keep resolving.
    function loadImportHtmlFromUrl(url) {
        var btn = document.getElementById('btn-import-html-load-url');
        btn.disabled = true;
        setImportHtmlUrlStatus(I18n.t('I18N_LOADING_URL', 'URL wird geladen...'));
        EditorApi.parseHtmlUrl(url).then(function(resp) {
            btn.disabled = false;
            setImportHtmlUrlStatus('');
            var html = resp.html || '';
            importHtmlPendingBaseUrl = resp.base_url || url;
            importHtmlPendingSource = html;
            document.getElementById('import-html-content').value = html;
            importHtmlPendingCandidates = resp.candidates || [];
            if (!importHtmlPendingCandidates.length) {
                importHtmlDirectImport(html);
                return;
            }
            renderImportHtmlCandidates(importHtmlPendingCandidates);
            document.getElementById('import-html-global-css-hint').style.display =
                resp.global_css ? '' : 'none';
            showImportHtmlStep(2);
        }).catch(function(err) {
            btn.disabled = false;
            setImportHtmlUrlStatus(
                I18n.t('I18N_IMPORT_FAILED', 'Import fehlgeschlagen') + ': ' + ((err && err.error) || ''),
                true);
        });
    }

    // --- HTML import dialog: "import external images into MediaDB" options ---

    function resetImportHtmlMediaOptions() {
@@ -672,6 +717,10 @@
    // everything rather than blocking on an empty picker.
    function goToImportHtmlStep2(html) {
        importHtmlPendingSource = html;
        // Paste/upload/manual edits have no known origin URL -- only
        // loadImportHtmlFromUrl sets importHtmlPendingBaseUrl, and only for
        // HTML it fetched itself unmodified.
        importHtmlPendingBaseUrl = '';
        EditorApi.parseHtml(html).then(function(resp) {
            importHtmlPendingCandidates = resp.candidates || [];
            if (!importHtmlPendingCandidates.length) {
@@ -692,7 +741,7 @@
    // Fallback used when the picker has nothing to show: import the whole
    // document directly instead of leaving the dialog stuck.
    function importHtmlDirectImport(html) {
        EditorApi.importHtml(html).then(function() {
        EditorApi.importHtml(html, null, null, null, null, importHtmlPendingBaseUrl).then(function() {
            document.getElementById('import-html-dialog').close();
            Canvas.clearSelection(); DocumentTree.clearSelection();
            PropertiesPanel.clear();
@@ -903,6 +952,20 @@
            document.getElementById('revisions-dialog').close();
        });

        // HTML Import dialog: import from URL (step 1, alternative to paste/upload)
        document.getElementById('btn-import-html-load-url').addEventListener('click', function() {
            var url = document.getElementById('import-html-url').value.trim();
            if (!url) return;
            loadImportHtmlFromUrl(url);
        });

        document.getElementById('import-html-url').addEventListener('keydown', function(e) {
            if (e.key === 'Enter') {
                e.preventDefault();
                document.getElementById('btn-import-html-load-url').click();
            }
        });

        // HTML Import dialog: paste/upload (step 1) -> block picker (step 2) -> import
        document.getElementById('import-html-file').addEventListener('change', function() {
            var file = this.files[0];
@@ -972,7 +1035,7 @@
                }
            }
            var mediaImport = getImportHtmlMediaImportOptions();
            EditorApi.importHtml(html, null, null, sections, mediaImport).then(function(resp) {
            EditorApi.importHtml(html, null, null, sections, mediaImport, importHtmlPendingBaseUrl).then(function(resp) {
                document.getElementById('import-html-dialog').close();
                Canvas.clearSelection(); DocumentTree.clearSelection();
                PropertiesPanel.clear();
+99 −1
Changes for editor/src/htmlimport.cpp: 99 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -991,6 +991,102 @@ void blogi::htmlimport::htmlElementToWidgetXml(

namespace {

// scheme://host:port for @p base, reconstructed the same way HttpUrl::print()
// does (always with an explicit port) so a re-parse of the result round-trips.
std::string urlOrigin(const libhttppp::HttpUrl &base)
{
    return (base.getProtocol() == libhttppp::HttpUrl::HTTPS ? "https://" : "http://") +
           base.getHost() + ":" + std::to_string(base.getPort());
}

// Resolves a possibly-relative reference taken from a page fetched from
// @p baseUrl (an <img src>, <a href>, ... value) into an absolute URL.
// Already-absolute http(s) URLs, protocol-relative ("//host/..."), fragment-
// only ("#..."), and non-fetchable schemes (data:, mailto:, javascript:,
// tel:) are passed through -- the last group deliberately, since resolving
// them against baseUrl would produce a nonsensical http(s) URL. Malformed
// baseUrl (shouldn't happen -- it's always the URL we ourselves just fetched
// the page from) falls back to returning @p ref unchanged.
std::string resolveUrl(const std::string &baseUrl, const std::string &ref)
{
    if (ref.empty() || ref[0] == '#') return ref;
    if (ref.compare(0, 7, "http://") == 0 || ref.compare(0, 8, "https://") == 0)
        return ref;
    if (ref.compare(0, 5, "data:") == 0 || ref.compare(0, 7, "mailto:") == 0 ||
        ref.compare(0, 11, "javascript:") == 0 || ref.compare(0, 4, "tel:") == 0)
        return ref;

    libhttppp::HttpUrl base;
    try {
        base = libhttppp::HttpUrl(baseUrl);
    } catch (...) {
        return ref;
    }

    if (ref.compare(0, 2, "//") == 0)
        return (base.getProtocol() == libhttppp::HttpUrl::HTTPS ? "https:" : "http:") + ref;

    if (ref[0] == '/')
        return urlOrigin(base) + ref;

    // Relative to the directory of the base path (everything up to, and
    // including, its last '/').
    const std::string &basePath = base.getPath();
    std::string combined = basePath.substr(0, basePath.find_last_of('/') + 1) + ref;

    std::vector<std::string> segs;
    std::stringstream ss(combined);
    std::string seg;
    while (std::getline(ss, seg, '/')) {
        if (seg.empty() || seg == ".") continue;
        if (seg == "..") { if (!segs.empty()) segs.pop_back(); }
        else segs.push_back(seg);
    }
    std::string path = "/";
    for (size_t i = 0; i < segs.size(); ++i) {
        path += segs[i];
        if (i + 1 < segs.size()) path += "/";
    }
    return urlOrigin(base) + path;
}

// Walks the whole parsed document (unlike collectTopLevelElements, this
// includes <head> -- external stylesheets/scripts live there) and rewrites
// every document-relative resource/link reference to absolute, in place, so
// the rest of the import pipeline (which only ever looks at the attribute
// value, see e.g. the http(s)-prefix checks around the <link>/<script>
// fetch loop in convertHtmlToWidgetXml, or the plain SetAttribute("src", ...)
// in htmlElementToWidgetXml's Image/Video branches) never has to know the
// page came from a URL rather than being pasted/uploaded already-absolute.
void rewriteRelativeUrls(libhtmlpp::Element *node, const std::string &baseUrl)
{
    while (node) {
        int type = node->getType();
        if (type == libhtmlpp::ScriptEL) {
            auto *sel = static_cast<libhtmlpp::ScriptElement*>(node);
            std::string src = sel->getAtributte("src");
            if (!src.empty()) sel->setAttribute("src", resolveUrl(baseUrl, src));
        } else if (type == libhtmlpp::HtmlEl || type == libhtmlpp::SvgEL ||
                   type == libhtmlpp::TextAreaEL) {
            auto *hel = static_cast<libhtmlpp::HtmlElement*>(node);
            std::string tag = hel->getTagname();
            std::transform(tag.begin(), tag.end(), tag.begin(),
                [](unsigned char c) { return std::tolower(c); });

            if (tag == "img" || tag == "source" || tag == "video" || tag == "audio") {
                std::string src = hel->getAtributte("src");
                if (!src.empty()) hel->setAttribute("src", resolveUrl(baseUrl, src));
            } else if (tag == "link" || tag == "a") {
                std::string href = hel->getAtributte("href");
                if (!href.empty()) hel->setAttribute("href", resolveUrl(baseUrl, href));
            }

            rewriteRelativeUrls(hel->firstChild(), baseUrl);
        }
        node = node->nextElement();
    }
}

// Collects, in document order, the "importable" element children of a given
// node: direct children of <body> (after transparently unwrapping <html>) at
// the document root, or a node's own direct children when called deeper in
@@ -1166,7 +1262,8 @@ bool blogi::htmlimport::convertHtmlToWidgetXml(const std::string &html,
                                               std::string &outGlobalCss,
                                               std::string &outGlobalJs,
                                               const std::set<std::string> *selectedPaths,
                                               ImportStats *outStats)
                                               ImportStats *outStats,
                                               const std::string &baseUrl)
{
    libhtmlpp::HtmlPage page;
    libhtmlpp::HtmlElement index;
@@ -1181,6 +1278,7 @@ bool blogi::htmlimport::convertHtmlToWidgetXml(const std::string &html,
    try {
        page.loadString(index, html);
        root = &index;
        if (!baseUrl.empty()) rewriteRelativeUrls(root, baseUrl);
    } catch (...) {
        // Fallback: single CustomHtml widget with raw HTML
        tinyxml2::XMLElement *w = xmlDoc.NewElement("CustomHtml");
+11 −2
Changes for editor/src/htmlimport.h: 11 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -127,13 +127,22 @@ namespace htmlimport {
     *  the whole document is imported (today's behavior). Selecting both an
     *  ancestor and one of its descendants imports the descendant twice
     *  (once as part of the ancestor's subtree, once standalone); this is
     *  intentionally not deduplicated -- deselect the ancestor instead. */
     *  intentionally not deduplicated -- deselect the ancestor instead.
     *  If @p baseUrl is non-empty (the page was fetched from a URL rather
     *  than pasted/uploaded), document-relative references -- <img src>,
     *  <source src>, <video>/<audio> src, <a href>, <link href>, <script
     *  src> -- are resolved against it before anything else runs, so
     *  relative resources on the original page still work once hosted by
     *  blogi (see rewriteRelativeUrls in htmlimport.cpp). Already-absolute
     *  references and non-http(s) schemes (data:, mailto:, javascript:,
     *  fragment-only "#...") are left untouched. */
    bool convertHtmlToWidgetXml(const std::string &html,
                                std::string &outXml,
                                std::string &outGlobalCss,
                                std::string &outGlobalJs,
                                const std::set<std::string> *selectedPaths = nullptr,
                                ImportStats *outStats = nullptr);
                                ImportStats *outStats = nullptr,
                                const std::string &baseUrl = "");

} // namespace htmlimport
} // namespace blogi
Loading