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("").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("").arg(cs, body); - } - h += QStringLiteral(""); - ++r; - } - h += QStringLiteral("
%2
%2
"); - 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("") + .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("").arg(cs, body); + } + inner += QStringLiteral(""); + } + inner += QStringLiteral("
%1
%2
"); + 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");