#include "denote_frontmatter.h" #include #include namespace Denote { FileType fileTypeFromExtension(const QString &ext) { const QString lower = ext.toLower(); if (lower == QStringLiteral(".md") || lower == QStringLiteral(".markdown")) return FileType::MdYaml; if (lower == QStringLiteral(".org")) return FileType::Org; if (lower == QStringLiteral(".txt")) return FileType::Txt; return FileType::Unknown; } bool supportsFrontMatter(const QString &ext) { return fileTypeFromExtension(ext) != FileType::Unknown; } // --- YAML frontmatter (Markdown) --- static FrontMatter parseYamlFrontMatter(const QString &text) { FrontMatter fm; // Match --- delimited block at start of file static const QRegularExpression reFm( QStringLiteral("\\A---\\n(.*?)\\n---"), QRegularExpression::DotMatchesEverythingOption); auto m = reFm.match(text); if (!m.hasMatch()) return fm; const QString block = m.captured(1); // title: value or title: "value" static const QRegularExpression reTitle( QStringLiteral("(?:^|\\n)title:\\s*\"?([^\"\\n]+)\"?")); auto mt = reTitle.match(block); if (mt.hasMatch()) fm.title = mt.captured(1).trimmed(); // identifier: value static const QRegularExpression reId( QStringLiteral("(?:^|\\n)identifier:\\s*\"?(\\d{8}T\\d{6})\"?")); auto mi = reId.match(block); if (mi.hasMatch()) fm.identifier = mi.captured(1); // signature: value static const QRegularExpression reSig( QStringLiteral("(?:^|\\n)signature:\\s*\"?([^\"\\n]+)\"?")); auto ms = reSig.match(block); if (ms.hasMatch()) fm.signature = ms.captured(1).trimmed(); // tags: [tag1, tag2] or tags:\n- tag1\n- tag2 // Inline form first static const QRegularExpression reTagsInline( QStringLiteral("(?:^|\\n)tags:\\s*\\[([^\\]]*?)\\]")); auto mtags = reTagsInline.match(block); if (mtags.hasMatch()) { const QString raw = mtags.captured(1); const auto parts = raw.split(QLatin1Char(','), Qt::SkipEmptyParts); for (const auto &p : parts) { QString tag = p.trimmed(); tag.remove(QLatin1Char('"')); if (!tag.isEmpty()) fm.tags.append(tag); } } else { // List form: tags:\n - tag1\n - tag2 static const QRegularExpression reTagsList( QStringLiteral("(?:^|\\n)tags:\\s*\\n((?:\\s*-\\s*[^\\n]+\\n?)+)")); auto mlist = reTagsList.match(block); if (mlist.hasMatch()) { static const QRegularExpression reItem( QStringLiteral("\\s*-\\s*\"?([^\"\\n]+)\"?")); auto it = reItem.globalMatch(mlist.captured(1)); while (it.hasNext()) { auto item = it.next(); QString tag = item.captured(1).trimmed(); if (!tag.isEmpty()) fm.tags.append(tag); } } } return fm; } static QByteArray marshalYaml(const FrontMatter &fm) { QString out; out += QStringLiteral("---\n"); out += QStringLiteral("title: ") + fm.title + QStringLiteral("\n"); out += QStringLiteral("tags: ["); for (int i = 0; i < fm.tags.size(); ++i) { if (i > 0) out += QStringLiteral(", "); out += fm.tags[i]; } out += QStringLiteral("]\n"); out += QStringLiteral("identifier: ") + fm.identifier + QStringLiteral("\n"); if (!fm.signature.isEmpty()) out += QStringLiteral("signature: ") + fm.signature + QStringLiteral("\n"); out += QStringLiteral("---\n"); return out.toUtf8(); } // --- Org-mode frontmatter --- static FrontMatter parseOrgFrontMatter(const QString &text) { FrontMatter fm; // #+title: value static const QRegularExpression reTitle( QStringLiteral("(?:^|\\n)#\\+title:\\s*(.+)"), QRegularExpression::CaseInsensitiveOption); auto m = reTitle.match(text); if (m.hasMatch()) fm.title = m.captured(1).trimmed(); // #+identifier: value static const QRegularExpression reId( QStringLiteral("(?:^|\\n)#\\+identifier:\\s*(\\d{8}T\\d{6})"), QRegularExpression::CaseInsensitiveOption); auto mi = reId.match(text); if (mi.hasMatch()) fm.identifier = mi.captured(1); // #+filetags: :tag1:tag2: static const QRegularExpression reTags( QStringLiteral("(?:^|\\n)#\\+filetags:\\s*:?([^\\n]+)"), QRegularExpression::CaseInsensitiveOption); auto mt = reTags.match(text); if (mt.hasMatch()) { QString raw = mt.captured(1).trimmed(); // Remove surrounding colons if (raw.startsWith(QLatin1Char(':'))) raw = raw.mid(1); if (raw.endsWith(QLatin1Char(':'))) raw.chop(1); fm.tags = raw.split(QLatin1Char(':'), Qt::SkipEmptyParts); } // #+signature: value static const QRegularExpression reSig( QStringLiteral("(?:^|\\n)#\\+signature:\\s*(.+)"), QRegularExpression::CaseInsensitiveOption); auto ms = reSig.match(text); if (ms.hasMatch()) fm.signature = ms.captured(1).trimmed(); return fm; } static QByteArray marshalOrg(const FrontMatter &fm) { QString out; out += QStringLiteral("#+title: ") + fm.title + QStringLiteral("\n"); if (!fm.tags.isEmpty()) { out += QStringLiteral("#+filetags: :") + fm.tags.join(QLatin1Char(':')) + QStringLiteral(":\n"); } out += QStringLiteral("#+identifier: ") + fm.identifier + QStringLiteral("\n"); if (!fm.signature.isEmpty()) out += QStringLiteral("#+signature: ") + fm.signature + QStringLiteral("\n"); return out.toUtf8(); } // --- Plain text frontmatter --- static FrontMatter parseTxtFrontMatter(const QString &text) { FrontMatter fm; // title: value static const QRegularExpression reTitle( QStringLiteral("(?:^|\\n)title:\\s*(.+)"), QRegularExpression::CaseInsensitiveOption); auto m = reTitle.match(text); if (m.hasMatch()) fm.title = m.captured(1).trimmed(); // identifier: value static const QRegularExpression reId( QStringLiteral("(?:^|\\n)identifier:\\s*(\\d{8}T\\d{6})"), QRegularExpression::CaseInsensitiveOption); auto mi = reId.match(text); if (mi.hasMatch()) fm.identifier = mi.captured(1); // tags: tag1, tag2 static const QRegularExpression reTags( QStringLiteral("(?:^|\\n)tags:\\s*(.+)"), QRegularExpression::CaseInsensitiveOption); auto mt = reTags.match(text); if (mt.hasMatch()) { fm.tags = mt.captured(1).split(QLatin1Char(','), Qt::SkipEmptyParts); for (auto &t : fm.tags) t = t.trimmed(); } // signature: value static const QRegularExpression reSig( QStringLiteral("(?:^|\\n)signature:\\s*(.+)"), QRegularExpression::CaseInsensitiveOption); auto ms = reSig.match(text); if (ms.hasMatch()) fm.signature = ms.captured(1).trimmed(); return fm; } static QByteArray marshalTxt(const FrontMatter &fm) { QString out; out += QStringLiteral("title: ") + fm.title + QStringLiteral("\n"); if (!fm.tags.isEmpty()) out += QStringLiteral("tags: ") + fm.tags.join(QStringLiteral(", ")) + QStringLiteral("\n"); out += QStringLiteral("identifier: ") + fm.identifier + QStringLiteral("\n"); if (!fm.signature.isEmpty()) out += QStringLiteral("signature: ") + fm.signature + QStringLiteral("\n"); out += QStringLiteral("--\n"); return out.toUtf8(); } // --- Public API --- FrontMatter parseFrontMatter(const QByteArray &content, FileType type) { const QString text = QString::fromUtf8(content); switch (type) { case FileType::MdYaml: return parseYamlFrontMatter(text); case FileType::Org: return parseOrgFrontMatter(text); case FileType::Txt: return parseTxtFrontMatter(text); case FileType::Unknown: return {}; } return {}; } QByteArray marshalFrontMatter(const FrontMatter &fm, FileType type) { switch (type) { case FileType::MdYaml: return marshalYaml(fm); case FileType::Org: return marshalOrg(fm); case FileType::Txt: return marshalTxt(fm); case FileType::Unknown: return {}; } return {}; } QString extractBody(const QByteArray &content, FileType type) { const QString text = QString::fromUtf8(content); switch (type) { case FileType::MdYaml: { // Body is everything after closing --- static const QRegularExpression reFm( QStringLiteral("\\A---\\n.*?\\n---\\n?"), QRegularExpression::DotMatchesEverythingOption); auto m = reFm.match(text); if (m.hasMatch()) return text.mid(m.capturedEnd()); return text; } case FileType::Org: { // Body starts after the last #+key: line at the top static const QRegularExpression reHeader( QStringLiteral("\\A((?:#\\+[^\\n]+\\n)*)")); auto m = reHeader.match(text); if (m.hasMatch()) return text.mid(m.capturedEnd()); return text; } case FileType::Txt: { // Body starts after the -- separator line int idx = text.indexOf(QStringLiteral("\n--\n")); if (idx >= 0) return text.mid(idx + 4); return text; } case FileType::Unknown: return text; } return text; } } // namespace Denote