commit 063c536a89e1dc84290a586dc0ab12f4d803d3c6 Author: Levi Neely <141506390+lneely@users.noreply.github.com> Date: Sun Jul 12 20:21:11 2026 +0200 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. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f0b81fe --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +build/ +*.o +*.so +*.a +CMakeFiles/ +CMakeCache.txt +cmake_install.cmake +Makefile +.cache/ +compile_commands.json diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..bab1388 --- /dev/null +++ b/CMakeLists.txt @@ -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) diff --git a/README.md b/README.md new file mode 100644 index 0000000..f7293cb --- /dev/null +++ b/README.md @@ -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 diff --git a/extractor/CMakeLists.txt b/extractor/CMakeLists.txt new file mode 100644 index 0000000..049e308 --- /dev/null +++ b/extractor/CMakeLists.txt @@ -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 +) diff --git a/extractor/denote_extractor.cpp b/extractor/denote_extractor.cpp new file mode 100644 index 0000000..e45769b --- /dev/null +++ b/extractor/denote_extractor.cpp @@ -0,0 +1,102 @@ +#include "denote_extractor.h" + +#include "denote_frontmatter.h" +#include "denote_metadata.h" + +#include +#include +#include + +#include +#include + +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); + } + } +} diff --git a/extractor/denote_extractor.h b/extractor/denote_extractor.h new file mode 100644 index 0000000..d0bfcda --- /dev/null +++ b/extractor/denote_extractor.h @@ -0,0 +1,20 @@ +#ifndef KDENOTE_EXTRACTOR_H +#define KDENOTE_EXTRACTOR_H + +#include + +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 diff --git a/extractor/denote_extractor.json b/extractor/denote_extractor.json new file mode 100644 index 0000000..1c0eb44 --- /dev/null +++ b/extractor/denote_extractor.json @@ -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" } + } +} diff --git a/fileitemaction/CMakeLists.txt b/fileitemaction/CMakeLists.txt new file mode 100644 index 0000000..69557c9 --- /dev/null +++ b/fileitemaction/CMakeLists.txt @@ -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 +) diff --git a/fileitemaction/denote_fileitemaction.cpp b/fileitemaction/denote_fileitemaction.cpp new file mode 100644 index 0000000..ebe08ab --- /dev/null +++ b/fileitemaction/denote_fileitemaction.cpp @@ -0,0 +1,156 @@ +#include "denote_fileitemaction.h" + +#include "denote_frontmatter.h" +#include "denote_metadata.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +K_PLUGIN_CLASS_WITH_JSON(DenoteFileItemAction, "denote_fileitemaction.json") + +DenoteFileItemAction::DenoteFileItemAction(QObject *parent, const QVariantList &) + : KAbstractFileItemActionPlugin(parent) +{ +} + +QList 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" diff --git a/fileitemaction/denote_fileitemaction.h b/fileitemaction/denote_fileitemaction.h new file mode 100644 index 0000000..948530a --- /dev/null +++ b/fileitemaction/denote_fileitemaction.h @@ -0,0 +1,21 @@ +#ifndef KDENOTE_FILEITEMACTION_H +#define KDENOTE_FILEITEMACTION_H + +#include +#include + +class DenoteFileItemAction : public KAbstractFileItemActionPlugin +{ + Q_OBJECT + +public: + explicit DenoteFileItemAction(QObject *parent, const QVariantList &args = {}); + + QList actions(const KFileItemListProperties &fileItemInfos, + QWidget *parentWidget) override; + +private: + void renameWithDenote(const KFileItemList &items, QWidget *parentWidget); +}; + +#endif // KDENOTE_FILEITEMACTION_H diff --git a/fileitemaction/denote_fileitemaction.json b/fileitemaction/denote_fileitemaction.json new file mode 100644 index 0000000..a40bbad --- /dev/null +++ b/fileitemaction/denote_fileitemaction.json @@ -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"] + } +} diff --git a/lib/CMakeLists.txt b/lib/CMakeLists.txt new file mode 100644 index 0000000..df76fae --- /dev/null +++ b/lib/CMakeLists.txt @@ -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() diff --git a/lib/denote_frontmatter.cpp b/lib/denote_frontmatter.cpp new file mode 100644 index 0000000..db60c73 --- /dev/null +++ b/lib/denote_frontmatter.cpp @@ -0,0 +1,306 @@ +#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 diff --git a/lib/denote_frontmatter.h b/lib/denote_frontmatter.h new file mode 100644 index 0000000..1c4c26b --- /dev/null +++ b/lib/denote_frontmatter.h @@ -0,0 +1,43 @@ +#ifndef KDENOTE_FRONTMATTER_H +#define KDENOTE_FRONTMATTER_H + +#include +#include + +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 diff --git a/lib/denote_metadata.cpp b/lib/denote_metadata.cpp new file mode 100644 index 0000000..1313abf --- /dev/null +++ b/lib/denote_metadata.cpp @@ -0,0 +1,123 @@ +#include "denote_metadata.h" + +#include +#include + +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 diff --git a/lib/denote_metadata.h b/lib/denote_metadata.h new file mode 100644 index 0000000..08ae733 --- /dev/null +++ b/lib/denote_metadata.h @@ -0,0 +1,47 @@ +#ifndef KDENOTE_METADATA_H +#define KDENOTE_METADATA_H + +#include +#include +#include + +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 diff --git a/lib/tests/CMakeLists.txt b/lib/tests/CMakeLists.txt new file mode 100644 index 0000000..a90d29e --- /dev/null +++ b/lib/tests/CMakeLists.txt @@ -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) diff --git a/lib/tests/test_frontmatter.cpp b/lib/tests/test_frontmatter.cpp new file mode 100644 index 0000000..901fef8 --- /dev/null +++ b/lib/tests/test_frontmatter.cpp @@ -0,0 +1,148 @@ +#include +#include + +#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" diff --git a/lib/tests/test_metadata.cpp b/lib/tests/test_metadata.cpp new file mode 100644 index 0000000..6ca9eb1 --- /dev/null +++ b/lib/tests/test_metadata.cpp @@ -0,0 +1,77 @@ +#include +#include + +#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"