2026-09-11 20:11:10 +01:00
|
|
|
///////////////////////////////////////////////////////////////////////////////////
|
|
|
|
|
// Copyright (C) 2026 Jon Beniston, M7RCE <jon@beniston.com> //
|
|
|
|
|
// Some code by AI //
|
|
|
|
|
// //
|
|
|
|
|
// This program is free software; you can redistribute it and/or modify //
|
|
|
|
|
// it under the terms of the GNU General Public License as published by //
|
|
|
|
|
// the Free Software Foundation as version 3 of the License, or //
|
|
|
|
|
// (at your option) any later version. //
|
|
|
|
|
// //
|
|
|
|
|
// This program is distributed in the hope that it will be useful, //
|
|
|
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of //
|
|
|
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the //
|
|
|
|
|
// GNU General Public License V3 for more details. //
|
|
|
|
|
// //
|
|
|
|
|
// You should have received a copy of the GNU General Public License //
|
|
|
|
|
// along with this program. If not, see <http://www.gnu.org/licenses/>. //
|
|
|
|
|
///////////////////////////////////////////////////////////////////////////////////
|
|
|
|
|
|
|
|
|
|
#include <QDebug>
|
|
|
|
|
#include <QDir>
|
|
|
|
|
#include <QFile>
|
|
|
|
|
#include <QJsonArray>
|
|
|
|
|
#include <QRegularExpression>
|
|
|
|
|
|
|
|
|
|
#include "maincore.h"
|
|
|
|
|
#include "device/deviceenumerator.h"
|
|
|
|
|
#include "plugin/pluginmanager.h"
|
|
|
|
|
#include "plugin/plugininterface.h"
|
|
|
|
|
|
|
|
|
|
#include "mcpdocs.h"
|
|
|
|
|
|
|
|
|
|
MCPDocs::MCPDocs() :
|
|
|
|
|
m_loaded(false)
|
|
|
|
|
{
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const QList<MCPDocs::Doc>& MCPDocs::list()
|
|
|
|
|
{
|
|
|
|
|
load();
|
|
|
|
|
return m_docs;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const MCPDocs::Doc *MCPDocs::find(const QString& typeIn, const QString& kindIn)
|
|
|
|
|
{
|
|
|
|
|
load();
|
|
|
|
|
QString type = normalize(typeIn);
|
|
|
|
|
QString kind = kindIn.trimmed().toLower();
|
|
|
|
|
|
|
|
|
|
// Exact id first, then displayed name, then readme title, so that "ADS-B" or "RTL-SDR" also resolve
|
|
|
|
|
for (int pass = 0; pass < 3; pass++)
|
|
|
|
|
{
|
|
|
|
|
for (const Doc& doc : m_docs)
|
|
|
|
|
{
|
|
|
|
|
if (!kind.isEmpty() && (doc.kind != kind)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool hit;
|
|
|
|
|
|
|
|
|
|
if (pass == 0)
|
|
|
|
|
{
|
|
|
|
|
hit = normalize(doc.id) == type;
|
|
|
|
|
|
|
|
|
|
for (const QString& alias : doc.aliases) {
|
|
|
|
|
hit = hit || (normalize(alias) == type);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
else if (pass == 1)
|
|
|
|
|
{
|
|
|
|
|
hit = normalize(doc.name) == type;
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
hit = normalize(doc.title).startsWith(type);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (hit) {
|
|
|
|
|
return &doc;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nullptr;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
QString MCPDocs::text(const Doc& doc, const QString& section)
|
|
|
|
|
{
|
|
|
|
|
QString markdown = toMarkdown(readFile(doc.path));
|
|
|
|
|
|
|
|
|
|
if (section.trimmed().isEmpty()) {
|
|
|
|
|
return markdown;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Return the section whose heading contains the requested text, up to the next heading
|
|
|
|
|
// of the same or a higher level
|
|
|
|
|
QString wanted = section.trimmed().toLower();
|
|
|
|
|
QStringList lines = markdown.split('\n');
|
|
|
|
|
int start = -1;
|
|
|
|
|
int startLevel = 0;
|
|
|
|
|
|
|
|
|
|
for (int i = 0; i < lines.size(); i++)
|
|
|
|
|
{
|
|
|
|
|
int level;
|
|
|
|
|
QString heading = headingText(lines[i], level);
|
|
|
|
|
|
|
|
|
|
if (level == 0) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (start < 0)
|
|
|
|
|
{
|
|
|
|
|
if (heading.toLower().contains(wanted))
|
|
|
|
|
{
|
|
|
|
|
start = i;
|
|
|
|
|
startLevel = level;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
else if (level <= startLevel)
|
|
|
|
|
{
|
|
|
|
|
return lines.mid(start, i - start).join('\n').trimmed();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (start >= 0) {
|
|
|
|
|
return lines.mid(start).join('\n').trimmed();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return QString();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
QJsonObject MCPDocs::index()
|
|
|
|
|
{
|
|
|
|
|
load();
|
|
|
|
|
QJsonArray docs;
|
|
|
|
|
|
|
|
|
|
for (const Doc& doc : m_docs)
|
|
|
|
|
{
|
|
|
|
|
QJsonObject d;
|
|
|
|
|
d["kind"] = doc.kind;
|
|
|
|
|
d["id"] = doc.id;
|
|
|
|
|
|
|
|
|
|
if (!doc.aliases.isEmpty()) {
|
|
|
|
|
d["aliases"] = QJsonArray::fromStringList(doc.aliases);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!doc.direction.isEmpty()) {
|
|
|
|
|
d["direction"] = doc.direction;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
d["name"] = doc.name;
|
|
|
|
|
d["title"] = doc.title;
|
|
|
|
|
d["uri"] = uri(doc);
|
|
|
|
|
d["headings"] = QJsonArray::fromStringList(doc.headings);
|
|
|
|
|
docs.append(d);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
QJsonObject result;
|
|
|
|
|
result["count"] = docs.count();
|
|
|
|
|
result["docs"] = docs;
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Hand written guide compiled in beside the plugin readmes
|
|
|
|
|
QString MCPDocs::guide()
|
|
|
|
|
{
|
|
|
|
|
QString text = readFile(":/mcpdocs/bandguide.md");
|
|
|
|
|
|
|
|
|
|
if (text.isEmpty()) {
|
|
|
|
|
return QString("The receiving guide is not available in this build.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return text;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
QStringList MCPDocs::unmatchedReadmes()
|
|
|
|
|
{
|
|
|
|
|
load();
|
|
|
|
|
QStringList result;
|
|
|
|
|
|
|
|
|
|
for (const Readme& readme : m_readmes)
|
|
|
|
|
{
|
|
|
|
|
if (!readme.used) {
|
|
|
|
|
result.append(readme.group + "/" + readme.dir);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
void MCPDocs::load()
|
|
|
|
|
{
|
|
|
|
|
if (m_loaded) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
m_loaded = true;
|
|
|
|
|
scanReadmes();
|
|
|
|
|
|
|
|
|
|
PluginManager *pluginManager = MainCore::instance()->getPluginManager();
|
|
|
|
|
|
|
|
|
|
if (!pluginManager) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const auto& reg : *pluginManager->getRxChannelRegistrations()) {
|
|
|
|
|
addDoc("channel", reg.m_channelId, "rx", reg.m_channelIdURI, reg.m_plugin->getPluginDescriptor().displayedName, {"channelrx"}, reg.m_plugin);
|
|
|
|
|
}
|
|
|
|
|
for (const auto& reg : *pluginManager->getTxChannelRegistrations()) {
|
|
|
|
|
addDoc("channel", reg.m_channelId, "tx", reg.m_channelIdURI, reg.m_plugin->getPluginDescriptor().displayedName, {"channeltx"}, reg.m_plugin);
|
|
|
|
|
}
|
|
|
|
|
for (const auto& reg : *pluginManager->getMIMOChannelRegistrations()) {
|
|
|
|
|
addDoc("channel", reg.m_channelId, "mimo", reg.m_channelIdURI, reg.m_plugin->getPluginDescriptor().displayedName, {"channelmimo"}, reg.m_plugin);
|
|
|
|
|
}
|
|
|
|
|
for (const auto& reg : pluginManager->getSourceDeviceRegistrations()) {
|
|
|
|
|
addDoc("device", reg.m_deviceHardwareId, "rx", reg.m_deviceId, reg.m_plugin->getPluginDescriptor().displayedName, {"samplesource"}, reg.m_plugin);
|
|
|
|
|
}
|
|
|
|
|
for (const auto& reg : pluginManager->getSinkDeviceRegistrations()) {
|
|
|
|
|
addDoc("device", reg.m_deviceHardwareId, "tx", reg.m_deviceId, reg.m_plugin->getPluginDescriptor().displayedName, {"samplesink"}, reg.m_plugin);
|
|
|
|
|
}
|
|
|
|
|
for (const auto& reg : pluginManager->getMIMODeviceRegistrations()) {
|
|
|
|
|
addDoc("device", reg.m_deviceHardwareId, "mimo", reg.m_deviceId, reg.m_plugin->getPluginDescriptor().displayedName, {"samplemimo"}, reg.m_plugin);
|
|
|
|
|
}
|
|
|
|
|
for (const auto& reg : *pluginManager->getFeatureRegistrations()) {
|
|
|
|
|
addDoc("feature", reg.m_featureId, "", reg.m_featureIdURI, reg.m_plugin->getPluginDescriptor().displayedName, {"feature"}, reg.m_plugin);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
applyEnumeratedDeviceIds();
|
2026-09-13 10:05:17 +01:00
|
|
|
addGuiDocs();
|
2026-09-11 20:11:10 +01:00
|
|
|
|
|
|
|
|
qDebug("MCPDocs::load: %d readmes, %d matched to plugins, unmatched: %s",
|
|
|
|
|
(int) m_readmes.size(), (int) m_docs.size(), qPrintable(unmatchedReadmes().join(", ")));
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-13 10:05:17 +01:00
|
|
|
// Pages of the GUI's own, compiled in under :/mcpdocs/gui by the CMake list beside this one.
|
|
|
|
|
// The spectrum display is the one that matters most: its settings are the only ones a client
|
|
|
|
|
// changes that no plugin readme explains
|
|
|
|
|
void MCPDocs::addGuiDocs()
|
|
|
|
|
{
|
|
|
|
|
struct Page { const char *id; const char *name; const char *file; QStringList aliases; };
|
|
|
|
|
|
|
|
|
|
static const QList<Page> pages = {
|
|
|
|
|
{"spectrum", "Spectrum display", "spectrum.md", {"GLSpectrum", "spectrum display", "spectrum component"}},
|
|
|
|
|
{"spectrummarkers", "Spectrum markers", "spectrummarkers.md", {"markers"}},
|
|
|
|
|
{"spectrummeasurements", "Spectrum measurements", "spectrummeasurements.md", {"measurements"}},
|
|
|
|
|
{"spectrumcalibration", "Spectrum calibration", "spectrumcalibration.md", {"calibration"}},
|
|
|
|
|
{"mainspectrum", "Main spectrum window", "mainspectrum.md", {}},
|
|
|
|
|
{"audio", "Audio management", "audio.md", {}},
|
|
|
|
|
{"configurations", "Configurations", "configurations.md", {}},
|
|
|
|
|
{"deviceuserargs", "Device user arguments", "deviceuserargs.md", {}},
|
|
|
|
|
{"transverter", "Transverter dialog", "transverterdialog.md", {}}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
for (const Page& page : pages)
|
|
|
|
|
{
|
|
|
|
|
QString path = QString(":/mcpdocs/gui/%1").arg(page.file);
|
|
|
|
|
|
|
|
|
|
if (!QFile::exists(path)) {
|
|
|
|
|
continue; // not in this build
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
QString markdown = toMarkdown(readFile(path));
|
|
|
|
|
Doc doc;
|
|
|
|
|
doc.kind = "gui";
|
|
|
|
|
doc.id = page.id;
|
|
|
|
|
doc.aliases = page.aliases;
|
|
|
|
|
doc.name = page.name;
|
|
|
|
|
doc.title = page.name;
|
|
|
|
|
doc.path = path;
|
|
|
|
|
doc.headings = extractHeadings(markdown);
|
|
|
|
|
doc.plugin = nullptr;
|
|
|
|
|
|
|
|
|
|
// The page's own first heading, when it has one
|
|
|
|
|
for (const QString& line : markdown.split('\n'))
|
|
|
|
|
{
|
|
|
|
|
int level;
|
|
|
|
|
QString heading = headingText(line, level);
|
|
|
|
|
|
|
|
|
|
if (level > 0)
|
|
|
|
|
{
|
|
|
|
|
doc.title = heading;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
m_docs.append(doc);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-11 20:11:10 +01:00
|
|
|
void MCPDocs::scanReadmes()
|
|
|
|
|
{
|
|
|
|
|
QDir root(":/mcpdocs");
|
|
|
|
|
|
|
|
|
|
for (const QString& group : root.entryList(QDir::Dirs | QDir::NoDotAndDotDot))
|
|
|
|
|
{
|
|
|
|
|
QDir groupDir(root.filePath(group));
|
|
|
|
|
|
|
|
|
|
for (const QString& dir : groupDir.entryList(QDir::Dirs | QDir::NoDotAndDotDot))
|
|
|
|
|
{
|
|
|
|
|
QString path = groupDir.filePath(dir) + "/readme.md";
|
|
|
|
|
|
|
|
|
|
if (!QFile::exists(path)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Readme readme;
|
|
|
|
|
readme.group = group;
|
|
|
|
|
readme.dir = dir;
|
|
|
|
|
readme.path = path;
|
|
|
|
|
readme.used = false;
|
|
|
|
|
|
|
|
|
|
// Title is the first heading
|
|
|
|
|
for (const QString& line : readFile(path).split('\n'))
|
|
|
|
|
{
|
|
|
|
|
int level;
|
|
|
|
|
QString heading = headingText(line, level);
|
|
|
|
|
|
|
|
|
|
if (level > 0)
|
|
|
|
|
{
|
|
|
|
|
readme.title = heading;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
m_readmes.append(readme);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The hardware id a device plugin registers with can differ from the one it enumerates devices under,
|
|
|
|
|
// and the enumerated one is what list_available_devices, set_device and describe_settings use.
|
|
|
|
|
// Make that the canonical id and keep the registration id as an alias.
|
|
|
|
|
void MCPDocs::applyEnumeratedDeviceIds()
|
|
|
|
|
{
|
|
|
|
|
DeviceEnumerator *enumerator = DeviceEnumerator::instance();
|
|
|
|
|
|
|
|
|
|
auto apply = [this](const QString& direction, const PluginInterface::SamplingDevice *device, PluginInterface *plugin)
|
|
|
|
|
{
|
|
|
|
|
for (Doc& doc : m_docs)
|
|
|
|
|
{
|
|
|
|
|
if ((doc.kind != "device") || (doc.direction != direction) || (doc.plugin != plugin)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (doc.id != device->hardwareId)
|
|
|
|
|
{
|
|
|
|
|
if (!doc.aliases.contains(doc.id)) {
|
|
|
|
|
doc.aliases.append(doc.id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
doc.aliases.removeAll(device->hardwareId);
|
|
|
|
|
doc.id = device->hardwareId;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
for (int i = 0; i < enumerator->getNbRxSamplingDevices(); i++) {
|
|
|
|
|
apply("rx", enumerator->getRxSamplingDevice(i), enumerator->getRxPluginInterface(i));
|
|
|
|
|
}
|
|
|
|
|
for (int i = 0; i < enumerator->getNbTxSamplingDevices(); i++) {
|
|
|
|
|
apply("tx", enumerator->getTxSamplingDevice(i), enumerator->getTxPluginInterface(i));
|
|
|
|
|
}
|
|
|
|
|
for (int i = 0; i < enumerator->getNbMIMOSamplingDevices(); i++) {
|
|
|
|
|
apply("mimo", enumerator->getMIMOSamplingDevice(i), enumerator->getMIMOPluginInterface(i));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void MCPDocs::addDoc(const QString& kind, const QString& id, const QString& direction, const QString& uri,
|
|
|
|
|
const QString& displayedName, const QStringList& groups, PluginInterface *plugin)
|
|
|
|
|
{
|
|
|
|
|
// Several device plugins register the same hardware id (e.g. for different device instances)
|
|
|
|
|
for (const Doc& existing : m_docs)
|
|
|
|
|
{
|
|
|
|
|
if ((existing.kind == kind) && (existing.id == id) && (existing.direction == direction)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const Readme *readme = match(groups, id, uri, displayedName);
|
|
|
|
|
|
|
|
|
|
if (!readme)
|
|
|
|
|
{
|
|
|
|
|
qDebug("MCPDocs::addDoc: no readme for %s %s (%s)", qPrintable(kind), qPrintable(id), qPrintable(displayedName));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Doc doc;
|
|
|
|
|
doc.kind = kind;
|
|
|
|
|
doc.id = id;
|
|
|
|
|
doc.direction = direction;
|
|
|
|
|
doc.name = displayedName;
|
|
|
|
|
doc.title = readme->title;
|
|
|
|
|
doc.path = readme->path;
|
|
|
|
|
doc.headings = extractHeadings(toMarkdown(readFile(readme->path)));
|
|
|
|
|
doc.plugin = plugin;
|
|
|
|
|
m_docs.append(doc);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Directory names and ids differ (demodadsb vs ADSBDemod, rtlsdr vs RTLSDR, chanalyzer vs ChannelAnalyzer),
|
|
|
|
|
// so compare normalized forms with the demod/mod affixes removed, then fall back to the readme title.
|
|
|
|
|
const MCPDocs::Readme *MCPDocs::match(const QStringList& groups, const QString& id, const QString& uri, const QString& displayedName)
|
|
|
|
|
{
|
|
|
|
|
// Plugins whose directory, id and title all differ
|
|
|
|
|
static const QMap<QString, QString> overrides = {
|
|
|
|
|
{"ChannelAnalyzer", "chanalyzer"},
|
|
|
|
|
{"IEEE_802_15_4_Mod", "mod802.15.4"},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
QStringList idCandidates;
|
|
|
|
|
QString normId = normalize(id);
|
|
|
|
|
idCandidates.append(normId);
|
|
|
|
|
idCandidates.append(normalize(uri.section('.', -1)));
|
|
|
|
|
|
|
|
|
|
if (overrides.contains(id)) {
|
|
|
|
|
idCandidates.prepend(normalize(overrides[id]));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Device plugins share a hardware id between input and output and live in
|
|
|
|
|
// "<id>input" / "<id>output" directories
|
|
|
|
|
idCandidates.append(normId + "input");
|
|
|
|
|
idCandidates.append(normId + "output");
|
|
|
|
|
|
2026-09-11 21:38:59 +01:00
|
|
|
for (const QString affix : {"demod", "mod"})
|
2026-09-11 20:11:10 +01:00
|
|
|
{
|
|
|
|
|
if (normId.endsWith(affix) && (normId.size() > affix.size())) {
|
|
|
|
|
idCandidates.append(normId.left(normId.size() - affix.size()));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
auto dirCandidates = [](const QString& dir)
|
|
|
|
|
{
|
|
|
|
|
QStringList result;
|
|
|
|
|
QString normDir = normalize(dir);
|
|
|
|
|
result.append(normDir);
|
|
|
|
|
|
2026-09-11 21:38:59 +01:00
|
|
|
for (const QString affix : {"demod", "mod"})
|
2026-09-11 20:11:10 +01:00
|
|
|
{
|
|
|
|
|
if (normDir.startsWith(affix) && (normDir.size() > affix.size())) {
|
|
|
|
|
result.append(normDir.mid(affix.size()));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return result;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Pass 1: names
|
|
|
|
|
for (Readme& readme : m_readmes)
|
|
|
|
|
{
|
|
|
|
|
if (!groups.contains(readme.group)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const QString& d : dirCandidates(readme.dir))
|
|
|
|
|
{
|
|
|
|
|
if (idCandidates.contains(d))
|
|
|
|
|
{
|
|
|
|
|
readme.used = true;
|
|
|
|
|
return &readme;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Pass 2: displayed name against the readme title, e.g. "ADS-B Demodulator" and "ADS-B demodulator plugin"
|
|
|
|
|
QString normName = normalize(displayedName);
|
|
|
|
|
|
|
|
|
|
if (!normName.isEmpty())
|
|
|
|
|
{
|
|
|
|
|
for (Readme& readme : m_readmes)
|
|
|
|
|
{
|
|
|
|
|
if (!groups.contains(readme.group)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
QString normTitle = normalize(readme.title);
|
|
|
|
|
|
|
|
|
|
if (normTitle.startsWith(normName) || normName.startsWith(normTitle))
|
|
|
|
|
{
|
|
|
|
|
readme.used = true;
|
|
|
|
|
return &readme;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nullptr;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
QString MCPDocs::normalize(const QString& s)
|
|
|
|
|
{
|
|
|
|
|
QString result;
|
|
|
|
|
|
|
|
|
|
for (const QChar& c : s.toLower())
|
|
|
|
|
{
|
|
|
|
|
if (c.isLetterOrNumber()) {
|
|
|
|
|
result.append(c);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
QString MCPDocs::readFile(const QString& path)
|
|
|
|
|
{
|
|
|
|
|
QFile file(path);
|
|
|
|
|
|
|
|
|
|
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
|
|
|
|
return QString();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return QString::fromUtf8(file.readAll());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Readmes mix HTML headings with markdown. Normalize headings to markdown and drop images,
|
|
|
|
|
// which cannot be served to the model.
|
|
|
|
|
QString MCPDocs::toMarkdown(const QString& raw)
|
|
|
|
|
{
|
|
|
|
|
static const QRegularExpression htmlHeading("^\\s*<h([1-6])>(.*?)</h\\1>\\s*$");
|
|
|
|
|
static const QRegularExpression image("^\\s*!\\[[^\\]]*\\]\\([^)]*\\)\\s*$");
|
|
|
|
|
static const QRegularExpression htmlImage("^\\s*<img\\b[^>]*>\\s*$", QRegularExpression::CaseInsensitiveOption);
|
|
|
|
|
QStringList out;
|
|
|
|
|
|
|
|
|
|
for (const QString& line : raw.split('\n'))
|
|
|
|
|
{
|
|
|
|
|
QRegularExpressionMatch m = htmlHeading.match(line);
|
|
|
|
|
|
|
|
|
|
if (m.hasMatch())
|
|
|
|
|
{
|
|
|
|
|
out.append(QString(m.captured(1).toInt(), '#') + " " + m.captured(2).trimmed());
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (image.match(line).hasMatch() || htmlImage.match(line).hasMatch()) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
out.append(line.endsWith('\r') ? line.chopped(1) : line);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return out.join('\n');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Returns the heading text of a markdown heading line and its level, or level 0 for other lines
|
|
|
|
|
QString MCPDocs::headingText(const QString& line, int& level)
|
|
|
|
|
{
|
|
|
|
|
static const QRegularExpression mdHeading("^(#{1,6})\\s+(.*?)\\s*#*\\s*$");
|
|
|
|
|
static const QRegularExpression htmlHeading("^\\s*<h([1-6])>(.*?)</h\\1>\\s*$");
|
|
|
|
|
QRegularExpressionMatch m = mdHeading.match(line);
|
|
|
|
|
|
|
|
|
|
if (m.hasMatch())
|
|
|
|
|
{
|
|
|
|
|
level = m.captured(1).size();
|
|
|
|
|
return m.captured(2);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
m = htmlHeading.match(line);
|
|
|
|
|
|
|
|
|
|
if (m.hasMatch())
|
|
|
|
|
{
|
|
|
|
|
level = m.captured(1).toInt();
|
|
|
|
|
return m.captured(2).trimmed();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
level = 0;
|
|
|
|
|
return QString();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
QStringList MCPDocs::extractHeadings(const QString& markdown)
|
|
|
|
|
{
|
|
|
|
|
QStringList headings;
|
|
|
|
|
|
|
|
|
|
for (const QString& line : markdown.split('\n'))
|
|
|
|
|
{
|
|
|
|
|
int level;
|
|
|
|
|
QString heading = headingText(line, level);
|
|
|
|
|
|
|
|
|
|
if (level > 0) {
|
|
|
|
|
headings.append(QString(level, '#') + " " + heading);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return headings;
|
|
|
|
|
}
|