~sql в пузыре кодоблока, кликабельные имена и пути
- renderSqlBlock: та же рамка что у кода (Telegram-пузырь), таблица светлым по тёмно-синему, кнопка копирования запроса - Имя файла (title) — ссылка flora://open (новая вкладка рядом); путь — ссылка flora://copycode/spath_N (клик копирует путь) - Database::filePathForTitle: резолв имени в путь (пусто при отсутствии/неоднозначности) - Тесты пузыря, open/copy ссылок и резолва
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user