diff --git a/src/db/Database.cpp b/src/db/Database.cpp
index 536eae0..9e6b235 100644
--- a/src/db/Database.cpp
+++ b/src/db/Database.cpp
@@ -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;
diff --git a/src/db/Database.h b/src/db/Database.h
index 8cc60c6..7665032 100644
--- a/src/db/Database.h
+++ b/src/db/Database.h
@@ -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);
diff --git a/src/markdown/MdParser.cpp b/src/markdown/MdParser.cpp
index 8a48916..d9e281b 100644
--- a/src/markdown/MdParser.cpp
+++ b/src/markdown/MdParser.cpp
@@ -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(
- "
"
- "
SQL: %1
"
- "
Таблицы: files(path,title,mtime,size), tags(name), file_tags, links, properties. Только SELECT / WITH.
"
- "
")
- .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("").arg(bg, border);
- h += QStringLiteral("
");
- h += QStringLiteral("").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("| %2 | ").arg(cs, esc(cols[c]));
- }
- h += QStringLiteral("
");
- int r = 0;
- for (const QVariant &rv : rows) {
- const QVariantList row = rv.toList();
- h += QStringLiteral("").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("NULL").arg(faint)
- : esc(cell);
- h += QStringLiteral("| %2 | ").arg(cs, body);
- }
- h += QStringLiteral("
");
- ++r;
- }
- h += QStringLiteral("
");
- QString foot = rows.size() == 1 ? QStringLiteral("1 строка") : QStringLiteral("%1 строк").arg(rows.size());
- if (truncated)
- foot += QStringLiteral(" (показаны первые)");
- if (!codeId.isEmpty()) {
- foot += QStringLiteral(" • 📋 SQL")
- .arg(codeId, faint);
- }
- h += QStringLiteral("%2
").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("📋 SQL").arg(codeId);
+
+ QString inner;
+ QString foot;
+ if (!err.isEmpty()) {
+ inner = QStringLiteral(
+ ""
+ "
SQL: %1
"
+ "
Таблицы: files(path,title,mtime,size), tags(name), file_tags, links, properties. Только SELECT / WITH.
"
+ "
")
+ .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("");
+ inner += QStringLiteral("");
+ for (int c = 0; c < colCount; ++c) {
+ inner += QStringLiteral("| %1 | ")
+ .arg(esc(cols[c]));
+ }
+ inner += QStringLiteral("
");
+ 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("");
+ 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("NULL");
+ } 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("%2")
+ .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("%2")
+ .arg(fileHrefFor(tp), esc(cell));
+ } else {
+ body = esc(cell);
+ }
+ inner += QStringLiteral("| %2 | ").arg(cs, body);
+ }
+ inner += QStringLiteral("
");
+ }
+ inner += QStringLiteral("
");
+ foot = rows.size() == 1 ? QStringLiteral("1 строка") : QStringLiteral("%1 строк").arg(rows.size());
+ if (truncated)
+ foot += QStringLiteral(" (показаны первые)");
+ }
+
+ QString out = QStringLiteral(
+ ""
+ ""
+ "
%2
"
+ "
%3
")
+ .arg(bubbleBg, copyLink, inner);
+ if (!foot.isEmpty())
+ out += QStringLiteral("
%1
").arg(esc(foot));
+ out += QStringLiteral("
");
+ 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";
diff --git a/tests/db_test.cpp b/tests/db_test.cpp
index 63dfa9c..58274b7 100644
--- a/tests/db_test.cpp
+++ b/tests/db_test.cpp
@@ -4,6 +4,7 @@
#include
#include
#include
+#include
#include
#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("a") || !html.contains(">b"))
+ // title = имя файла без расширения; пузырь кодоблока; имена — ссылки
+ if (!html.contains("a") || !html.contains(">b"))
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("c"))
+ 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") || !h.contains(">b"))
+ if (!h.contains(">a") || !h.contains(">b"))
return fail("xfile table", h.left(300));
- if (h.contains(">c"))
+ if (h.contains(">c") || h.contains(">c"))
return fail("xfile leak isolate");
if (h.contains("тип: хаб"))
return fail("xfile fm leak");