~sql в пузыре кодоблока, кликабельные имена и пути

- renderSqlBlock: та же рамка что у кода (Telegram-пузырь), таблица
  светлым по тёмно-синему, кнопка копирования запроса
- Имя файла (title) — ссылка flora://open (новая вкладка рядом);
  путь — ссылка flora://copycode/spath_N (клик копирует путь)
- Database::filePathForTitle: резолв имени в путь (пусто при
  отсутствии/неоднозначности)
- Тесты пузыря, open/copy ссылок и резолва
This commit is contained in:
2026-09-07 17:03:07 +03:00
parent 8e8cf3ce70
commit 51372ace4e
4 changed files with 196 additions and 75 deletions
+20
View File
@@ -580,6 +580,26 @@ QVariantMap Database::stats()
return out;
}
QString Database::filePathForTitle(const QString &title)
{
const QString t = title.trimmed();
if (!isOpen() || t.isEmpty())
return QString();
QSqlQuery q(m_db);
q.prepare(QStringLiteral("SELECT path FROM files WHERE title = ? LIMIT 2"));
q.addBindValue(t);
if (!q.exec())
return QString();
QString found;
int n = 0;
while (q.next()) {
found = q.value(0).toString();
if (++n > 1)
return QString(); // неоднозначно
}
return n == 1 ? found : QString();
}
QVariantMap Database::fileProperties(const QString &path)
{
QVariantMap out;
+3
View File
@@ -53,6 +53,9 @@ public:
Q_INVOKABLE QVariantMap queryTable(const QString &sql);
Q_INVOKABLE QVariantMap stats();
// Путь файла по названию (title = имя без расширения). Пусто если нет
// или несколько (неоднозначно — ссылку не строим).
Q_INVOKABLE QString filePathForTitle(const QString &title);
Q_INVOKABLE QVariantMap fileProperties(const QString &path);
Q_INVOKABLE QString saveFileProperties(const QString &path, const QVariantMap &props);
+124 -70
View File
@@ -227,74 +227,6 @@ bool isSqlFence(const QString &lang)
return lang.trimmed().compare(QLatin1String("~sql"), Qt::CaseInsensitive) == 0;
}
// ~sql-блок → HTML-таблица с результатом (стиль как у GFM-таблиц, с учётом темы).
// codeId нужен для кнопки «скопировать запрос». Пусто = fallback на обычный код.
QString renderSqlBlock(Database *db, const QString &sql, const QString &codeId)
{
if (!db || !db->isOpen() || !db->queryEnabled())
return QString();
const QVariantMap res = db->queryTable(sql);
const QString err = res.value("error").toString();
QSettings s;
const bool isDark = s.value("ui/theme").toString() == "dark";
const QString bg = isDark ? "#2d333f" : "#f8f9fb";
const QString headerBg = isDark ? "#252a33" : "#eef1f3";
const QString border = isDark ? "#30363d" : "#d0d7de";
const QString headerText = isDark ? "#c9d1d9" : "#24292f";
const QString cellText = isDark ? "#c9d1d9" : "#24292f";
const QString faint = isDark ? "#8b949e" : "#656d76";
if (!err.isEmpty()) {
return QStringLiteral(
"<div style=\"background:rgba(244,63,94,0.15);border:1px solid #e5484d;border-radius:8px;padding:10px 12px;margin:12px 0\">"
"<div style=\"color:#e5484d;font-weight:bold;font-size:12px\">SQL: %1</div>"
"<div style=\"color:%2;font-size:11px;margin-top:6px\">Таблицы: files(path,title,mtime,size), tags(name), file_tags, links, properties. Только SELECT / WITH.</div>"
"</div>")
.arg(esc(err), faint);
}
const QStringList cols = res.value("columns").toStringList();
const QVariantList rows = res.value("rows").toList();
const bool truncated = res.value("truncated").toBool();
QString h = QStringLiteral("<div style=\"background:%1;border:1px solid %2;border-radius:12px;overflow:hidden;margin:16px 0\">").arg(bg, border);
h += QStringLiteral("<table width=\"100%\" cellspacing=\"0\" cellpadding=\"0\">");
h += QStringLiteral("<tr style=\"background:%1\">").arg(headerBg);
const int colCount = cols.size();
for (int c = 0; c < colCount; ++c) {
QString cs = QStringLiteral("padding:8px 12px;text-align:left;color:%1;font-weight:600").arg(headerText);
if (c < colCount - 1)
cs += QStringLiteral(";border-right:1px solid %1").arg(border);
h += QStringLiteral("<th style=\"%1\">%2</th>").arg(cs, esc(cols[c]));
}
h += QStringLiteral("</tr>");
int r = 0;
for (const QVariant &rv : rows) {
const QVariantList row = rv.toList();
h += QStringLiteral("<tr style=\"background:%1\">").arg(bg);
for (int c = 0; c < colCount; ++c) {
const QString cell = c < row.size() ? row[c].toString() : QString();
QString cs = QStringLiteral("padding:8px 12px;text-align:left;color:%1").arg(cellText);
cs += QStringLiteral(";border-top:1px solid %1").arg(border);
if (c < colCount - 1)
cs += QStringLiteral(";border-right:1px solid %1").arg(border);
const QString body = (cell == QLatin1String("NULL"))
? QStringLiteral("<i style=\"color:%1\">NULL</i>").arg(faint)
: esc(cell);
h += QStringLiteral("<td style=\"%1\">%2</td>").arg(cs, body);
}
h += QStringLiteral("</tr>");
++r;
}
h += QStringLiteral("</table></div>");
QString foot = rows.size() == 1 ? QStringLiteral("1 строка") : QStringLiteral("%1 строк").arg(rows.size());
if (truncated)
foot += QStringLiteral(" (показаны первые)");
if (!codeId.isEmpty()) {
foot += QStringLiteral(" • <a href=\"flora://copycode/%1\" style=\"color:%2;text-decoration:none\">📋 SQL</a>")
.arg(codeId, faint);
}
h += QStringLiteral("<div style=\"color:%1;font-size:11px;margin:-8px 0 12px 2px\">%2</div>").arg(faint, foot);
return h;
}
QString fileHrefFor(const QString &path)
{
const QByteArray enc = QUrl::toPercentEncoding(QDir::cleanPath(path));
@@ -506,6 +438,128 @@ MdParser::MdParser(QObject *parent)
m_diffCache.setMaxCost(8);
}
// Ячейка — путь к файлу хранилища? Возвращает абсолютный путь или "".
QString vaultFileForCell(Database *db, const QString &cell)
{
const QString t = cell.trimmed();
if (t.isEmpty() || !db)
return QString();
const QString vault = db->vault();
if (vault.isEmpty())
return QString();
if (QDir::isAbsolutePath(t)) {
const QString clean = QDir::cleanPath(t);
if (clean.startsWith(vault + QLatin1Char('/')) && QFileInfo::exists(clean))
return clean;
return QString();
}
const QString rel = QDir::cleanPath(vault + QLatin1Char('/') + t);
if (QFileInfo::exists(rel))
return rel;
return QString();
}
// ~sql-блок → результат в пузыре кодоблока (как в Telegram).
// Имена файлов — ссылки flora://open (новая вкладка), пути — flora://copycode
// (клик копирует путь). codeId — кнопка «скопировать запрос».
// Пусто = fallback на обычный код.
QString renderSqlBlock(Database *db, MdParser::Impl *impl, const QString &sql, const QString &codeId)
{
if (!db || !db->isOpen() || !db->queryEnabled())
return QString();
const QVariantMap res = db->queryTable(sql);
const QString err = res.value("error").toString();
const QString bubbleBg = "#2e4d6a";
const QString copyLink = codeId.isEmpty() ? QString()
: QStringLiteral("<a href=\"flora://copycode/%1\" class=\"copybtn\" style=\"display:inline-block;background:rgba(255,255,255,0.12);color:#e0e0e0;text-decoration:none;font-size:12px;padding:4px 10px;border-radius:14px;border:1px solid rgba(255,255,255,0.18);font-family:-apple-system,sans-serif;\" title=\"Копировать\">📋 SQL</a>").arg(codeId);
QString inner;
QString foot;
if (!err.isEmpty()) {
inner = QStringLiteral(
"<div style=\"background:rgba(244,63,94,0.30);border:1px solid #e5484d;border-radius:9px;padding:10px 12px\">"
"<div style=\"color:#ffc4c4;font-weight:bold;font-size:12px\">SQL: %1</div>"
"<div style=\"color:#d6dee7;font-size:11px;margin-top:6px\">Таблицы: files(path,title,mtime,size), tags(name), file_tags, links, properties. Только SELECT / WITH.</div>"
"</div>")
.arg(esc(err));
} else {
const QStringList cols = res.value("columns").toStringList();
const QVariantList rows = res.value("rows").toList();
const bool truncated = res.value("truncated").toBool();
const int colCount = cols.size();
inner = QStringLiteral("<table width=\"100%\" cellspacing=\"0\" cellpadding=\"0\" style=\"font-family:-apple-system,sans-serif;font-size:13px\">");
inner += QStringLiteral("<tr>");
for (int c = 0; c < colCount; ++c) {
inner += QStringLiteral("<th style=\"padding:6px 10px;text-align:left;color:#9fb3c8;font-weight:600;font-size:12px;border-bottom:1px solid rgba(255,255,255,0.25)\">%1</th>")
.arg(esc(cols[c]));
}
inner += QStringLiteral("</tr>");
for (const QVariant &rv : rows) {
const QVariantList row = rv.toList();
// Путь строки: ячейка-путь, иначе нечем открывать title
QString rowPath;
for (int c = 0; c < colCount; ++c) {
const QString probe = c < row.size() ? row[c].toString() : QString();
rowPath = vaultFileForCell(db, probe);
if (!rowPath.isEmpty())
break;
}
inner += QStringLiteral("<tr>");
for (int c = 0; c < colCount; ++c) {
const QString cell = c < row.size() ? row[c].toString() : QString();
const QString cs = QStringLiteral("padding:6px 10px;text-align:left;color:#ffffff;border-top:1px solid rgba(255,255,255,0.12);word-break:break-word");
QString body;
if (cell == QLatin1String("NULL")) {
body = QStringLiteral("<i style=\"color:#8ea3b8\">NULL</i>");
} else if (!vaultFileForCell(db, cell).isEmpty()) {
// Путь: клик копирует его в буфер
const QString abs = vaultFileForCell(db, cell);
QString pid;
if (impl) {
pid = QStringLiteral("spath_%1").arg(impl->codeCounter++);
impl->codeBlocks.insert(pid, abs);
}
body = pid.isEmpty()
? esc(cell)
: QStringLiteral("<a href=\"flora://copycode/%1\" style=\"color:#c9d6e2;text-decoration:underline\" title=\"Нажмите, чтобы скопировать путь\">%2</a>")
.arg(pid, esc(cell));
} else if (c < cols.size()
&& cols[c].trimmed().compare(QLatin1String("title"), Qt::CaseInsensitive) == 0) {
// Имя: клик открывает файл в соседней вкладке
QString tp = rowPath;
if (tp.isEmpty())
tp = db->filePathForTitle(cell);
body = tp.isEmpty()
? esc(cell)
: QStringLiteral("<a href=\"%1\" style=\"color:#8fc3ff;text-decoration:underline\" title=\"Открыть в соседней вкладке\">%2</a>")
.arg(fileHrefFor(tp), esc(cell));
} else {
body = esc(cell);
}
inner += QStringLiteral("<td style=\"%1\">%2</td>").arg(cs, body);
}
inner += QStringLiteral("</tr>");
}
inner += QStringLiteral("</table>");
foot = rows.size() == 1 ? QStringLiteral("1 строка") : QStringLiteral("%1 строк").arg(rows.size());
if (truncated)
foot += QStringLiteral(" (показаны первые)");
}
QString out = QStringLiteral(
"<style>.copybtn:hover{background:rgba(255,255,255,0.22) !important;border-color:rgba(255,255,255,0.28) !important;color:#ffffff !important;}"
".copybtn:active{background:rgba(255,255,255,0.30) !important;}</style>"
"<div style=\"background:%1;border-radius:14px;padding:12px 14px 12px 14px;margin:12px 0;max-width:88%%;box-shadow:0 1px 2px rgba(0,0,0,0.18)\">"
"<div style=\"text-align:right;margin-bottom:8px\">%2</div>"
"<div style=\"background:rgba(0,0,0,0.22);border-radius:9px;padding:12px 14px\">%3</div>")
.arg(bubbleBg, copyLink, inner);
if (!foot.isEmpty())
out += QStringLiteral("<div style=\"color:#9fb3c8;font-size:11px;margin-top:8px;font-family:-apple-system,sans-serif\">%1</div>").arg(esc(foot));
out += QStringLiteral("</div>");
return out;
}
MdParser::~MdParser()
{
delete m;
@@ -735,7 +789,7 @@ private:
Database *db = m_impl
? m_impl->databases.value(QDir::cleanPath(m_vaultRoot), nullptr)
: nullptr;
const QString table = renderSqlBlock(db, code, codeId);
const QString table = renderSqlBlock(db, m_impl, code, codeId);
if (!table.isEmpty())
return table;
}
@@ -1054,7 +1108,7 @@ QVariantList MdParser::parseBlocks(const QString &markdown, const QString &vault
}
// ~sql: запрос к базе хранилища, результат — таблицей (как в Просмотре)
if (isSqlFence(lang)) {
const QString table = renderSqlBlock(databaseFor(vaultRoot), code, codeId);
const QString table = renderSqlBlock(databaseFor(vaultRoot), m, code, codeId);
if (!table.isEmpty()) {
QVariantMap block;
block["type"] = "html";
+49 -5
View File
@@ -4,6 +4,7 @@
#include <QTemporaryDir>
#include <QDir>
#include <QFile>
#include <QUrl>
#include <cstdio>
#include "core/Frontmatter.h"
@@ -193,9 +194,11 @@ int main(int argc, char *argv[])
if (blocks[1].toMap().value("type").toString() != "html")
return fail("sql type", blocks[1].toMap().value("type").toString());
const QString html = blocks[1].toMap().value("html").toString();
// title = имя файла без расширения
if (!html.contains("<table") || !html.contains(">a</td>") || !html.contains(">b</td>"))
// title = имя файла без расширения; пузырь кодоблока; имена — ссылки
if (!html.contains("<table") || !html.contains(">a</a>") || !html.contains(">b</a>"))
return fail("sql table", html.left(300));
if (!html.contains("#2e4d6a"))
return fail("sql bubble", html.left(200));
// plain render тоже таблицей
const QString h = p.render("```~sql\nSELECT title FROM files\n```\n", vault, vault);
if (!h.contains("<table"))
@@ -316,16 +319,57 @@ int main(int argc, char *argv[])
if (r.value("rows").toList().value(0).toList().value(0).toString() != "0")
return fail("isolate c");
}
// ~sql внутри index.md видит другие файлы (и не видит изолят)
// filePathForTitle: точное имя → путь, нет/несколько → пусто
{
if (db2.filePathForTitle("a") != v2 + "/a.md")
return fail("title lookup", db2.filePathForTitle("a"));
if (!db2.filePathForTitle("нет-такого").isEmpty())
return fail("title missing");
}
// ~sql внутри index.md видит другие файлы (и не видит изолят).
// Имена — ссылки flora://open, пути — flora://copycode (клик = в буфер).
{
MdParser p;
p.setDatabaseForVault(v2, &db2);
const QVariantList blocks = p.parseBlocks(
"```~sql\nSELECT title, path FROM files WHERE title IN ('a','b','c') ORDER BY title\n```\n",
v2, v2);
if (blocks.size() != 1 || blocks[0].toMap().value("type").toString() != "html")
return fail("xfile type");
const QString h = blocks[0].toMap().value("html").toString();
if (!h.contains("#2e4d6a"))
return fail("xfile bubble", h.left(200));
// имя a открывает файл
const QString openA = "flora://open/" + QString::fromLatin1(QUrl::toPercentEncoding(v2 + "/a.md"));
if (!h.contains(openA))
return fail("xfile open link", h.left(500));
// путь a копируется (spath_* зарегистрирован)
if (!h.contains("flora://copycode/spath_"))
return fail("xfile copy link", h.left(500));
if (p.getCodeBlock("spath_0") != v2 + "/a.md"
&& p.getCodeBlock("spath_1") != v2 + "/a.md"
&& p.getCodeBlock("spath_2") != v2 + "/a.md")
return fail("xfile copy target");
// изолят c тоже в выдаче (запрос его выбрал), но имя так же кликабельно
if (!h.contains(">c</a>"))
return fail("xfile c link", h.left(500));
// title без path в строке резолвится через базу
const QVariantList b2 = p.parseBlocks("```~sql\nSELECT title FROM files WHERE title='b'\n```\n", v2, v2);
const QString h2 = b2[0].toMap().value("html").toString();
const QString openB = "flora://open/" + QString::fromLatin1(QUrl::toPercentEncoding(v2 + "/b.md"));
if (!h2.contains(openB))
return fail("xfile title lookup", h2.left(500));
}
// Рендер index.md целиком: таблица есть, изолята нет, шапки нет
{
MdParser p;
p.setDatabaseForVault(v2, &db2);
QFile f(v2 + "/index.md");
f.open(QIODevice::ReadOnly);
const QString h = p.render(QString::fromUtf8(f.readAll()), v2, v2);
if (!h.contains(">a</td>") || !h.contains(">b</td>"))
if (!h.contains(">a</a>") || !h.contains(">b</a>"))
return fail("xfile table", h.left(300));
if (h.contains(">c</td>"))
if (h.contains(">c</td>") || h.contains(">c</a>"))
return fail("xfile leak isolate");
if (h.contains("тип: хаб"))
return fail("xfile fm leak");