orkmode/ui/src/documentmodel.cpp

690 lines
20 KiB
C++

#include "documentmodel.h"
#include "orkclient.h"
#include <QDebug>
#include <QDesktopServices>
#include <QRegularExpression>
#include <QUrl>
static const QStringList DONE_KEYWORDS = {"DONE", "CANCELLED", "CANCELED"};
DocumentModel::DocumentModel(QObject *parent)
: QAbstractListModel(parent)
{
}
void DocumentModel::setClient(OrkClient *client)
{
m_client = client;
}
void DocumentModel::setDocument(const QString &doc)
{
if (doc == m_document)
return;
m_document = doc;
loadDocument();
emit documentChanged();
}
int DocumentModel::rowCount(const QModelIndex &parent) const
{
if (parent.isValid())
return 0;
return m_visibleRows.size();
}
QVariant DocumentModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid() || index.row() < 0 || index.row() >= m_visibleRows.size())
return {};
const QString &id = m_visibleRows[index.row()];
if (!m_sections.contains(id))
return {};
Section &sec = m_sections[id];
// Lazy load fields
if (!sec.fieldsLoaded)
loadFields(sec);
switch (role) {
case TitleRole:
return sec.title;
case KeywordRole:
return sec.keyword;
case PriorityRole:
return sec.priority;
case TagsRole:
return sec.tags;
case LevelRole:
return sec.level;
case ScheduledRole:
return sec.scheduled;
case DeadlineRole:
return sec.deadline;
case SectionIdRole:
return sec.id;
case PathRole:
return sec.path;
case IsTodoRole:
return !sec.keyword.isEmpty();
case IsDoneRole:
return DONE_KEYWORDS.contains(sec.keyword);
case BodyRole:
if (!sec.bodyLoaded)
loadBody(sec);
return sec.body;
case PropertiesRole:
if (!sec.propertiesLoaded)
loadProperties(sec);
return sec.properties;
case HasChildrenRole:
return !sec.childIds.isEmpty();
case ExpandedRole:
return m_expanded.contains(id);
default:
return {};
}
}
QHash<int, QByteArray> DocumentModel::roleNames() const
{
return {
{TitleRole, "title"},
{KeywordRole, "keyword"},
{PriorityRole, "priority"},
{TagsRole, "tags"},
{LevelRole, "level"},
{ScheduledRole, "scheduled"},
{DeadlineRole, "deadline"},
{SectionIdRole, "sectionId"},
{PathRole, "path"},
{IsTodoRole, "isTodo"},
{IsDoneRole, "isDone"},
{BodyRole, "body"},
{PropertiesRole, "properties"},
{HasChildrenRole, "hasChildren"},
{ExpandedRole, "expanded"},
};
}
void DocumentModel::toggleExpanded(int row)
{
if (row < 0 || row >= m_visibleRows.size())
return;
const QString &id = m_visibleRows[row];
if (!m_sections.contains(id))
return;
const Section &sec = m_sections[id];
if (sec.childIds.isEmpty())
return; // No children to expand/collapse
if (m_expanded.contains(id))
m_expanded.remove(id);
else
m_expanded.insert(id);
// Rebuild visible rows and notify
rebuildVisibleRows();
}
void DocumentModel::reload()
{
loadDocument();
}
QString DocumentModel::executeBlock(int row, int blockIndex)
{
if (!m_client || m_document.isEmpty())
return QStringLiteral("error: not connected");
if (row < 0 || row >= m_visibleRows.size())
return QStringLiteral("error: invalid row");
const QString &id = m_visibleRows[row];
if (!m_sections.contains(id))
return QStringLiteral("error: unknown section");
// Document name without a leading slash for the ctl verb.
QString doc = m_document;
if (doc.startsWith('/'))
doc = doc.mid(1);
const QString cmd = QStringLiteral("execute %1 %2 %3")
.arg(doc, id)
.arg(blockIndex);
const QString result = m_client->rdwr(QStringLiteral("/ctl"), cmd);
// Refresh the section body so the inserted #+RESULTS: block is rendered.
Section &sec = m_sections[id];
sec.bodyLoaded = false;
loadBody(sec);
const QModelIndex idx = index(row);
emit dataChanged(idx, idx, {BodyRole});
return result;
}
bool DocumentModel::isLanguageSupported(const QString &lang)
{
if (lang.isEmpty())
return false;
if (!m_languagesLoaded) {
m_supportedLanguages.clear();
if (m_client) {
const QString out = m_client->rdwr(QStringLiteral("/ctl"),
QStringLiteral("languages"));
for (const QString &line : out.split('\n', Qt::SkipEmptyParts))
m_supportedLanguages.append(line.trimmed().toLower());
}
m_languagesLoaded = true;
}
return m_supportedLanguages.contains(lang.trimmed().toLower());
}
bool DocumentModel::rewriteCheckbox(
int row, int checkboxOrdinal,
const std::function<QString(const QString &)> &nextState)
{
if (!m_client || row < 0 || row >= m_visibleRows.size())
return false;
const QString &id = m_visibleRows[row];
if (!m_sections.contains(id))
return false;
Section &sec = m_sections[id];
if (!sec.bodyLoaded)
loadBody(sec);
// Match a list item's checkbox cookie: leading indent, a bullet (- / +) or
// ordered marker (N. / N)), then a [ ]/[x]/[X]/[-] cookie.
static const QRegularExpression cookieRe(
QStringLiteral("^(\\s*(?:[-+]|\\d+[.)])\\s+\\[)([ xX-])(\\])"));
const QStringList lines = sec.body.split('\n');
QStringList out;
out.reserve(lines.size());
int seen = -1;
bool changed = false;
for (const QString &line : lines) {
QRegularExpressionMatch m = cookieRe.match(line);
if (m.hasMatch()) {
++seen;
if (seen == checkboxOrdinal) {
const QString next = nextState(m.captured(2));
QString updated = line;
updated.replace(m.capturedStart(2), m.capturedLength(2), next);
out.append(updated);
changed = true;
continue;
}
}
out.append(line);
}
if (!changed)
return false;
const QString newBody = out.join('\n');
if (!m_client->write(sec.path + QStringLiteral("/body"), newBody))
return false;
// Refresh from the server so the parsed state matches what was written.
sec.bodyLoaded = false;
loadBody(sec);
const QModelIndex idx = index(row);
emit dataChanged(idx, idx, {BodyRole});
return true;
}
bool DocumentModel::toggleCheckbox(int row, int checkboxOrdinal)
{
// Checked -> unchecked; unchecked/partial -> checked.
return rewriteCheckbox(row, checkboxOrdinal, [](const QString &state) {
const bool checked = (state == QLatin1String("X") ||
state == QLatin1String("x"));
return checked ? QStringLiteral(" ") : QStringLiteral("X");
});
}
bool DocumentModel::cycleCheckbox(int row, int checkboxOrdinal, bool forward)
{
// Cycle order: unchecked ( ) -> partial (-) -> checked (X) -> wrap.
return rewriteCheckbox(row, checkboxOrdinal, [forward](const QString &state) {
// Normalize current state to an index in the cycle.
int cur;
if (state == QLatin1String("-"))
cur = 1; // partial
else if (state == QLatin1String("X") || state == QLatin1String("x"))
cur = 2; // checked
else
cur = 0; // unchecked
const int next = forward ? (cur + 1) % 3 : (cur + 2) % 3;
switch (next) {
case 1: return QStringLiteral("-");
case 2: return QStringLiteral("X");
default: return QStringLiteral(" ");
}
});
}
bool DocumentModel::setKeyword(int row, const QString &keyword)
{
if (!m_client || row < 0 || row >= m_visibleRows.size())
return false;
const QString &id = m_visibleRows[row];
if (!m_sections.contains(id))
return false;
Section &sec = m_sections[id];
if (!m_client->write(sec.path + QStringLiteral("/keyword"), keyword))
return false;
// Refresh the section's fields so the model reflects the written headline.
sec.fieldsLoaded = false;
loadFields(sec);
const QModelIndex idx = index(row);
emit dataChanged(idx, idx, {KeywordRole, IsTodoRole, IsDoneRole, TitleRole});
return true;
}
bool DocumentModel::cycleKeyword(int row, bool forward)
{
if (!m_client || row < 0 || row >= m_visibleRows.size())
return false;
const QString &id = m_visibleRows[row];
if (!m_sections.contains(id))
return false;
Section &sec = m_sections[id];
if (!sec.fieldsLoaded)
loadFields(sec);
// Build the cycle from this document's effective keyword set, then a
// trailing empty state so cycling can clear the keyword. Reading the
// per-document /<doc>/keywords (not the global default) ensures we only
// cycle to keywords the parser recognizes for this document — otherwise an
// unrecognized keyword would merge into the title and change the section
// id. The set separates not-done and done states with '|', which is not a
// keyword.
QString doc = m_document;
if (doc.startsWith('/'))
doc = doc.mid(1);
const QString kwLine = m_client->read(QStringLiteral("/%1/keywords").arg(doc));
QStringList states;
for (const QString &tok : kwLine.split(QRegularExpression(QStringLiteral("\\s+")),
Qt::SkipEmptyParts)) {
if (tok == QLatin1String("|"))
continue;
states.append(tok);
}
if (states.isEmpty())
return false;
states.append(QString()); // trailing empty = "no keyword"
// Find the current keyword's position; default to the empty state so the
// first forward step lands on the first keyword.
int cur = states.indexOf(sec.keyword);
if (cur < 0)
cur = states.size() - 1; // treat unknown as the empty state
const int n = states.size();
const int next = forward ? (cur + 1) % n : (cur - 1 + n) % n;
return setKeyword(row, states[next]);
}
QStringList DocumentModel::keywordChoices()
{
QStringList choices;
if (!m_client)
return choices;
QString doc = m_document;
if (doc.startsWith('/'))
doc = doc.mid(1);
const QString kwLine = m_client->read(QStringLiteral("/%1/keywords").arg(doc));
for (const QString &tok : kwLine.split(QRegularExpression(QStringLiteral("\\s+")),
Qt::SkipEmptyParts)) {
if (tok == QLatin1String("|"))
continue;
choices.append(tok);
}
return choices;
}
bool DocumentModel::setPriority(int row, const QString &priority)
{
if (!m_client || row < 0 || row >= m_visibleRows.size())
return false;
const QString &id = m_visibleRows[row];
if (!m_sections.contains(id))
return false;
Section &sec = m_sections[id];
if (!m_client->write(sec.path + QStringLiteral("/priority"), priority))
return false;
sec.fieldsLoaded = false;
loadFields(sec);
const QModelIndex idx = index(row);
emit dataChanged(idx, idx, {PriorityRole});
return true;
}
bool DocumentModel::cyclePriority(int row, bool forward)
{
if (!m_client || row < 0 || row >= m_visibleRows.size())
return false;
const QString &id = m_visibleRows[row];
if (!m_sections.contains(id))
return false;
Section &sec = m_sections[id];
if (!sec.fieldsLoaded)
loadFields(sec);
// Cycle: (none) -> A -> B -> C -> (none).
static const QStringList states = {QString(), QStringLiteral("A"),
QStringLiteral("B"), QStringLiteral("C")};
int cur = states.indexOf(sec.priority);
if (cur < 0)
cur = 0; // treat unknown as the empty state
const int n = states.size();
const int next = forward ? (cur + 1) % n : (cur - 1 + n) % n;
return setPriority(row, states[next]);
}
bool DocumentModel::setTags(int row, const QString &tags)
{
if (!m_client || row < 0 || row >= m_visibleRows.size())
return false;
const QString &id = m_visibleRows[row];
if (!m_sections.contains(id))
return false;
Section &sec = m_sections[id];
if (!m_client->write(sec.path + QStringLiteral("/tags"), tags))
return false;
sec.fieldsLoaded = false;
loadFields(sec);
const QModelIndex idx = index(row);
emit dataChanged(idx, idx, {TagsRole});
return true;
}
bool DocumentModel::setScheduled(int row, const QString &timestamp)
{
if (!m_client || row < 0 || row >= m_visibleRows.size())
return false;
const QString &id = m_visibleRows[row];
if (!m_sections.contains(id))
return false;
Section &sec = m_sections[id];
if (!m_client->write(sec.path + QStringLiteral("/scheduled"), timestamp))
return false;
sec.fieldsLoaded = false;
loadFields(sec);
const QModelIndex idx = index(row);
emit dataChanged(idx, idx, {ScheduledRole});
return true;
}
bool DocumentModel::setDeadline(int row, const QString &timestamp)
{
if (!m_client || row < 0 || row >= m_visibleRows.size())
return false;
const QString &id = m_visibleRows[row];
if (!m_sections.contains(id))
return false;
Section &sec = m_sections[id];
if (!m_client->write(sec.path + QStringLiteral("/deadline"), timestamp))
return false;
sec.fieldsLoaded = false;
loadFields(sec);
const QModelIndex idx = index(row);
emit dataChanged(idx, idx, {DeadlineRole});
return true;
}
int DocumentModel::revealSection(const QString &sectionId, int bodyOffset)
{
if (sectionId.isEmpty() || !m_sections.contains(sectionId))
return -1;
// Walk up the parent chain and expand every ancestor so the target becomes
// visible. (The target itself is not expanded — only revealed.)
QString cur = m_sections[sectionId].parentId;
bool changed = false;
while (!cur.isEmpty() && m_sections.contains(cur)) {
if (!m_expanded.contains(cur)) {
m_expanded.insert(cur);
changed = true;
}
cur = m_sections[cur].parentId;
}
if (changed)
rebuildVisibleRows();
const int row = m_visibleRows.indexOf(sectionId);
if (row >= 0)
emit sectionRevealed(row, bodyOffset);
return row;
}
QString DocumentModel::followLink(const QString &target)
{
if (!m_client || m_document.isEmpty())
return QStringLiteral("error");
QString doc = m_document;
if (doc.startsWith('/'))
doc = doc.mid(1);
// Resolve the target via the document's /resolve file.
const QString resp =
m_client->rdwr(QStringLiteral("/%1/resolve").arg(doc), target);
// Parse the small "key: value" blob.
QString kind;
QString section;
QString targetDoc;
int offset = 0;
for (const QString &line : resp.split('\n', Qt::SkipEmptyParts)) {
const int colon = line.indexOf(':');
if (colon < 0)
continue;
const QString key = line.left(colon).trimmed();
const QString val = line.mid(colon + 1).trimmed();
if (key == QLatin1String("kind"))
kind = val;
else if (key == QLatin1String("section"))
section = val;
else if (key == QLatin1String("doc"))
targetDoc = val;
else if (key == QLatin1String("offset"))
offset = val.toInt();
}
if (kind == QLatin1String("external")) {
QDesktopServices::openUrl(QUrl(target));
} else if (kind == QLatin1String("dedicated") || kind == QLatin1String("fuzzy")) {
// The resolver may hand back a target in another document (file: links,
// globally unique id: links, cross-file headline fallback). Switch the
// model to the owning document before revealing the section.
if (!targetDoc.isEmpty() && targetDoc != doc)
setDocument(targetDoc);
if (!section.isEmpty())
revealSection(section, offset);
}
return kind.isEmpty() ? QStringLiteral("notfound") : kind;
}
void DocumentModel::loadDocument()
{
beginResetModel();
m_sections.clear();
m_topLevelIds.clear();
m_expanded.clear();
m_visibleRows.clear();
if (!m_client || m_document.isEmpty()) {
endResetModel();
return;
}
// Load top-level sections (path needs leading /)
QString docPath = m_document.startsWith('/') ? m_document : ("/" + m_document);
loadSections(docPath, QString(), 1);
// Start with all sections expanded
for (const QString &id : m_sections.keys())
m_expanded.insert(id);
rebuildVisibleRows();
endResetModel();
}
void DocumentModel::loadSections(const QString &parentPath, const QString &parentId, int level)
{
// Use listDirs to only get directories (actual sections), not files
QStringList entries = m_client->listDirs(parentPath);
QStringList sectionIds;
for (const QString &entry : entries) {
// Skip special directories
if (entry == "new")
continue;
Section sec;
sec.id = entry;
sec.path = parentPath + "/" + entry;
sec.level = level;
sec.parentId = parentId;
m_sections[entry] = sec;
sectionIds.append(entry);
// Recursively load children of this section
loadSections(sec.path, entry, level + 1);
}
// Populate childIds for each section at this level
for (const QString &id : sectionIds) {
Section &sec = m_sections[id];
QStringList childEntries = m_client->listDirs(sec.path);
for (const QString &childEntry : childEntries) {
if (childEntry != "new" && m_sections.contains(childEntry) && m_sections[childEntry].parentId == id) {
sec.childIds.append(childEntry);
}
}
}
// Store top-level section ids
if (parentId.isEmpty()) {
m_topLevelIds = sectionIds;
}
}
void DocumentModel::loadFields(Section &sec) const
{
if (!m_client || sec.fieldsLoaded)
return;
sec.title = m_client->read(sec.path + "/title").trimmed();
sec.keyword = m_client->read(sec.path + "/keyword").trimmed();
sec.priority = m_client->read(sec.path + "/priority").trimmed();
sec.scheduled = m_client->read(sec.path + "/scheduled").trimmed();
sec.deadline = m_client->read(sec.path + "/deadline").trimmed();
QString tagsStr = m_client->read(sec.path + "/tags").trimmed();
if (!tagsStr.isEmpty())
sec.tags = tagsStr.split(':');
// Level is already set from tree structure
sec.fieldsLoaded = true;
}
void DocumentModel::loadBody(Section &sec) const
{
if (!m_client || sec.bodyLoaded)
return;
sec.body = m_client->read(sec.path + "/body");
sec.bodyLoaded = true;
}
void DocumentModel::loadProperties(Section &sec) const
{
if (!m_client || sec.propertiesLoaded)
return;
QString propsStr = m_client->read(sec.path + "/properties").trimmed();
if (!propsStr.isEmpty()) {
for (const QString &line : propsStr.split('\n')) {
int eq = line.indexOf('=');
if (eq > 0) {
QString key = line.left(eq).trimmed();
QString value = line.mid(eq + 1).trimmed();
sec.properties[key] = value;
}
}
}
sec.propertiesLoaded = true;
}
void DocumentModel::rebuildVisibleRows()
{
QStringList oldRows = m_visibleRows;
m_visibleRows.clear();
// DFS traversal respecting expansion state
std::function<void(const QStringList &)> addSections = [&](const QStringList &ids) {
for (const QString &id : ids) {
if (!m_sections.contains(id))
continue;
m_visibleRows.append(id);
const Section &sec = m_sections[id];
if (m_expanded.contains(id) && !sec.childIds.isEmpty()) {
addSections(sec.childIds);
}
}
};
addSections(m_topLevelIds);
// Emit change signals
if (m_visibleRows != oldRows) {
emit dataChanged(index(0), index(rowCount() - 1));
emit layoutChanged();
}
}