Loading editor/html/css/editor.css +29 −6 Changes for editor/html/css/editor.css: 29 added lines, 6 removed lines. Original line number Diff line number Diff line Loading @@ -1768,21 +1768,44 @@ dialog label { border-radius: 4px; } .import-html-candidate { .import-html-node > .import-html-node-header { border-bottom: 1px solid var(--border); } .import-html-node:last-child > .import-html-node-header { border-bottom: none; } .import-html-node.collapsed > .import-html-children { display: none; } .import-html-node-header { display: flex; align-items: baseline; gap: 8px; padding: 6px 8px; border-bottom: 1px solid var(--border); cursor: pointer; } .import-html-candidate:last-child { border-bottom: none; .import-html-node-header:hover { background: var(--bg-hover); } .import-html-candidate:hover { background: var(--bg-hover); .import-html-node-toggle { flex: 0 0 auto; width: 14px; font-size: 10px; text-align: center; color: var(--text-muted); } .import-html-node-toggle.has-children { cursor: pointer; } .import-html-children { padding-left: 16px; } .import-html-candidate-tag { Loading editor/html/js/editor.js +60 −17 Changes for editor/html/js/editor.js: 60 added lines, 17 removed lines. Original line number Diff line number Diff line Loading @@ -578,41 +578,84 @@ }); } function renderImportHtmlCandidates(candidates) { function renderImportHtmlCandidates(nodes) { var container = document.getElementById('import-html-candidates'); container.innerHTML = ''; for (var i = 0; i < candidates.length; i++) { var cand = candidates[i]; var label = document.createElement('label'); label.className = 'import-html-candidate'; for (var i = 0; i < nodes.length; i++) { container.appendChild(renderImportHtmlNode(nodes[i], 0)); } } // Recursively renders one picker-tree node (see ImportNode in // htmlimport.h) plus its children. Children start collapsed so the // first paint matches the old flat top-level-only picker; expanding // lets the user drill down and pick an element at any depth instead of // only whole top-level blocks. Each row's checkbox is independent of // its ancestors/descendants -- checking both a block and something // nested inside it imports that nested part twice (see // convertHtmlToWidgetXml's selectedPaths doc comment). function renderImportHtmlNode(node, depth) { var hasChildren = !!(node.children && node.children.length > 0); var wrap = document.createElement('div'); wrap.className = 'import-html-node' + (hasChildren ? ' collapsed' : ''); var header = document.createElement('label'); header.className = 'import-html-node-header'; var toggle = document.createElement('span'); toggle.className = 'import-html-node-toggle'; if (hasChildren) { toggle.textContent = '▶'; toggle.classList.add('has-children'); toggle.addEventListener('click', function(e) { e.preventDefault(); e.stopPropagation(); var collapsed = wrap.classList.toggle('collapsed'); toggle.textContent = collapsed ? '▶' : '▼'; }); } else { toggle.textContent = '·'; } header.appendChild(toggle); var checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.checked = true; checkbox.setAttribute('data-index', cand.index); label.appendChild(checkbox); checkbox.checked = depth === 0; checkbox.setAttribute('data-path', node.path); header.appendChild(checkbox); var tag = document.createElement('span'); tag.className = 'import-html-candidate-tag'; tag.textContent = cand.tag || '?'; label.appendChild(tag); tag.textContent = node.tag || '?'; header.appendChild(tag); var meta = (cand.css_id ? '#' + cand.css_id : '') + (cand.css_class ? '.' + cand.css_class.split(' ').join('.') : ''); var meta = (node.css_id ? '#' + node.css_id : '') + (node.css_class ? '.' + node.css_class.split(' ').join('.') : ''); if (meta) { var metaEl = document.createElement('span'); metaEl.className = 'import-html-candidate-meta'; metaEl.textContent = meta; label.appendChild(metaEl); header.appendChild(metaEl); } var preview = document.createElement('span'); preview.className = 'import-html-candidate-preview'; preview.textContent = cand.preview || ''; label.appendChild(preview); preview.textContent = node.preview || ''; header.appendChild(preview); container.appendChild(label); wrap.appendChild(header); if (hasChildren) { var childrenWrap = document.createElement('div'); childrenWrap.className = 'import-html-children'; for (var i = 0; i < node.children.length; i++) { childrenWrap.appendChild(renderImportHtmlNode(node.children[i], depth + 1)); } wrap.appendChild(childrenWrap); } return wrap; } function setImportHtmlCandidatesChecked(checked) { Loading Loading @@ -779,7 +822,7 @@ sections = []; var boxes = document.querySelectorAll('#import-html-candidates input[type=checkbox]'); for (var i = 0; i < boxes.length; i++) { if (boxes[i].checked) sections.push(parseInt(boxes[i].getAttribute('data-index'), 10)); if (boxes[i].checked) sections.push(boxes[i].getAttribute('data-path')); } } EditorApi.importHtml(html, null, null, sections).then(function() { Loading editor/src/htmlimport.cpp +103 −49 Changes for editor/src/htmlimport.cpp: 103 added lines, 49 removed lines. Original line number Diff line number Diff line Loading @@ -290,8 +290,8 @@ void blogi::htmlimport::htmlElementToWidgetXml( // stop is only honored for the initial (htmlEl, parentXml) frame -- every // frame pushed onto childFrames below defaults stop to nullptr, so // recursing into an element's own children is never truncated. This lets // callers process a single top-level sibling in isolation (see // convertHtmlToWidgetXml's selectedIndexes handling) without mutating // callers process a single node (at any depth) in isolation (see // convertHtmlToWidgetXml's selectedPaths handling) without mutating // the parsed document tree. struct Frame { libhtmlpp::Element *cur; tinyxml2::XMLElement *pXml; libhtmlpp::Element *stop = nullptr; }; std::stack<Frame> workStack; Loading Loading @@ -832,11 +832,12 @@ void blogi::htmlimport::htmlElementToWidgetXml( namespace { // Collects, in document order, the elements the HTML-import block picker // should offer as top-level candidates: direct children of <body> (after // transparently unwrapping <html>), or top-level fragment elements when the // input has no <html>/<body> wrapper at all. <head> and its children are // always excluded. // 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 // the import tree -- <head> and its children are always excluded. Used both // to build the import picker's tree (buildImportNode, recursively) and to // resolve an ImportNode::path back to an Element* (resolveImportPath). void collectTopLevelElements(libhtmlpp::Element *node, std::vector<libhtmlpp::Element*> &out) { Loading Loading @@ -891,38 +892,13 @@ void collectPlainText(libhtmlpp::Element *node, std::string &out, size_t maxLen) } } } // namespace // How deep the import picker tree recurses -- a safety net against // pathologically deep/malformed input HTML; nodes past this depth are still // importable individually, just not expanded any further in the picker. const int kMaxImportTreeDepth = 12; std::vector<blogi::htmlimport::ImportCandidate> blogi::htmlimport::listTopLevelCandidates(const std::string &html) std::string collapseWhitespace(const std::string &text, size_t maxLen) { std::vector<ImportCandidate> result; libhtmlpp::HtmlPage page; libhtmlpp::HtmlElement index; try { page.loadString(index, html); } catch (...) { return result; } std::vector<libhtmlpp::Element*> topLevel; collectTopLevelElements(&index, topLevel); for (size_t i = 0; i < topLevel.size(); ++i) { libhtmlpp::HtmlElement *el = static_cast<libhtmlpp::HtmlElement*>(topLevel[i]); std::string tag = el->getTagname(); std::transform(tag.begin(), tag.end(), tag.begin(), [](unsigned char c) { return std::tolower(c); }); ImportCandidate cand; cand.index = static_cast<int>(i); cand.tag = tag; cand.cssClass = el->getAtributte("class"); cand.cssId = el->getAtributte("id"); std::string text; collectPlainText(el->firstChild(), text, 160); std::string collapsed; bool lastSpace = false; for (char c : text) { Loading @@ -934,13 +910,93 @@ blogi::htmlimport::listTopLevelCandidates(const std::string &html) lastSpace = false; } } if (collapsed.size() > 80) { collapsed.resize(80); if (collapsed.size() > maxLen) { collapsed.resize(maxLen); collapsed += "..."; } cand.preview = collapsed; return collapsed; } blogi::htmlimport::ImportNode buildImportNode(libhtmlpp::Element *node, const std::string &path, int depth) { libhtmlpp::HtmlElement *el = static_cast<libhtmlpp::HtmlElement*>(node); std::string tag = el->getTagname(); std::transform(tag.begin(), tag.end(), tag.begin(), [](unsigned char c) { return std::tolower(c); }); blogi::htmlimport::ImportNode result; result.path = path; result.tag = tag; result.cssClass = el->getAtributte("class"); result.cssId = el->getAtributte("id"); std::string text; collectPlainText(el->firstChild(), text, 160); result.preview = collapseWhitespace(text, 80); if (depth < kMaxImportTreeDepth) { std::vector<libhtmlpp::Element*> children; collectTopLevelElements(el->firstChild(), children); for (size_t i = 0; i < children.size(); ++i) { result.children.push_back( buildImportNode(children[i], path + "." + std::to_string(i), depth + 1)); } } return result; } // Resolves a dot-joined path (e.g. "0.2.1", see ImportNode::path) back to // the Element* it names, by re-running the exact same per-level traversal // (collectTopLevelElements) buildImportNode used to assign that path in the // first place -- deterministic as long as it's the same HTML string. libhtmlpp::Element *resolveImportPath(libhtmlpp::Element *root, const std::string &path) { std::vector<libhtmlpp::Element*> level; collectTopLevelElements(root, level); libhtmlpp::Element *cur = nullptr; size_t start = 0; while (start <= path.size()) { size_t dot = path.find('.', start); std::string part = path.substr(start, dot == std::string::npos ? std::string::npos : dot - start); int idx = -1; try { idx = std::stoi(part); } catch (...) { return nullptr; } if (idx < 0 || static_cast<size_t>(idx) >= level.size()) return nullptr; cur = level[idx]; if (dot == std::string::npos) break; level.clear(); collectTopLevelElements(static_cast<libhtmlpp::HtmlElement*>(cur)->firstChild(), level); start = dot + 1; } return cur; } } // namespace std::vector<blogi::htmlimport::ImportNode> blogi::htmlimport::buildImportTree(const std::string &html) { std::vector<ImportNode> result; libhtmlpp::HtmlPage page; libhtmlpp::HtmlElement index; try { page.loadString(index, html); } catch (...) { return result; } std::vector<libhtmlpp::Element*> topLevel; collectTopLevelElements(&index, topLevel); result.push_back(cand); for (size_t i = 0; i < topLevel.size(); ++i) { result.push_back(buildImportNode(topLevel[i], std::to_string(i), 0)); } return result; Loading @@ -949,7 +1005,7 @@ blogi::htmlimport::listTopLevelCandidates(const std::string &html) bool blogi::htmlimport::convertHtmlToWidgetXml(const std::string &html, std::string &outXml, std::string &outGlobalCss, const std::set<int> *selectedIndexes) const std::set<std::string> *selectedPaths) { libhtmlpp::HtmlPage page; libhtmlpp::HtmlElement index; Loading Loading @@ -1078,13 +1134,11 @@ bool blogi::htmlimport::convertHtmlToWidgetXml(const std::string &html, } if (root) { if (selectedIndexes) { std::vector<libhtmlpp::Element*> topLevel; collectTopLevelElements(root, topLevel); for (size_t i = 0; i < topLevel.size(); ++i) { if (selectedIndexes->count(static_cast<int>(i))) { htmlElementToWidgetXml(topLevel[i], xmlDoc, xmlRoot, stylesheet, topLevel[i]->nextElement()); if (selectedPaths) { for (const auto &path : *selectedPaths) { libhtmlpp::Element *el = resolveImportPath(root, path); if (el) { htmlElementToWidgetXml(el, xmlDoc, xmlRoot, stylesheet, el->nextElement()); } } } else { Loading editor/src/htmlimport.h +22 −15 Changes for editor/src/htmlimport.h: 22 added lines, 15 removed lines. Original line number Diff line number Diff line Loading @@ -67,33 +67,40 @@ namespace htmlimport { const libhtmlpp::CSSStyleSheet &sheet, libhtmlpp::Element *stopBefore = nullptr); /** One HTML-import "top-level candidate": a direct child of <body> (after * transparently unwrapping <html>/<head>), or a top-level fragment * element when the input has no <html>/<body> wrapper at all. Used by * the import dialog's block picker so the user can choose which * top-level blocks to bring in before conversion. */ struct ImportCandidate { int index = 0; /** One node of the HTML-import block picker's tree: an element reachable * from the document root by transparently unwrapping <html>/<body> and * dropping <head> (see collectTopLevelElements in htmlimport.cpp), * recursively down through its own children. `path` is a dot-joined * sequence of per-level ordinal indices (e.g. "0.2.1") that uniquely * and deterministically identifies this node for a given HTML string -- * see resolveImportPath, which uses the identical traversal rule to * turn a path back into an Element* on a fresh parse of the same HTML. */ struct ImportNode { std::string path; std::string tag; std::string cssClass; std::string cssId; std::string preview; std::vector<ImportNode> children; }; /** Parse @p html and list its top-level candidates (see ImportCandidate) * without converting anything to widget XML. */ std::vector<ImportCandidate> listTopLevelCandidates(const std::string &html); /** Parse @p html and build its full import tree (see ImportNode) without * converting anything to widget XML. */ std::vector<ImportNode> buildImportTree(const std::string &html); /** Convert raw HTML string to a widget XML string (<WebEditor>...). * Returns true on success, writes the XML to @p outXml and optional * global CSS to @p outGlobalCss. If @p selectedIndexes is non-null, only * the top-level candidates (see listTopLevelCandidates) whose index is * in the set are converted/imported; otherwise every top-level block is * imported (today's behavior). */ * global CSS to @p outGlobalCss. If @p selectedPaths is non-null, only * the nodes (see ImportNode::path, at any depth) named in the set are * converted/imported, each as its own top-level element -- otherwise * 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. */ bool convertHtmlToWidgetXml(const std::string &html, std::string &outXml, std::string &outGlobalCss, const std::set<int> *selectedIndexes = nullptr); const std::set<std::string> *selectedPaths = nullptr); } // namespace htmlimport } // namespace blogi editor/src/webedit_api.cpp +32 −18 Changes for editor/src/webedit_api.cpp: 32 added lines, 18 removed lines. Original line number Diff line number Diff line Loading @@ -5061,9 +5061,28 @@ void webedit::Api::handleMediaBrowse(libhttppp::HttpRequest &curreq, // ====================== HTML Import/Export ====================== // Recursively serializes one ImportNode (and its children) for the // parse-html response -- same shape as the document tree JSON // (handleGetDocument's "tree", see serializeTree) but with "path" instead // of "uuid", since these nodes don't have widget instances yet. static json_object *importNodeToJson(const blogi::htmlimport::ImportNode &node) { json_object *obj = json_object_new_object(); json_object_object_add(obj, "path", json_object_new_string(node.path.c_str())); json_object_object_add(obj, "tag", json_object_new_string(node.tag.c_str())); json_object_object_add(obj, "css_class", json_object_new_string(node.cssClass.c_str())); json_object_object_add(obj, "css_id", json_object_new_string(node.cssId.c_str())); json_object_object_add(obj, "preview", json_object_new_string(node.preview.c_str())); json_object *children = json_object_new_array(); for (const auto &child : node.children) { json_object_array_add(children, importNodeToJson(child)); } json_object_object_add(obj, "children", children); return obj; } // Parses html without importing anything -- lets the import dialog show a // picker of top-level candidates (see listTopLevelCandidates) so the user // can choose which blocks to bring in before handleImportHtml commits them. // picker tree (see buildImportTree) so the user can choose which blocks/ // elements, at any depth, to bring in before handleImportHtml commits them. void webedit::Api::handleParseHtml(libhttppp::HttpRequest &curreq, const std::string &sessionid) { (void)sessionid; Loading @@ -5083,21 +5102,15 @@ void webedit::Api::handleParseHtml(libhttppp::HttpRequest &curreq, return; } auto candidates = blogi::htmlimport::listTopLevelCandidates(html); auto tree = blogi::htmlimport::buildImportTree(html); std::string widgetXml, globalCss; blogi::htmlimport::convertHtmlToWidgetXml(html, widgetXml, globalCss); json_object *resp = json_object_new_object(); json_object *arr = json_object_new_array(); for (const auto &cand : candidates) { json_object *c = json_object_new_object(); json_object_object_add(c, "index", json_object_new_int(cand.index)); json_object_object_add(c, "tag", json_object_new_string(cand.tag.c_str())); json_object_object_add(c, "css_class", json_object_new_string(cand.cssClass.c_str())); json_object_object_add(c, "css_id", json_object_new_string(cand.cssId.c_str())); json_object_object_add(c, "preview", json_object_new_string(cand.preview.c_str())); json_object_array_add(arr, c); for (const auto &node : tree) { json_object_array_add(arr, importNodeToJson(node)); } json_object_object_add(resp, "candidates", arr); if (!globalCss.empty()) Loading Loading @@ -5133,24 +5146,25 @@ void webedit::Api::handleImportHtml(libhttppp::HttpRequest &curreq, return; } // Optional: only import the top-level candidates the import dialog's // block picker selected (see handleParseHtml/listTopLevelCandidates). // Absent/empty means "import everything" (today's behavior). std::set<int> selectedIndexes; // Optional: only import the nodes (at any depth, see ImportNode::path) // the import dialog's picker tree selected (see handleParseHtml/ // buildImportTree). Absent/empty means "import everything" (today's // behavior). std::set<std::string> selectedPaths; bool haveSelection = false; if (sectionsObj && json_object_is_type(sectionsObj, json_type_array)) { haveSelection = true; size_t n = json_object_array_length(sectionsObj); for (size_t i = 0; i < n; ++i) { json_object *idxObj = json_object_array_get_idx(sectionsObj, i); if (idxObj) selectedIndexes.insert(json_object_get_int(idxObj)); json_object *pathObj = json_object_array_get_idx(sectionsObj, i); if (pathObj) selectedPaths.insert(json_object_get_string(pathObj)); } } // Convert HTML to widget XML via htmlimport std::string widgetXml, globalCss; if (!blogi::htmlimport::convertHtmlToWidgetXml(html, widgetXml, globalCss, haveSelection ? &selectedIndexes : nullptr)) { haveSelection ? &selectedPaths : nullptr)) { json_object_put(req); sendJsonError(curreq, 500, "HTML conversion failed"); return; Loading Loading
editor/html/css/editor.css +29 −6 Changes for editor/html/css/editor.css: 29 added lines, 6 removed lines. Original line number Diff line number Diff line Loading @@ -1768,21 +1768,44 @@ dialog label { border-radius: 4px; } .import-html-candidate { .import-html-node > .import-html-node-header { border-bottom: 1px solid var(--border); } .import-html-node:last-child > .import-html-node-header { border-bottom: none; } .import-html-node.collapsed > .import-html-children { display: none; } .import-html-node-header { display: flex; align-items: baseline; gap: 8px; padding: 6px 8px; border-bottom: 1px solid var(--border); cursor: pointer; } .import-html-candidate:last-child { border-bottom: none; .import-html-node-header:hover { background: var(--bg-hover); } .import-html-candidate:hover { background: var(--bg-hover); .import-html-node-toggle { flex: 0 0 auto; width: 14px; font-size: 10px; text-align: center; color: var(--text-muted); } .import-html-node-toggle.has-children { cursor: pointer; } .import-html-children { padding-left: 16px; } .import-html-candidate-tag { Loading
editor/html/js/editor.js +60 −17 Changes for editor/html/js/editor.js: 60 added lines, 17 removed lines. Original line number Diff line number Diff line Loading @@ -578,41 +578,84 @@ }); } function renderImportHtmlCandidates(candidates) { function renderImportHtmlCandidates(nodes) { var container = document.getElementById('import-html-candidates'); container.innerHTML = ''; for (var i = 0; i < candidates.length; i++) { var cand = candidates[i]; var label = document.createElement('label'); label.className = 'import-html-candidate'; for (var i = 0; i < nodes.length; i++) { container.appendChild(renderImportHtmlNode(nodes[i], 0)); } } // Recursively renders one picker-tree node (see ImportNode in // htmlimport.h) plus its children. Children start collapsed so the // first paint matches the old flat top-level-only picker; expanding // lets the user drill down and pick an element at any depth instead of // only whole top-level blocks. Each row's checkbox is independent of // its ancestors/descendants -- checking both a block and something // nested inside it imports that nested part twice (see // convertHtmlToWidgetXml's selectedPaths doc comment). function renderImportHtmlNode(node, depth) { var hasChildren = !!(node.children && node.children.length > 0); var wrap = document.createElement('div'); wrap.className = 'import-html-node' + (hasChildren ? ' collapsed' : ''); var header = document.createElement('label'); header.className = 'import-html-node-header'; var toggle = document.createElement('span'); toggle.className = 'import-html-node-toggle'; if (hasChildren) { toggle.textContent = '▶'; toggle.classList.add('has-children'); toggle.addEventListener('click', function(e) { e.preventDefault(); e.stopPropagation(); var collapsed = wrap.classList.toggle('collapsed'); toggle.textContent = collapsed ? '▶' : '▼'; }); } else { toggle.textContent = '·'; } header.appendChild(toggle); var checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.checked = true; checkbox.setAttribute('data-index', cand.index); label.appendChild(checkbox); checkbox.checked = depth === 0; checkbox.setAttribute('data-path', node.path); header.appendChild(checkbox); var tag = document.createElement('span'); tag.className = 'import-html-candidate-tag'; tag.textContent = cand.tag || '?'; label.appendChild(tag); tag.textContent = node.tag || '?'; header.appendChild(tag); var meta = (cand.css_id ? '#' + cand.css_id : '') + (cand.css_class ? '.' + cand.css_class.split(' ').join('.') : ''); var meta = (node.css_id ? '#' + node.css_id : '') + (node.css_class ? '.' + node.css_class.split(' ').join('.') : ''); if (meta) { var metaEl = document.createElement('span'); metaEl.className = 'import-html-candidate-meta'; metaEl.textContent = meta; label.appendChild(metaEl); header.appendChild(metaEl); } var preview = document.createElement('span'); preview.className = 'import-html-candidate-preview'; preview.textContent = cand.preview || ''; label.appendChild(preview); preview.textContent = node.preview || ''; header.appendChild(preview); container.appendChild(label); wrap.appendChild(header); if (hasChildren) { var childrenWrap = document.createElement('div'); childrenWrap.className = 'import-html-children'; for (var i = 0; i < node.children.length; i++) { childrenWrap.appendChild(renderImportHtmlNode(node.children[i], depth + 1)); } wrap.appendChild(childrenWrap); } return wrap; } function setImportHtmlCandidatesChecked(checked) { Loading Loading @@ -779,7 +822,7 @@ sections = []; var boxes = document.querySelectorAll('#import-html-candidates input[type=checkbox]'); for (var i = 0; i < boxes.length; i++) { if (boxes[i].checked) sections.push(parseInt(boxes[i].getAttribute('data-index'), 10)); if (boxes[i].checked) sections.push(boxes[i].getAttribute('data-path')); } } EditorApi.importHtml(html, null, null, sections).then(function() { Loading
editor/src/htmlimport.cpp +103 −49 Changes for editor/src/htmlimport.cpp: 103 added lines, 49 removed lines. Original line number Diff line number Diff line Loading @@ -290,8 +290,8 @@ void blogi::htmlimport::htmlElementToWidgetXml( // stop is only honored for the initial (htmlEl, parentXml) frame -- every // frame pushed onto childFrames below defaults stop to nullptr, so // recursing into an element's own children is never truncated. This lets // callers process a single top-level sibling in isolation (see // convertHtmlToWidgetXml's selectedIndexes handling) without mutating // callers process a single node (at any depth) in isolation (see // convertHtmlToWidgetXml's selectedPaths handling) without mutating // the parsed document tree. struct Frame { libhtmlpp::Element *cur; tinyxml2::XMLElement *pXml; libhtmlpp::Element *stop = nullptr; }; std::stack<Frame> workStack; Loading Loading @@ -832,11 +832,12 @@ void blogi::htmlimport::htmlElementToWidgetXml( namespace { // Collects, in document order, the elements the HTML-import block picker // should offer as top-level candidates: direct children of <body> (after // transparently unwrapping <html>), or top-level fragment elements when the // input has no <html>/<body> wrapper at all. <head> and its children are // always excluded. // 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 // the import tree -- <head> and its children are always excluded. Used both // to build the import picker's tree (buildImportNode, recursively) and to // resolve an ImportNode::path back to an Element* (resolveImportPath). void collectTopLevelElements(libhtmlpp::Element *node, std::vector<libhtmlpp::Element*> &out) { Loading Loading @@ -891,38 +892,13 @@ void collectPlainText(libhtmlpp::Element *node, std::string &out, size_t maxLen) } } } // namespace // How deep the import picker tree recurses -- a safety net against // pathologically deep/malformed input HTML; nodes past this depth are still // importable individually, just not expanded any further in the picker. const int kMaxImportTreeDepth = 12; std::vector<blogi::htmlimport::ImportCandidate> blogi::htmlimport::listTopLevelCandidates(const std::string &html) std::string collapseWhitespace(const std::string &text, size_t maxLen) { std::vector<ImportCandidate> result; libhtmlpp::HtmlPage page; libhtmlpp::HtmlElement index; try { page.loadString(index, html); } catch (...) { return result; } std::vector<libhtmlpp::Element*> topLevel; collectTopLevelElements(&index, topLevel); for (size_t i = 0; i < topLevel.size(); ++i) { libhtmlpp::HtmlElement *el = static_cast<libhtmlpp::HtmlElement*>(topLevel[i]); std::string tag = el->getTagname(); std::transform(tag.begin(), tag.end(), tag.begin(), [](unsigned char c) { return std::tolower(c); }); ImportCandidate cand; cand.index = static_cast<int>(i); cand.tag = tag; cand.cssClass = el->getAtributte("class"); cand.cssId = el->getAtributte("id"); std::string text; collectPlainText(el->firstChild(), text, 160); std::string collapsed; bool lastSpace = false; for (char c : text) { Loading @@ -934,13 +910,93 @@ blogi::htmlimport::listTopLevelCandidates(const std::string &html) lastSpace = false; } } if (collapsed.size() > 80) { collapsed.resize(80); if (collapsed.size() > maxLen) { collapsed.resize(maxLen); collapsed += "..."; } cand.preview = collapsed; return collapsed; } blogi::htmlimport::ImportNode buildImportNode(libhtmlpp::Element *node, const std::string &path, int depth) { libhtmlpp::HtmlElement *el = static_cast<libhtmlpp::HtmlElement*>(node); std::string tag = el->getTagname(); std::transform(tag.begin(), tag.end(), tag.begin(), [](unsigned char c) { return std::tolower(c); }); blogi::htmlimport::ImportNode result; result.path = path; result.tag = tag; result.cssClass = el->getAtributte("class"); result.cssId = el->getAtributte("id"); std::string text; collectPlainText(el->firstChild(), text, 160); result.preview = collapseWhitespace(text, 80); if (depth < kMaxImportTreeDepth) { std::vector<libhtmlpp::Element*> children; collectTopLevelElements(el->firstChild(), children); for (size_t i = 0; i < children.size(); ++i) { result.children.push_back( buildImportNode(children[i], path + "." + std::to_string(i), depth + 1)); } } return result; } // Resolves a dot-joined path (e.g. "0.2.1", see ImportNode::path) back to // the Element* it names, by re-running the exact same per-level traversal // (collectTopLevelElements) buildImportNode used to assign that path in the // first place -- deterministic as long as it's the same HTML string. libhtmlpp::Element *resolveImportPath(libhtmlpp::Element *root, const std::string &path) { std::vector<libhtmlpp::Element*> level; collectTopLevelElements(root, level); libhtmlpp::Element *cur = nullptr; size_t start = 0; while (start <= path.size()) { size_t dot = path.find('.', start); std::string part = path.substr(start, dot == std::string::npos ? std::string::npos : dot - start); int idx = -1; try { idx = std::stoi(part); } catch (...) { return nullptr; } if (idx < 0 || static_cast<size_t>(idx) >= level.size()) return nullptr; cur = level[idx]; if (dot == std::string::npos) break; level.clear(); collectTopLevelElements(static_cast<libhtmlpp::HtmlElement*>(cur)->firstChild(), level); start = dot + 1; } return cur; } } // namespace std::vector<blogi::htmlimport::ImportNode> blogi::htmlimport::buildImportTree(const std::string &html) { std::vector<ImportNode> result; libhtmlpp::HtmlPage page; libhtmlpp::HtmlElement index; try { page.loadString(index, html); } catch (...) { return result; } std::vector<libhtmlpp::Element*> topLevel; collectTopLevelElements(&index, topLevel); result.push_back(cand); for (size_t i = 0; i < topLevel.size(); ++i) { result.push_back(buildImportNode(topLevel[i], std::to_string(i), 0)); } return result; Loading @@ -949,7 +1005,7 @@ blogi::htmlimport::listTopLevelCandidates(const std::string &html) bool blogi::htmlimport::convertHtmlToWidgetXml(const std::string &html, std::string &outXml, std::string &outGlobalCss, const std::set<int> *selectedIndexes) const std::set<std::string> *selectedPaths) { libhtmlpp::HtmlPage page; libhtmlpp::HtmlElement index; Loading Loading @@ -1078,13 +1134,11 @@ bool blogi::htmlimport::convertHtmlToWidgetXml(const std::string &html, } if (root) { if (selectedIndexes) { std::vector<libhtmlpp::Element*> topLevel; collectTopLevelElements(root, topLevel); for (size_t i = 0; i < topLevel.size(); ++i) { if (selectedIndexes->count(static_cast<int>(i))) { htmlElementToWidgetXml(topLevel[i], xmlDoc, xmlRoot, stylesheet, topLevel[i]->nextElement()); if (selectedPaths) { for (const auto &path : *selectedPaths) { libhtmlpp::Element *el = resolveImportPath(root, path); if (el) { htmlElementToWidgetXml(el, xmlDoc, xmlRoot, stylesheet, el->nextElement()); } } } else { Loading
editor/src/htmlimport.h +22 −15 Changes for editor/src/htmlimport.h: 22 added lines, 15 removed lines. Original line number Diff line number Diff line Loading @@ -67,33 +67,40 @@ namespace htmlimport { const libhtmlpp::CSSStyleSheet &sheet, libhtmlpp::Element *stopBefore = nullptr); /** One HTML-import "top-level candidate": a direct child of <body> (after * transparently unwrapping <html>/<head>), or a top-level fragment * element when the input has no <html>/<body> wrapper at all. Used by * the import dialog's block picker so the user can choose which * top-level blocks to bring in before conversion. */ struct ImportCandidate { int index = 0; /** One node of the HTML-import block picker's tree: an element reachable * from the document root by transparently unwrapping <html>/<body> and * dropping <head> (see collectTopLevelElements in htmlimport.cpp), * recursively down through its own children. `path` is a dot-joined * sequence of per-level ordinal indices (e.g. "0.2.1") that uniquely * and deterministically identifies this node for a given HTML string -- * see resolveImportPath, which uses the identical traversal rule to * turn a path back into an Element* on a fresh parse of the same HTML. */ struct ImportNode { std::string path; std::string tag; std::string cssClass; std::string cssId; std::string preview; std::vector<ImportNode> children; }; /** Parse @p html and list its top-level candidates (see ImportCandidate) * without converting anything to widget XML. */ std::vector<ImportCandidate> listTopLevelCandidates(const std::string &html); /** Parse @p html and build its full import tree (see ImportNode) without * converting anything to widget XML. */ std::vector<ImportNode> buildImportTree(const std::string &html); /** Convert raw HTML string to a widget XML string (<WebEditor>...). * Returns true on success, writes the XML to @p outXml and optional * global CSS to @p outGlobalCss. If @p selectedIndexes is non-null, only * the top-level candidates (see listTopLevelCandidates) whose index is * in the set are converted/imported; otherwise every top-level block is * imported (today's behavior). */ * global CSS to @p outGlobalCss. If @p selectedPaths is non-null, only * the nodes (see ImportNode::path, at any depth) named in the set are * converted/imported, each as its own top-level element -- otherwise * 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. */ bool convertHtmlToWidgetXml(const std::string &html, std::string &outXml, std::string &outGlobalCss, const std::set<int> *selectedIndexes = nullptr); const std::set<std::string> *selectedPaths = nullptr); } // namespace htmlimport } // namespace blogi
editor/src/webedit_api.cpp +32 −18 Changes for editor/src/webedit_api.cpp: 32 added lines, 18 removed lines. Original line number Diff line number Diff line Loading @@ -5061,9 +5061,28 @@ void webedit::Api::handleMediaBrowse(libhttppp::HttpRequest &curreq, // ====================== HTML Import/Export ====================== // Recursively serializes one ImportNode (and its children) for the // parse-html response -- same shape as the document tree JSON // (handleGetDocument's "tree", see serializeTree) but with "path" instead // of "uuid", since these nodes don't have widget instances yet. static json_object *importNodeToJson(const blogi::htmlimport::ImportNode &node) { json_object *obj = json_object_new_object(); json_object_object_add(obj, "path", json_object_new_string(node.path.c_str())); json_object_object_add(obj, "tag", json_object_new_string(node.tag.c_str())); json_object_object_add(obj, "css_class", json_object_new_string(node.cssClass.c_str())); json_object_object_add(obj, "css_id", json_object_new_string(node.cssId.c_str())); json_object_object_add(obj, "preview", json_object_new_string(node.preview.c_str())); json_object *children = json_object_new_array(); for (const auto &child : node.children) { json_object_array_add(children, importNodeToJson(child)); } json_object_object_add(obj, "children", children); return obj; } // Parses html without importing anything -- lets the import dialog show a // picker of top-level candidates (see listTopLevelCandidates) so the user // can choose which blocks to bring in before handleImportHtml commits them. // picker tree (see buildImportTree) so the user can choose which blocks/ // elements, at any depth, to bring in before handleImportHtml commits them. void webedit::Api::handleParseHtml(libhttppp::HttpRequest &curreq, const std::string &sessionid) { (void)sessionid; Loading @@ -5083,21 +5102,15 @@ void webedit::Api::handleParseHtml(libhttppp::HttpRequest &curreq, return; } auto candidates = blogi::htmlimport::listTopLevelCandidates(html); auto tree = blogi::htmlimport::buildImportTree(html); std::string widgetXml, globalCss; blogi::htmlimport::convertHtmlToWidgetXml(html, widgetXml, globalCss); json_object *resp = json_object_new_object(); json_object *arr = json_object_new_array(); for (const auto &cand : candidates) { json_object *c = json_object_new_object(); json_object_object_add(c, "index", json_object_new_int(cand.index)); json_object_object_add(c, "tag", json_object_new_string(cand.tag.c_str())); json_object_object_add(c, "css_class", json_object_new_string(cand.cssClass.c_str())); json_object_object_add(c, "css_id", json_object_new_string(cand.cssId.c_str())); json_object_object_add(c, "preview", json_object_new_string(cand.preview.c_str())); json_object_array_add(arr, c); for (const auto &node : tree) { json_object_array_add(arr, importNodeToJson(node)); } json_object_object_add(resp, "candidates", arr); if (!globalCss.empty()) Loading Loading @@ -5133,24 +5146,25 @@ void webedit::Api::handleImportHtml(libhttppp::HttpRequest &curreq, return; } // Optional: only import the top-level candidates the import dialog's // block picker selected (see handleParseHtml/listTopLevelCandidates). // Absent/empty means "import everything" (today's behavior). std::set<int> selectedIndexes; // Optional: only import the nodes (at any depth, see ImportNode::path) // the import dialog's picker tree selected (see handleParseHtml/ // buildImportTree). Absent/empty means "import everything" (today's // behavior). std::set<std::string> selectedPaths; bool haveSelection = false; if (sectionsObj && json_object_is_type(sectionsObj, json_type_array)) { haveSelection = true; size_t n = json_object_array_length(sectionsObj); for (size_t i = 0; i < n; ++i) { json_object *idxObj = json_object_array_get_idx(sectionsObj, i); if (idxObj) selectedIndexes.insert(json_object_get_int(idxObj)); json_object *pathObj = json_object_array_get_idx(sectionsObj, i); if (pathObj) selectedPaths.insert(json_object_get_string(pathObj)); } } // Convert HTML to widget XML via htmlimport std::string widgetXml, globalCss; if (!blogi::htmlimport::convertHtmlToWidgetXml(html, widgetXml, globalCss, haveSelection ? &selectedIndexes : nullptr)) { haveSelection ? &selectedPaths : nullptr)) { json_object_put(req); sendJsonError(curreq, 500, "HTML conversion failed"); return; Loading