Initial implementation: Baloo extractor + Dolphin rename action

Components:
- lib/: shared denote metadata parsing and frontmatter handling
- extractor/: KFileMetaData plugin for Baloo indexing
- fileitemaction/: "Rename with Denote..." Dolphin context menu
- Unit tests for metadata and frontmatter parsing

All tests passing, builds against KF6/Qt6.
This commit is contained in:
Levi Neely 2026-07-12 20:21:11 +02:00
commit 063c536a89
19 changed files with 1193 additions and 0 deletions

10
.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
build/
*.o
*.so
*.a
CMakeFiles/
CMakeCache.txt
cmake_install.cmake
Makefile
.cache/
compile_commands.json

21
CMakeLists.txt Normal file
View File

@ -0,0 +1,21 @@
cmake_minimum_required(VERSION 3.20)
project(kdenote VERSION 0.1.0 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_AUTOMOC ON)
find_package(ECM 6.0 REQUIRED NO_MODULE)
set(CMAKE_MODULE_PATH ${ECM_MODULE_PATH})
include(KDEInstallDirs)
include(KDECMakeSettings)
include(KDECompilerSettings NO_POLICY_SCOPE)
include(ECMSetupVersion)
find_package(Qt6 REQUIRED COMPONENTS Core)
find_package(KF6 REQUIRED COMPONENTS FileMetaData)
add_subdirectory(lib)
add_subdirectory(extractor)
add_subdirectory(fileitemaction)

52
README.md Normal file
View File

@ -0,0 +1,52 @@
# kdenote
KDE integration plugins for [Denote](https://protesilaos.com/emacs/denote) notes.
## Components
- **Baloo extractor** — KFileMetaData plugin that indexes denote file metadata (title, tags, date, signature) so Dolphin search, KRunner, and Baloo queries work on your notes.
- **Dolphin context menu** — "Rename with Denote..." action for files that don't yet conform to the denote naming scheme.
- **Shared library** — Denote filename parsing/building and frontmatter handling (YAML/Org/txt), used by both plugins.
## Building
```sh
mkdir build && cd build
cmake .. -DBUILD_TESTING=ON
make
make install
```
### Dependencies
- Qt 6
- KDE Frameworks 6 (KFileMetaData, KIO, WidgetsAddons, ECM)
- C++20 compiler
## Configuration
Ensure your denote directory is indexed by Baloo:
```sh
balooctl config add includeFolders /path/to/your/notes
```
After installing the extractor plugin, trigger a reindex:
```sh
balooctl check /path/to/your/notes
```
## Denote filename format
```
YYYYMMDDTHHMMSS[==signature]--slugified-title[__tag1_tag2].ext
```
Examples:
- `20240315T120000--my-daily-note__journal_daily.md`
- `20240315T120000==1a--project-plan__work.org`
## License
LGPL-2.1-or-later

13
extractor/CMakeLists.txt Normal file
View File

@ -0,0 +1,13 @@
add_library(kdenote_extractor MODULE
denote_extractor.cpp
)
target_link_libraries(kdenote_extractor
kdenote_lib
KF6::FileMetaData
Qt6::Core
)
install(TARGETS kdenote_extractor
DESTINATION ${KDE_INSTALL_PLUGINDIR}/kf6/kfilemetadata
)

View File

@ -0,0 +1,102 @@
#include "denote_extractor.h"
#include "denote_frontmatter.h"
#include "denote_metadata.h"
#include <KFileMetaData/ExtractionResult>
#include <KFileMetaData/Properties>
#include <KFileMetaData/Types>
#include <QFile>
#include <QFileInfo>
DenoteExtractor::DenoteExtractor(QObject *parent)
: KFileMetaData::ExtractorPlugin(parent)
{
}
QStringList DenoteExtractor::mimetypes() const
{
return {
QStringLiteral("text/markdown"),
QStringLiteral("text/x-org"),
QStringLiteral("text/plain"),
};
}
void DenoteExtractor::extract(KFileMetaData::ExtractionResult *result)
{
const QString path = result->inputUrl();
const QFileInfo fi(path);
// Only process files that look like denote files (have an identifier)
Denote::Metadata meta = Denote::parseFilename(path);
if (!meta.isValid())
return;
// Start with filename-derived metadata
QString title = meta.title;
QStringList tags = meta.tags;
QString signature = meta.signature;
QString identifier = meta.identifier;
// Try to get richer metadata from frontmatter
const QString ext = QStringLiteral(".") + fi.suffix();
Denote::FileType fileType = Denote::fileTypeFromExtension(ext);
QByteArray content;
if (fileType != Denote::FileType::Unknown) {
QFile file(path);
if (file.open(QIODevice::ReadOnly)) {
content = file.readAll();
file.close();
Denote::FrontMatter fm = Denote::parseFrontMatter(content, fileType);
// Prefer frontmatter values over filename-derived ones
if (!fm.title.isEmpty())
title = fm.title;
if (!fm.tags.isEmpty())
tags = fm.tags;
if (!fm.signature.isEmpty())
signature = fm.signature;
if (!fm.identifier.isEmpty())
identifier = fm.identifier;
}
}
// Emit metadata properties
if (result->inputFlags() & KFileMetaData::ExtractionResult::ExtractMetaData) {
result->addType(KFileMetaData::Type::Document);
if (!title.isEmpty())
result->add(KFileMetaData::Property::Title, title);
if (!tags.isEmpty())
result->add(KFileMetaData::Property::Keywords, tags.join(QStringLiteral(", ")));
if (!identifier.isEmpty()) {
QDateTime dt = QDateTime::fromString(identifier, QStringLiteral("yyyyMMdd'T'HHmmss"));
if (dt.isValid())
result->add(KFileMetaData::Property::CreationDate, dt);
}
if (!signature.isEmpty())
result->add(KFileMetaData::Property::Subject, signature);
}
// Emit body text for full-text indexing
if (result->inputFlags() & KFileMetaData::ExtractionResult::ExtractPlainText) {
if (content.isEmpty() && fileType != Denote::FileType::Unknown) {
QFile file(path);
if (file.open(QIODevice::ReadOnly)) {
content = file.readAll();
file.close();
}
}
if (!content.isEmpty()) {
QString body = Denote::extractBody(content, fileType);
if (!body.isEmpty())
result->append(body);
}
}
}

View File

@ -0,0 +1,20 @@
#ifndef KDENOTE_EXTRACTOR_H
#define KDENOTE_EXTRACTOR_H
#include <KFileMetaData/ExtractorPlugin>
class DenoteExtractor : public KFileMetaData::ExtractorPlugin
{
Q_OBJECT
Q_PLUGIN_METADATA(IID "org.kde.kf5.kfilemetadata.ExtractorPlugin"
FILE "denote_extractor.json")
Q_INTERFACES(KFileMetaData::ExtractorPlugin)
public:
explicit DenoteExtractor(QObject *parent = nullptr);
QStringList mimetypes() const override;
void extract(KFileMetaData::ExtractionResult *result) override;
};
#endif // KDENOTE_EXTRACTOR_H

View File

@ -0,0 +1,9 @@
{
"Name": "Denote Metadata Extractor",
"Id": "org.kde.kdenote.extractor",
"MimeTypes": {
"text/markdown": { "Version": "0.1" },
"text/x-org": { "Version": "0.1" },
"text/plain": { "Version": "0.1" }
}
}

View File

@ -0,0 +1,17 @@
find_package(KF6 REQUIRED COMPONENTS KIO WidgetsAddons)
add_library(kdenote_fileitemaction MODULE
denote_fileitemaction.cpp
)
target_link_libraries(kdenote_fileitemaction
kdenote_lib
KF6::KIOWidgets
KF6::WidgetsAddons
Qt6::Core
Qt6::Widgets
)
install(TARGETS kdenote_fileitemaction
DESTINATION ${KDE_INSTALL_PLUGINDIR}/kf6/kfileitemaction
)

View File

@ -0,0 +1,156 @@
#include "denote_fileitemaction.h"
#include "denote_frontmatter.h"
#include "denote_metadata.h"
#include <KFileItem>
#include <KPluginFactory>
#include <QAction>
#include <QDialog>
#include <QDialogButtonBox>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QFormLayout>
#include <QLineEdit>
#include <QVBoxLayout>
#include <QWidget>
K_PLUGIN_CLASS_WITH_JSON(DenoteFileItemAction, "denote_fileitemaction.json")
DenoteFileItemAction::DenoteFileItemAction(QObject *parent, const QVariantList &)
: KAbstractFileItemActionPlugin(parent)
{
}
QList<QAction *> DenoteFileItemAction::actions(const KFileItemListProperties &fileItemInfos,
QWidget *parentWidget)
{
// Only show for single file selection that is NOT already a denote file
if (fileItemInfos.items().count() != 1)
return {};
const KFileItem item = fileItemInfos.items().first();
if (item.isDir())
return {};
// Check if already a denote file
Denote::Metadata meta = Denote::parseFilename(item.localPath());
if (meta.isValid())
return {};
auto *action = new QAction(QStringLiteral("Rename with Denote..."), parentWidget);
action->setIcon(QIcon::fromTheme(QStringLiteral("edit-rename")));
const KFileItemList items = fileItemInfos.items();
connect(action, &QAction::triggered, this, [this, items, parentWidget]() {
renameWithDenote(items, parentWidget);
});
return {action};
}
void DenoteFileItemAction::renameWithDenote(const KFileItemList &items, QWidget *parentWidget)
{
if (items.isEmpty())
return;
const KFileItem item = items.first();
const QString path = item.localPath();
const QFileInfo fi(path);
// Pre-fill title from filename (without extension)
QString defaultTitle = fi.completeBaseName();
defaultTitle.replace(QLatin1Char('-'), QLatin1Char(' '));
defaultTitle.replace(QLatin1Char('_'), QLatin1Char(' '));
// Try to extract title from frontmatter if applicable
const QString ext = QStringLiteral(".") + fi.suffix();
Denote::FileType fileType = Denote::fileTypeFromExtension(ext);
if (fileType != Denote::FileType::Unknown) {
QFile file(path);
if (file.open(QIODevice::ReadOnly)) {
QByteArray content = file.readAll();
file.close();
Denote::FrontMatter fm = Denote::parseFrontMatter(content, fileType);
if (!fm.title.isEmpty())
defaultTitle = fm.title;
}
}
// Build dialog
QDialog dialog(parentWidget);
dialog.setWindowTitle(QStringLiteral("Rename with Denote"));
auto *layout = new QVBoxLayout(&dialog);
auto *form = new QFormLayout();
auto *titleEdit = new QLineEdit(defaultTitle);
form->addRow(QStringLiteral("Title:"), titleEdit);
auto *tagsEdit = new QLineEdit();
tagsEdit->setPlaceholderText(QStringLiteral("tag1, tag2, tag3"));
form->addRow(QStringLiteral("Tags:"), tagsEdit);
auto *sigEdit = new QLineEdit();
sigEdit->setPlaceholderText(QStringLiteral("(optional)"));
form->addRow(QStringLiteral("Signature:"), sigEdit);
layout->addLayout(form);
auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
connect(buttons, &QDialogButtonBox::accepted, &dialog, &QDialog::accept);
connect(buttons, &QDialogButtonBox::rejected, &dialog, &QDialog::reject);
layout->addWidget(buttons);
if (dialog.exec() != QDialog::Accepted)
return;
const QString title = titleEdit->text().trimmed();
if (title.isEmpty())
return;
QStringList tags;
const QString tagsText = tagsEdit->text().trimmed();
if (!tagsText.isEmpty()) {
tags = tagsText.split(QLatin1Char(','), Qt::SkipEmptyParts);
for (auto &t : tags)
t = t.trimmed().toLower();
}
const QString signature = sigEdit->text().trimmed();
// Build new filename
const QString newName = Denote::buildFilename(title, tags, signature, ext);
const QString newPath = fi.dir().filePath(newName);
// Rename
QFile::rename(path, newPath);
// Update frontmatter if supported
if (fileType != Denote::FileType::Unknown) {
QFile file(newPath);
if (file.open(QIODevice::ReadWrite)) {
QByteArray content = file.readAll();
Denote::FrontMatter fm;
fm.title = title;
fm.tags = tags;
fm.identifier = Denote::parseFilename(newPath).identifier;
fm.signature = signature;
// Replace or prepend frontmatter
QString body = Denote::extractBody(content, fileType);
QByteArray newContent = Denote::marshalFrontMatter(fm, fileType);
newContent += body.toUtf8();
file.seek(0);
file.resize(0);
file.write(newContent);
file.close();
}
}
}
#include "denote_fileitemaction.moc"

View File

@ -0,0 +1,21 @@
#ifndef KDENOTE_FILEITEMACTION_H
#define KDENOTE_FILEITEMACTION_H
#include <KAbstractFileItemActionPlugin>
#include <KFileItemListProperties>
class DenoteFileItemAction : public KAbstractFileItemActionPlugin
{
Q_OBJECT
public:
explicit DenoteFileItemAction(QObject *parent, const QVariantList &args = {});
QList<QAction *> actions(const KFileItemListProperties &fileItemInfos,
QWidget *parentWidget) override;
private:
void renameWithDenote(const KFileItemList &items, QWidget *parentWidget);
};
#endif // KDENOTE_FILEITEMACTION_H

View File

@ -0,0 +1,8 @@
{
"KPlugin": {
"Name": "Denote Rename Action",
"Description": "Rename files to conform to denote naming convention",
"MimeTypes": ["application/octet-stream"],
"ServiceTypes": ["KFileItemAction/Plugin"]
}
}

11
lib/CMakeLists.txt Normal file
View File

@ -0,0 +1,11 @@
add_library(kdenote_lib STATIC
denote_metadata.cpp
denote_frontmatter.cpp
)
target_include_directories(kdenote_lib PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(kdenote_lib PUBLIC Qt6::Core)
if(BUILD_TESTING)
add_subdirectory(tests)
endif()

306
lib/denote_frontmatter.cpp Normal file
View File

@ -0,0 +1,306 @@
#include "denote_frontmatter.h"
#include <QRegularExpression>
#include <QStringList>
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

43
lib/denote_frontmatter.h Normal file
View File

@ -0,0 +1,43 @@
#ifndef KDENOTE_FRONTMATTER_H
#define KDENOTE_FRONTMATTER_H
#include <QString>
#include <QStringList>
namespace Denote {
/// Supported file types for frontmatter.
enum class FileType {
MdYaml, ///< Markdown with YAML frontmatter (---)
Org, ///< Org-mode (#+key: value)
Txt, ///< Plain text (title: value style)
Unknown
};
/// Parsed frontmatter from a denote note.
struct FrontMatter {
QString title;
QStringList tags;
QString identifier;
QString signature;
};
/// Detect the file type from extension.
FileType fileTypeFromExtension(const QString &ext);
/// Returns true if the extension supports frontmatter.
bool supportsFrontMatter(const QString &ext);
/// Parse frontmatter from file content.
/// Returns a default-constructed FrontMatter if none found.
FrontMatter parseFrontMatter(const QByteArray &content, FileType type);
/// Generate frontmatter content for a new file.
QByteArray marshalFrontMatter(const FrontMatter &fm, FileType type);
/// Extract the body (content after frontmatter) from file content.
QString extractBody(const QByteArray &content, FileType type);
} // namespace Denote
#endif // KDENOTE_FRONTMATTER_H

123
lib/denote_metadata.cpp Normal file
View File

@ -0,0 +1,123 @@
#include "denote_metadata.h"
#include <QFileInfo>
#include <QRegularExpression>
namespace Denote {
QDateTime Metadata::creationDate() const
{
if (identifier.isEmpty())
return {};
return QDateTime::fromString(identifier, QStringLiteral("yyyyMMdd'T'HHmmss"));
}
Metadata parseFilename(const QString &path)
{
Metadata meta;
meta.path = path;
const QString fname = QFileInfo(path).fileName();
// Identifier: leading YYYYMMDDTHHMMSS
static const QRegularExpression reId(QStringLiteral("^(\\d{8}T\\d{6})"));
auto m = reId.match(fname);
if (m.hasMatch())
meta.identifier = m.captured(1);
// Signature: ==sig-- (between identifier and title)
static const QRegularExpression reSig(QStringLiteral("==([^-]+?)--"));
m = reSig.match(fname);
if (m.hasMatch())
meta.signature = m.captured(1);
// Title: --title (first -- delimited segment, before __ or .)
static const QRegularExpression reTitle(QStringLiteral("--([^_\\.]+)"));
m = reTitle.match(fname);
if (m.hasMatch()) {
QString raw = m.captured(1);
raw.replace(QLatin1Char('-'), QLatin1Char(' '));
meta.title = raw;
}
// Tags: __tag1_tag2 (before extension or end)
static const QRegularExpression reTags(QStringLiteral("__(.+?)(?:\\.|$)"));
m = reTags.match(fname);
if (m.hasMatch()) {
meta.tags = m.captured(1).split(QLatin1Char('_'), Qt::SkipEmptyParts);
}
return meta;
}
QString generateIdentifier()
{
return QDateTime::currentDateTime().toString(QStringLiteral("yyyyMMdd'T'HHmmss"));
}
QString slugifyTitle(const QString &title)
{
QString slug = title.toLower();
slug.replace(QLatin1Char(' '), QLatin1Char('-'));
slug.replace(QLatin1Char('_'), QLatin1Char('-'));
// Remove anything that isn't a-z, 0-9, or hyphen
static const QRegularExpression reClean(QStringLiteral("[^a-z0-9-]"));
slug.remove(reClean);
return slug;
}
bool isValidTag(const QString &tag)
{
if (tag.isEmpty())
return false;
for (const QChar &ch : tag) {
if (!ch.isLetterOrNumber())
return false;
if (ch.isLetter() && ch.isUpper())
return false;
}
return true;
}
QString buildFilename(const QString &title,
const QStringList &tags,
const QString &signature,
const QString &ext)
{
QString result = generateIdentifier();
// Signature
if (!signature.isEmpty()) {
QString sig = signature.toLower();
sig.replace(QLatin1Char(' '), QStringLiteral("=="));
sig.replace(QLatin1Char('_'), QStringLiteral("=="));
// Remove special chars
static const QRegularExpression reSigClean(
QStringLiteral("[{}!@#$%^&*()+\'\"?,.\\\\|;:~`\u2018\u2019\u201c\u201d/-]"));
sig.remove(reSigClean);
// Normalize consecutive equals
static const QRegularExpression reEq(QStringLiteral("={3,}"));
sig.replace(reEq, QStringLiteral("=="));
// Trim trailing =
while (sig.endsWith(QLatin1Char('=')))
sig.chop(1);
result += QStringLiteral("==") + sig;
}
// Title
result += QStringLiteral("--") + slugifyTitle(title);
// Tags
if (!tags.isEmpty()) {
result += QStringLiteral("__") + tags.join(QLatin1Char('_'));
}
// Extension
result += ext;
return result;
}
} // namespace Denote

47
lib/denote_metadata.h Normal file
View File

@ -0,0 +1,47 @@
#ifndef KDENOTE_METADATA_H
#define KDENOTE_METADATA_H
#include <QDateTime>
#include <QString>
#include <QStringList>
namespace Denote {
/// Metadata extracted from a denote-style filename.
struct Metadata {
QString path; ///< Full filesystem path
QString identifier; ///< YYYYMMDDTHHMMSS timestamp
QString signature; ///< Optional signature component
QString title; ///< Title (from filename or frontmatter)
QStringList tags; ///< Keywords / tags
/// Parse the identifier into a QDateTime.
QDateTime creationDate() const;
/// True if the filename conforms to denote naming.
bool isValid() const { return !identifier.isEmpty(); }
};
/// Parse a denote-style filename into its components.
/// Works on filename only (no file I/O).
Metadata parseFilename(const QString &path);
/// Generate a new identifier from the current time.
QString generateIdentifier();
/// Build a canonical denote filename from components.
/// Returns just the filename (no directory).
QString buildFilename(const QString &title,
const QStringList &tags = {},
const QString &signature = {},
const QString &ext = QStringLiteral(".md"));
/// Slugify a title for use in a denote filename.
QString slugifyTitle(const QString &title);
/// Check if a tag is valid (lowercase alphanumeric/unicode letters).
bool isValidTag(const QString &tag);
} // namespace Denote
#endif // KDENOTE_METADATA_H

9
lib/tests/CMakeLists.txt Normal file
View File

@ -0,0 +1,9 @@
find_package(Qt6 REQUIRED COMPONENTS Test)
add_executable(test_metadata test_metadata.cpp)
target_link_libraries(test_metadata kdenote_lib Qt6::Test)
add_test(NAME test_metadata COMMAND test_metadata)
add_executable(test_frontmatter test_frontmatter.cpp)
target_link_libraries(test_frontmatter kdenote_lib Qt6::Test)
add_test(NAME test_frontmatter COMMAND test_frontmatter)

View File

@ -0,0 +1,148 @@
#include <QObject>
#include <QTest>
#include "denote_frontmatter.h"
class TestFrontMatter : public QObject
{
Q_OBJECT
private Q_SLOTS:
void parseYaml()
{
QByteArray content =
"---\n"
"title: My Test Note\n"
"tags: [journal, daily]\n"
"identifier: 20240315T120000\n"
"signature: 1a\n"
"---\n"
"Body content here.\n";
auto fm = Denote::parseFrontMatter(content, Denote::FileType::MdYaml);
QCOMPARE(fm.title, QStringLiteral("My Test Note"));
QCOMPARE(fm.tags, QStringList({QStringLiteral("journal"), QStringLiteral("daily")}));
QCOMPARE(fm.identifier, QStringLiteral("20240315T120000"));
QCOMPARE(fm.signature, QStringLiteral("1a"));
}
void parseYamlListTags()
{
QByteArray content =
"---\n"
"title: Note\n"
"tags:\n"
" - alpha\n"
" - beta\n"
"identifier: 20240101T090000\n"
"---\n";
auto fm = Denote::parseFrontMatter(content, Denote::FileType::MdYaml);
QCOMPARE(fm.title, QStringLiteral("Note"));
QCOMPARE(fm.tags, QStringList({QStringLiteral("alpha"), QStringLiteral("beta")}));
}
void parseOrg()
{
QByteArray content =
"#+title: Org Note Title\n"
"#+filetags: :emacs:org:\n"
"#+identifier: 20240315T120000\n"
"#+signature: 2b\n"
"\n"
"* Heading\n"
"Body text.\n";
auto fm = Denote::parseFrontMatter(content, Denote::FileType::Org);
QCOMPARE(fm.title, QStringLiteral("Org Note Title"));
QCOMPARE(fm.tags, QStringList({QStringLiteral("emacs"), QStringLiteral("org")}));
QCOMPARE(fm.identifier, QStringLiteral("20240315T120000"));
QCOMPARE(fm.signature, QStringLiteral("2b"));
}
void parseTxt()
{
QByteArray content =
"title: Plain Text Note\n"
"tags: notes, plain\n"
"identifier: 20240315T120000\n"
"--\n"
"The body.\n";
auto fm = Denote::parseFrontMatter(content, Denote::FileType::Txt);
QCOMPARE(fm.title, QStringLiteral("Plain Text Note"));
QCOMPARE(fm.tags, QStringList({QStringLiteral("notes"), QStringLiteral("plain")}));
QCOMPARE(fm.identifier, QStringLiteral("20240315T120000"));
}
void marshalYaml()
{
Denote::FrontMatter fm;
fm.title = QStringLiteral("Test");
fm.tags = {QStringLiteral("a"), QStringLiteral("b")};
fm.identifier = QStringLiteral("20240101T000000");
QByteArray result = Denote::marshalFrontMatter(fm, Denote::FileType::MdYaml);
QVERIFY(result.contains("title: Test"));
QVERIFY(result.contains("tags: [a, b]"));
QVERIFY(result.contains("identifier: 20240101T000000"));
QVERIFY(result.startsWith("---\n"));
QVERIFY(result.endsWith("---\n"));
}
void marshalOrg()
{
Denote::FrontMatter fm;
fm.title = QStringLiteral("Org Test");
fm.tags = {QStringLiteral("x")};
fm.identifier = QStringLiteral("20240101T000000");
QByteArray result = Denote::marshalFrontMatter(fm, Denote::FileType::Org);
QVERIFY(result.contains("#+title: Org Test"));
QVERIFY(result.contains("#+filetags: :x:"));
QVERIFY(result.contains("#+identifier: 20240101T000000"));
}
void extractBodyYaml()
{
QByteArray content =
"---\n"
"title: X\n"
"---\n"
"Body here.\n";
QString body = Denote::extractBody(content, Denote::FileType::MdYaml);
QCOMPARE(body.trimmed(), QStringLiteral("Body here."));
}
void extractBodyOrg()
{
QByteArray content =
"#+title: X\n"
"#+filetags: :a:\n"
"\n"
"* Heading\n";
QString body = Denote::extractBody(content, Denote::FileType::Org);
QVERIFY(body.contains(QStringLiteral("Heading")));
}
void fileTypeDetection()
{
QCOMPARE(Denote::fileTypeFromExtension(QStringLiteral(".md")), Denote::FileType::MdYaml);
QCOMPARE(Denote::fileTypeFromExtension(QStringLiteral(".org")), Denote::FileType::Org);
QCOMPARE(Denote::fileTypeFromExtension(QStringLiteral(".txt")), Denote::FileType::Txt);
QCOMPARE(Denote::fileTypeFromExtension(QStringLiteral(".pdf")), Denote::FileType::Unknown);
QVERIFY(Denote::supportsFrontMatter(QStringLiteral(".md")));
QVERIFY(!Denote::supportsFrontMatter(QStringLiteral(".png")));
}
};
QTEST_MAIN(TestFrontMatter)
#include "test_frontmatter.moc"

View File

@ -0,0 +1,77 @@
#include <QObject>
#include <QTest>
#include "denote_metadata.h"
class TestMetadata : public QObject
{
Q_OBJECT
private Q_SLOTS:
void parseBasic()
{
auto m = Denote::parseFilename(
QStringLiteral("/home/user/doc/20240315T120000--my-test-note__journal_daily.md"));
QCOMPARE(m.identifier, QStringLiteral("20240315T120000"));
QCOMPARE(m.title, QStringLiteral("my test note"));
QCOMPARE(m.tags, QStringList({QStringLiteral("journal"), QStringLiteral("daily")}));
QVERIFY(m.signature.isEmpty());
QVERIFY(m.isValid());
}
void parseWithSignature()
{
auto m = Denote::parseFilename(
QStringLiteral("/doc/20240315T120000==1a--my-note__tag1.org"));
QCOMPARE(m.identifier, QStringLiteral("20240315T120000"));
QCOMPARE(m.signature, QStringLiteral("1a"));
QCOMPARE(m.title, QStringLiteral("my note"));
QCOMPARE(m.tags, QStringList({QStringLiteral("tag1")}));
}
void parseNoTags()
{
auto m = Denote::parseFilename(
QStringLiteral("/doc/20240101T090000--some-title.md"));
QCOMPARE(m.identifier, QStringLiteral("20240101T090000"));
QCOMPARE(m.title, QStringLiteral("some title"));
QVERIFY(m.tags.isEmpty());
}
void parseNonDenote()
{
auto m = Denote::parseFilename(QStringLiteral("/doc/regular-file.txt"));
QVERIFY(!m.isValid());
}
void slugifyBasic()
{
QCOMPARE(Denote::slugifyTitle(QStringLiteral("My Test Title")),
QStringLiteral("my-test-title"));
QCOMPARE(Denote::slugifyTitle(QStringLiteral("Hello_World!")),
QStringLiteral("hello-world"));
}
void tagValidation()
{
QVERIFY(Denote::isValidTag(QStringLiteral("journal")));
QVERIFY(Denote::isValidTag(QStringLiteral("tag123")));
QVERIFY(!Denote::isValidTag(QStringLiteral("Tag")));
QVERIFY(!Denote::isValidTag(QStringLiteral("has space")));
QVERIFY(!Denote::isValidTag(QStringLiteral("")));
}
void creationDate()
{
auto m = Denote::parseFilename(
QStringLiteral("/doc/20240315T143022--title.md"));
QDateTime expected(QDate(2024, 3, 15), QTime(14, 30, 22));
QCOMPARE(m.creationDate(), expected);
}
};
QTEST_MAIN(TestMetadata)
#include "test_metadata.moc"