#include "documentmodel.h" #include "orkclient.h" #include #include #include #include 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 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 &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 //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 ×tamp) { 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 ×tamp) { 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 §ionId, 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 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(); } }