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:
commit
063c536a89
|
|
@ -0,0 +1,10 @@
|
|||
build/
|
||||
*.o
|
||||
*.so
|
||||
*.a
|
||||
CMakeFiles/
|
||||
CMakeCache.txt
|
||||
cmake_install.cmake
|
||||
Makefile
|
||||
.cache/
|
||||
compile_commands.json
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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" }
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
@ -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"]
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
Loading…
Reference in New Issue