Skip to content

Commit 486fc0f

Browse files
committed
Add batch tag editing for multiple selected files
Select several notes in a bundled notebook, choose Tags, and add or remove tags on all of them through the existing ViewTagsDialog2 surface. TagViewer2 becomes multi-node with three per-tag states: All (every target has it), Partial (some do, shown italic with an "N of M" tooltip) and None. Clicking cycles Partial/None to All and All to None, and the dialog yields addedTags()/removedTags(). Only that delta is written, via the incremental TagService::tagFile/untagFile primitives, so a tag left Partial and any tag outside the list are never touched and no file's tag array is overwritten. Persistence goes through the controller per the multi-target batch pattern: manageTagsRequested carries a QList, NotebookExplorer2 shows ONE dialog, and the per-id handleTagDeltaResult is looped in the view. Targets are the current selection filtered to valid, non-folder, non-root nodes of the clicked notebook; the same predicate greys out the action. TagPopup2 keeps the legacy single-node save() path unchanged. vxcore's tag primitives are not idempotent, so applyTagDelta pre-reads each file and drops the ops that would be no-ops on it; every failure that then comes back is genuine. Unreadable targets are excluded from the tri-state rather than counted as untagged, since getFileInfo returns an empty object on failure. TagModel coalesces its reload so a batch resets the tag tree once instead of once per tag per file.
1 parent 1de75a3 commit 486fc0f

27 files changed

Lines changed: 1371 additions & 107 deletions

‎src/controllers/CMakeLists.txt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ target_sources(vnote PRIVATE
55
notebooknodecontroller.cpp notebooknodecontroller.h
66
notebooknodecontroller_reorder.cpp
77
notebooknodecontroller_shareseam.cpp
8+
notebooknodecontroller_tagseam.cpp
89
newnotebookcontroller.cpp newnotebookcontroller.h
910
newnotecontroller.cpp newnotecontroller.h
1011
newfoldercontroller.cpp newfoldercontroller.h

‎src/controllers/notebooknodecontroller.cpp‎

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
#include <core/servicelocator.h>
2222
#include <core/services/bufferservice.h>
2323
#include <core/services/notebookcoreservice.h>
24+
#include <core/services/tagservice.h>
2425
#include <core/services/workspacecoreservice.h>
2526
#include <core/sessionconfig.h>
2627
#include <core/widgetconfig.h>
@@ -192,6 +193,15 @@ bool NotebookNodeController::isSingleEffectiveSelection(const NodeIdentifier &p_
192193
return resolveSelection(p_clickedId).size() == 1;
193194
}
194195

196+
QList<NodeIdentifier>
197+
NotebookNodeController::resolveTagTargets(const NodeIdentifier &p_clickedId) const {
198+
if (!p_clickedId.isValid()) {
199+
return {};
200+
}
201+
return filterTagTargets(resolveSelection(p_clickedId), p_clickedId,
202+
[this](const NodeIdentifier &p_id) { return getNodeInfo(p_id); });
203+
}
204+
195205
QMenu *NotebookNodeController::createContextMenu(const NodeIdentifier &p_nodeId,
196206
QWidget *p_parent) {
197207
// Get node info to determine if it's a folder or external
@@ -497,7 +507,7 @@ void NotebookNodeController::addInfoActions(QMenu *p_menu, const NodeIdentifier
497507
auto *tagAction = p_menu->addAction(tr("&Tags"));
498508
connect(tagAction, &QAction::triggered, this,
499509
[this, p_nodeId]() { manageNodeTags(p_nodeId); });
500-
tagAction->setEnabled(isSingleEffectiveSelection(p_nodeId) && !p_readOnly);
510+
tagAction->setEnabled(!p_readOnly && !resolveTagTargets(p_nodeId).isEmpty());
501511
}
502512
}
503513
}
@@ -1031,7 +1041,11 @@ void NotebookNodeController::manageNodeTags(const NodeIdentifier &p_nodeId) {
10311041
if (isNotebookReadOnly(p_nodeId.notebookId)) {
10321042
return;
10331043
}
1034-
emit manageTagsRequested(p_nodeId);
1044+
const auto ids = resolveTagTargets(p_nodeId);
1045+
if (ids.isEmpty()) {
1046+
return;
1047+
}
1048+
emit manageTagsRequested(ids);
10351049
}
10361050

10371051
bool NotebookNodeController::canPaste() const { return !m_clipboard->nodes.isEmpty(); }
@@ -1192,6 +1206,56 @@ void NotebookNodeController::handleMarkResult(const NodeIdentifier &p_nodeId,
11921206
}
11931207
}
11941208

1209+
bool NotebookNodeController::handleTagDeltaResult(const NodeIdentifier &p_nodeId,
1210+
const QSet<QString> &p_added,
1211+
const QSet<QString> &p_removed) {
1212+
if (p_added.isEmpty() && p_removed.isEmpty()) {
1213+
return true;
1214+
}
1215+
if (!p_nodeId.isValid() || isNotebookReadOnly(p_nodeId.notebookId)) {
1216+
return false;
1217+
}
1218+
1219+
const NodeInfo nodeInfo = getNodeInfo(p_nodeId);
1220+
if (!nodeInfo.isValid() || nodeInfo.isFolder || nodeInfo.isRoot()) {
1221+
return false;
1222+
}
1223+
1224+
auto *notebookService = m_services.get<NotebookCoreService>();
1225+
auto *tagService = m_services.get<TagService>();
1226+
if (!notebookService || !tagService) {
1227+
return false;
1228+
}
1229+
1230+
// Orchestration (pre-read, plan, issue, aggregate) lives in
1231+
// notebooknodecontroller_tagseam.cpp so it is covered by a real controller
1232+
// test; this method only binds it to the services.
1233+
TagDeltaIo io;
1234+
io.readTags = [notebookService, &p_nodeId](QSet<QString> &p_out) {
1235+
VxCoreError err = VXCORE_OK;
1236+
const auto fileInfo =
1237+
notebookService->getFileInfo(p_nodeId.notebookId, p_nodeId.relativePath, &err);
1238+
if (err != VXCORE_OK) {
1239+
// getFileInfo returns an empty object on failure, indistinguishable from
1240+
// "no tags".
1241+
return false;
1242+
}
1243+
const auto tagsArray = fileInfo.value(QLatin1String(vxcore::kJsonKeyTags)).toArray();
1244+
for (const auto &tagVal : tagsArray) {
1245+
p_out.insert(tagVal.toString());
1246+
}
1247+
return true;
1248+
};
1249+
io.tagFile = [tagService, &p_nodeId](const QString &p_tag) {
1250+
return tagService->tagFile(p_nodeId.notebookId, p_nodeId.relativePath, p_tag);
1251+
};
1252+
io.untagFile = [tagService, &p_nodeId](const QString &p_tag) {
1253+
return tagService->untagFile(p_nodeId.notebookId, p_nodeId.relativePath, p_tag);
1254+
};
1255+
1256+
return applyTagDelta(io, p_added, p_removed);
1257+
}
1258+
11951259
void NotebookNodeController::handleDeleteConfirmed(const QList<NodeIdentifier> &p_nodeIds,
11961260
bool p_permanent) {
11971261
if (p_nodeIds.isEmpty()) {

‎src/controllers/notebooknodecontroller.h‎

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,15 @@ public slots:
162162
void handleRenameResult(const NodeIdentifier &p_nodeId, const QString &p_newName);
163163
void handleMarkResult(const NodeIdentifier &p_nodeId, const QString &p_textColor,
164164
const QString &p_bgColor);
165+
// Per-id apply primitive for the batch Tags flow. Applies the DELTA produced
166+
// by ViewTagsDialog2: tags in p_added are applied with TagService::tagFile,
167+
// tags in p_removed are dropped with TagService::untagFile. Deliberately NOT
168+
// a whole-array rewrite (updateFileTags) — a read-modify-write would erase
169+
// concurrent/hook-driven tag changes and would silently succeed for a
170+
// remove-only delta when getFileInfo fails. Re-checks read-only + eligibility.
171+
// Returns false if any call fails.
172+
bool handleTagDeltaResult(const NodeIdentifier &p_nodeId, const QSet<QString> &p_added,
173+
const QSet<QString> &p_removed);
165174
void handleDeleteConfirmed(const QList<NodeIdentifier> &p_nodeIds, bool p_permanent);
166175
void handleRemoveConfirmed(const QList<NodeIdentifier> &p_nodeIds);
167176
void handleImportFiles(const NodeIdentifier &p_targetFolderId, const QStringList &p_files);
@@ -216,7 +225,7 @@ public slots:
216225

217226
void ignoreRequested(const NodeIdentifier &p_nodeId);
218227

219-
void manageTagsRequested(const NodeIdentifier &p_nodeId);
228+
void manageTagsRequested(const QList<NodeIdentifier> &p_ids);
220229

221230
// T7 (notebook-explorer-drag-reorder): emitted after the service confirms
222231
// a successful folder-children reorder. The View should reload the folder
@@ -253,6 +262,55 @@ public slots:
253262
static bool isFolderShareEligible(const NodeInfo &p_nodeInfo, bool p_notebookIsBundled,
254263
bool p_singleEffectiveSelection);
255264

265+
// Pure target-filter behind resolveTagTargets(), extracted into
266+
// notebooknodecontroller_tagseam.cpp so controller tests can link the REAL
267+
// production predicate without the controller's 18+ transitive service deps.
268+
// p_infoLookup plays the role of getNodeInfo(); an unresolved id must yield a
269+
// default-constructed NodeInfo, exactly as the controller sees it.
270+
//
271+
// Keeps only ids that resolve to a valid, non-folder, non-root node in the
272+
// SAME notebook as the clicked node, de-duplicated and order-preserving.
273+
using NodeInfoLookup = std::function<NodeInfo(const NodeIdentifier &)>;
274+
static QList<NodeIdentifier> filterTagTargets(const QList<NodeIdentifier> &p_resolvedSelection,
275+
const NodeIdentifier &p_clickedId,
276+
const NodeInfoLookup &p_infoLookup);
277+
278+
// The ops handleTagDeltaResult actually issues for ONE file, after dropping
279+
// the ones that would be no-ops on it. Sorted, so the order is deterministic.
280+
struct TagDeltaOps {
281+
QStringList toAdd;
282+
QStringList toRemove;
283+
};
284+
285+
// Pure delta planner behind handleTagDeltaResult, extracted into
286+
// notebooknodecontroller_tagseam.cpp alongside filterTagTargets so controller
287+
// tests can link the REAL production decision.
288+
//
289+
// vxcore's incremental primitives are NOT idempotent (TagFile returns
290+
// VXCORE_ERR_ALREADY_EXISTS, UntagFile returns VXCORE_ERR_NOT_FOUND), and a
291+
// delta derived from a Partial tag necessarily hits both across a batch. So a
292+
// tag already present is dropped from toAdd and a tag already absent is dropped
293+
// from toRemove, leaving every remaining failure a genuine one.
294+
static TagDeltaOps planTagDelta(const QSet<QString> &p_currentTags, const QSet<QString> &p_added,
295+
const QSet<QString> &p_removed);
296+
297+
// I/O seam for applyTagDelta, so the orchestration can be driven without the
298+
// controller's services. readTags returns false when the file's current tags
299+
// could NOT be determined; tagFile / untagFile return false on failure.
300+
struct TagDeltaIo {
301+
std::function<bool(QSet<QString> &)> readTags;
302+
std::function<bool(const QString &)> tagFile;
303+
std::function<bool(const QString &)> untagFile;
304+
};
305+
306+
// Pre-read -> plan -> issue -> aggregate, for ONE file. Returns false when the
307+
// pre-read fails (nothing is issued in that case) or when any issued op fails.
308+
// Extracted alongside planTagDelta so controller tests cover the REAL
309+
// orchestration; handleTagDeltaResult adds only its read-only / eligibility
310+
// guards and binds this to the services.
311+
static bool applyTagDelta(const TagDeltaIo &p_io, const QSet<QString> &p_added,
312+
const QSet<QString> &p_removed);
313+
256314
private:
257315
// Resolve selection following Qt right-click convention:
258316
// - If clicked node is in current selection, return full selection
@@ -265,6 +323,12 @@ public slots:
265323
// Returns true if resolveSelection(p_clickedId).size() == 1.
266324
bool isSingleEffectiveSelection(const NodeIdentifier &p_clickedId) const;
267325

326+
// Eligible targets for the "Tags" action, derived from resolveSelection().
327+
// Keeps only nodes that resolve to a valid, non-folder, non-root node in the
328+
// SAME notebook as the clicked node. Drives both action enablement and
329+
// execution so the menu never offers an action that would be a no-op.
330+
QList<NodeIdentifier> resolveTagTargets(const NodeIdentifier &p_clickedId) const;
331+
268332
// Add actions to context menu based on node type. p_readOnly greys out
269333
// mutating actions when the clicked notebook is read-only.
270334
void addNewActions(QMenu *p_menu, const NodeIdentifier &p_nodeId, bool p_isFolder,
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
// Pure helpers behind the batch "Tags" context-menu action: the target filter
2+
// (filterTagTargets) and the per-file delta planner (planTagDelta).
3+
//
4+
// Deliberately isolated in their own translation unit for the same reason as
5+
// notebooknodecontroller_shareseam.cpp: notebooknodecontroller.cpp pulls in ~18
6+
// transitive service dependencies, so a GUILESS controller test that only wants
7+
// these decisions would otherwise have to link the whole controller stack.
8+
// Compiling just this TU gives the tests REAL production coverage instead of a
9+
// hand-mirrored copy of the logic.
10+
11+
#include "notebooknodecontroller.h"
12+
13+
#include <algorithm>
14+
15+
#include <core/nodeidentifier.h>
16+
#include <nodeinfo.h>
17+
18+
using namespace vnotex;
19+
20+
QList<NodeIdentifier>
21+
NotebookNodeController::filterTagTargets(const QList<NodeIdentifier> &p_resolvedSelection,
22+
const NodeIdentifier &p_clickedId,
23+
const NodeInfoLookup &p_infoLookup) {
24+
QList<NodeIdentifier> targets;
25+
if (!p_clickedId.isValid() || !p_infoLookup) {
26+
return targets;
27+
}
28+
29+
for (const auto &id : p_resolvedSelection) {
30+
// A batch spans exactly one notebook: the dialog lists that notebook's tags.
31+
if (id.notebookId != p_clickedId.notebookId) {
32+
continue;
33+
}
34+
35+
// NodeIdentifier::isValid() only checks a non-empty notebookId, and an
36+
// unresolved node yields a DEFAULT NodeInfo whose isFolder is false — so
37+
// NodeInfo::isValid() is the discriminator that rejects a stale id.
38+
const NodeInfo info = p_infoLookup(id);
39+
if (!info.isValid() || info.isFolder || info.isRoot()) {
40+
continue;
41+
}
42+
43+
if (!targets.contains(id)) {
44+
targets.append(id);
45+
}
46+
}
47+
48+
return targets;
49+
}
50+
51+
NotebookNodeController::TagDeltaOps
52+
NotebookNodeController::planTagDelta(const QSet<QString> &p_currentTags,
53+
const QSet<QString> &p_added, const QSet<QString> &p_removed) {
54+
TagDeltaOps ops;
55+
56+
for (const auto &tag : p_added) {
57+
// Already on this file: issuing TagFile would return
58+
// VXCORE_ERR_ALREADY_EXISTS, which is a routine no-op for a delta derived
59+
// from a Partial tag, not a failure. Skip it so every failure that does come
60+
// back from the service is genuine.
61+
if (!p_currentTags.contains(tag)) {
62+
ops.toAdd.append(tag);
63+
}
64+
}
65+
66+
for (const auto &tag : p_removed) {
67+
// Not on this file: UntagFile would return VXCORE_ERR_NOT_FOUND. Same
68+
// reasoning as above.
69+
if (p_currentTags.contains(tag)) {
70+
ops.toRemove.append(tag);
71+
}
72+
}
73+
74+
// QSet iteration order is unspecified; sort so the issued order (and any test
75+
// assertion over it) is deterministic.
76+
std::sort(ops.toAdd.begin(), ops.toAdd.end());
77+
std::sort(ops.toRemove.begin(), ops.toRemove.end());
78+
return ops;
79+
}
80+
bool NotebookNodeController::applyTagDelta(const TagDeltaIo &p_io, const QSet<QString> &p_added,
81+
const QSet<QString> &p_removed) {
82+
if (p_added.isEmpty() && p_removed.isEmpty()) {
83+
return true;
84+
}
85+
if (!p_io.readTags || !p_io.tagFile || !p_io.untagFile) {
86+
return false;
87+
}
88+
89+
// Read this file's CURRENT tags so the ops that would be no-ops on it can be
90+
// dropped. This is a pre-check that SKIPS work; it is NOT a read-modify-write
91+
// of the tag array -- nothing is written back wholesale, so a tag added
92+
// concurrently (or by a hook) survives. Because the redundant ops never reach
93+
// the service, every failure that does come back is a GENUINE failure and is
94+
// reported as one. Confirming state AFTER a failure would instead mask a
95+
// persistence error, since vxcore mutates its cached FileRecord before saving.
96+
QSet<QString> currentTags;
97+
if (!p_io.readTags(currentTags)) {
98+
// The current tags are unknown, and "unknown" is indistinguishable from
99+
// "none" -- fail loud rather than guess, and issue nothing.
100+
return false;
101+
}
102+
103+
const TagDeltaOps ops = planTagDelta(currentTags, p_added, p_removed);
104+
105+
bool allOk = true;
106+
for (const auto &tag : ops.toAdd) {
107+
if (!p_io.tagFile(tag)) {
108+
allOk = false;
109+
}
110+
}
111+
for (const auto &tag : ops.toRemove) {
112+
if (!p_io.untagFile(tag)) {
113+
allOk = false;
114+
}
115+
}
116+
return allOk;
117+
}

‎src/models/tagmodel.cpp‎

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,7 @@ TagModel::TagModel(ServiceLocator &p_services, QObject *p_parent)
2222
}
2323
}
2424

25-
TagModel::~TagModel() {
26-
}
25+
TagModel::~TagModel() {}
2726

2827
QModelIndex TagModel::index(int p_row, int p_column, const QModelIndex &p_parent) const {
2928
if (m_notebookId.isEmpty() || p_row < 0 || p_column < 0 || p_column > 0) {
@@ -153,9 +152,7 @@ void TagModel::setNotebookId(const QString &p_notebookId) {
153152
emit notebookChanged();
154153
}
155154

156-
QString TagModel::getNotebookId() const {
157-
return m_notebookId;
158-
}
155+
QString TagModel::getNotebookId() const { return m_notebookId; }
159156

160157
void TagModel::reload() {
161158
beginResetModel();
@@ -206,9 +203,26 @@ void TagModel::reload() {
206203
}
207204

208205
void TagModel::onTagsChanged(const QString &p_notebookId) {
209-
if (p_notebookId == m_notebookId) {
210-
reload();
206+
if (p_notebookId != m_notebookId) {
207+
return;
211208
}
209+
210+
// Coalesce: a batch tag edit emits one tagsChanged per file per tag (all
211+
// synchronous on the GUI thread), and each reload() is a full
212+
// beginResetModel + listTags + cache rebuild. Collapse the whole burst into a
213+
// single reload posted to the event loop, which still runs before the next
214+
// repaint so the tag tree is never visibly stale.
215+
if (m_reloadPending) {
216+
return;
217+
}
218+
m_reloadPending = true;
219+
QMetaObject::invokeMethod(
220+
this,
221+
[this]() {
222+
m_reloadPending = false;
223+
reload();
224+
},
225+
Qt::QueuedConnection);
212226
}
213227

214228
void TagModel::reloadTag(const QString &p_tagName) {
@@ -368,4 +382,3 @@ QString TagModel::fullTagPath(const QString &p_tagName) const {
368382

369383
return parts.join(QLatin1Char('/'));
370384
}
371-

0 commit comments

Comments
 (0)