From 5ab6a58968cefebcda3a7a194ddee7e337e028bb Mon Sep 17 00:00:00 2001 From: Mike Kaganski Date: Tue, 7 Apr 2026 17:34:09 +0500 Subject: [PATCH] sc: add threaded comment data model for Calc annotations Add ScThreadedCommentData, ScThreadedCommentEntry, and ScPersonData structs to support the Excel "threaded comments" feature ([MS-XLSX] sections 2.3.7, 2.6.205). ScPostIt gains an optional mpThreadedCommentData member with IsResolved()/SetResolved() accessors for the "done" flag, and ScDocument gains a person list (AddPerson, GetPersonById). Clone() preserves threaded data in both code paths. This is the document model foundation for comment resolution in Calc; import/export/LOK/Online follow separately. Signed-off-by: Mike Kaganski Change-Id: Ic782f9126c38088292b068066ba0de3889563531 Reviewed-on: https://gerrit.collaboraoffice.com/c/core/+/800 Reviewed-by: Miklos Vajna Tested-by: Jenkins CPCI (cherry picked from commit 3f8cd9c6ab6738a434788cb9280373413a0b7205) Reviewed-on: https://gerrit.libreoffice.org/c/core/+/205293 Reviewed-by: Xisco Fauli Tested-by: Xisco Fauli --- sc/inc/document.hxx | 8 +++ sc/inc/postit.hxx | 41 ++++++++++++ sc/qa/unit/ucalc.cxx | 106 +++++++++++++++++++++++++++++++ sc/source/core/data/document.cxx | 14 ++++ sc/source/core/data/postit.cxx | 25 +++++++- 5 files changed, 193 insertions(+), 1 deletion(-) diff --git a/sc/inc/document.hxx b/sc/inc/document.hxx index 6efc37b6dcf8..9d830e2316b3 100644 --- a/sc/inc/document.hxx +++ b/sc/inc/document.hxx @@ -205,6 +205,7 @@ class ScEditDataArray; class EditTextObject; struct ScRefCellValue; class ScPostIt; +struct ScPersonData; struct ScSubTotalParam; struct ScQueryParam; class ScHint; @@ -476,6 +477,7 @@ private: std::unique_ptr pConditionalFormatDialogMode; std::unique_ptr pAutoNameCache; // for automatic name lookup during CompileXML + std::vector maPersonList; // persons for threaded comments std::unique_ptr pPreviewFont; // convert to std::unique_ptr or whatever ScStyleSheet* pPreviewCellStyle; @@ -1406,6 +1408,12 @@ public: bool ContainsNotesInRange( const ScRangeList& rRange ) const; + // Person list (for threaded comments, [MS-XLSX] section 2.1.19) + SC_DLLPUBLIC void AddPerson(const ScPersonData& rPerson); + SC_DLLPUBLIC const ScPersonData* GetPersonById(const OUString& rId) const; + const std::vector& GetPersonList() const { return maPersonList; } + std::vector& GetPersonList() { return maPersonList; } + SC_DLLPUBLIC void SetDrawPageSize(SCTAB nTab); bool IsMerged( const ScAddress& rPos ) const; diff --git a/sc/inc/postit.hxx b/sc/inc/postit.hxx index 2e54e64c44b5..e945bfa168db 100644 --- a/sc/inc/postit.hxx +++ b/sc/inc/postit.hxx @@ -27,6 +27,7 @@ #include #include +#include class EditTextObject; class OutlinerParaObject; @@ -37,6 +38,35 @@ class ScDocument; namespace tools { class Rectangle; } struct ScCaptionInitData; +/** A single entry in a threaded comment (root or reply). + Maps to CT_ThreadedComment in [MS-XLSX] section 2.6.205. */ +struct ScThreadedCommentEntry +{ + OUString maGuid; /// Unique ID (ST_Guid). + OUString maPersonId; /// Reference to person in ScDocument person list. + OUString maDateTime; /// ISO 8601 / xsd:dateTime (UTC). + OUString maText; /// Text of this entry. +}; + +/** Threaded-comment metadata attached to a ScPostIt. + When present the note participates in the Excel "threaded comments" feature. */ +struct ScThreadedCommentData +{ + ScThreadedCommentEntry maRoot; /// Root entry (guid, personId, dateTime). + bool mbDone = false; /// Resolved / done flag. + std::vector maReplies; /// Reply chain. +}; + +/** Person metadata for threaded comments. + Maps to CT_Person in [MS-XLSX] section 2.6.203. */ +struct ScPersonData +{ + OUString maId; /// Unique person GUID. + OUString maDisplayName; /// Display name. + OUString maUserId; /// Provider-specific user ID. + OUString maProviderId; /// Identity provider ID. +}; + /** Internal data for a cell annotation. */ struct ScNoteData { @@ -157,6 +187,16 @@ public: static OString NoteRangeToJsonString(const ScDocument& rDoc, const ScAddress& rPos); + /** Returns true if this note has been resolved (threaded comment "done" flag). */ + bool IsResolved() const { return mpThreadedCommentData && mpThreadedCommentData->mbDone; } + /** Sets the resolved state. Creates threaded comment data if not yet present. */ + void SetResolved(bool bResolved); + + /** Returns the threaded comment data, or nullptr if this is a classic note. */ + const ScThreadedCommentData* GetThreadedCommentData() const { return mpThreadedCommentData.get(); } + /** Sets (replaces) the threaded comment data. Pass nullptr to remove. */ + SC_DLLPUBLIC void SetThreadedCommentData(std::unique_ptr pData); + private: ScPostIt( const ScPostIt& ) = delete; ScPostIt& operator=( const ScPostIt& ) = delete; @@ -172,6 +212,7 @@ private: ScDocument& mrDoc; /// Parent document containing the note. mutable ScNoteData maNoteData; /// Note data with pointer to caption object. sal_uInt32 mnPostItId; + std::unique_ptr mpThreadedCommentData; /// Threaded comment metadata (nullable). }; class GenerateNoteCaption diff --git a/sc/qa/unit/ucalc.cxx b/sc/qa/unit/ucalc.cxx index 2f3a9ced13ef..2c36f9f7c1ea 100644 --- a/sc/qa/unit/ucalc.cxx +++ b/sc/qa/unit/ucalc.cxx @@ -42,6 +42,7 @@ #include #include +#include #include #include @@ -5169,6 +5170,111 @@ CPPUNIT_TEST_FIXTURE(Test, testNoteDefaultStyle) m_pDoc->DeleteTab(0); } +CPPUNIT_TEST_FIXTURE(Test, testNoteThreadedCommentData) +{ + m_pDoc->InsertTab(0, u"PostIts"_ustr); + m_pDoc->InitDrawLayer(m_xDocShell.get()); + + ScAddress aPos(0, 0, 0); + ScPostIt* pNote = m_pDoc->GetOrCreateNote(aPos); + pNote->SetText(aPos, u"Hello"_ustr); + pNote->SetAuthor(u"Author"_ustr); + + // Initially not resolved (no threaded data). + CPPUNIT_ASSERT(!pNote->IsResolved()); + CPPUNIT_ASSERT(!pNote->GetThreadedCommentData()); + + // SetResolved creates threaded data on demand. + pNote->SetResolved(true); + CPPUNIT_ASSERT(pNote->IsResolved()); + CPPUNIT_ASSERT(pNote->GetThreadedCommentData()); + CPPUNIT_ASSERT(pNote->GetThreadedCommentData()->mbDone); + + // SetResolved(false) clears the flag but keeps the data. + pNote->SetResolved(false); + CPPUNIT_ASSERT(!pNote->IsResolved()); + CPPUNIT_ASSERT(pNote->GetThreadedCommentData()); + CPPUNIT_ASSERT(!pNote->GetThreadedCommentData()->mbDone); + + // Set full threaded comment data. + auto pData = std::make_unique(); + pData->maRoot.maGuid = u"{11111111-1111-1111-1111-111111111111}"_ustr; + pData->maRoot.maPersonId = u"{AAAA}"_ustr; + pData->maRoot.maDateTime = u"2026-04-07T12:00:00Z"_ustr; + pData->mbDone = true; + + ScThreadedCommentEntry aReply; + aReply.maGuid = u"{22222222-2222-2222-2222-222222222222}"_ustr; + aReply.maPersonId = u"{BBBB}"_ustr; + aReply.maText = u"Reply text"_ustr; + pData->maReplies.push_back(aReply); + + pNote->SetThreadedCommentData(std::move(pData)); + CPPUNIT_ASSERT(pNote->IsResolved()); + CPPUNIT_ASSERT_EQUAL(size_t(1), pNote->GetThreadedCommentData()->maReplies.size()); + CPPUNIT_ASSERT_EQUAL(u"Reply text"_ustr, pNote->GetThreadedCommentData()->maReplies[0].maText); + + // Clone preserves threaded data. + std::unique_ptr pClone = pNote->Clone(aPos, *m_pDoc, ScAddress(1, 0, 0), true); + CPPUNIT_ASSERT(pClone->IsResolved()); + CPPUNIT_ASSERT(pClone->GetThreadedCommentData()); + CPPUNIT_ASSERT_EQUAL(u"{11111111-1111-1111-1111-111111111111}"_ustr, + pClone->GetThreadedCommentData()->maRoot.maGuid); + CPPUNIT_ASSERT_EQUAL(size_t(1), pClone->GetThreadedCommentData()->maReplies.size()); + + // Clone without caption also preserves threaded data. + std::unique_ptr pClone2 = pNote->Clone(aPos, *m_pDoc, ScAddress(2, 0, 0), false); + CPPUNIT_ASSERT(pClone2->IsResolved()); + CPPUNIT_ASSERT_EQUAL(u"{11111111-1111-1111-1111-111111111111}"_ustr, + pClone2->GetThreadedCommentData()->maRoot.maGuid); + + // Remove threaded data. + pNote->SetThreadedCommentData(nullptr); + CPPUNIT_ASSERT(!pNote->IsResolved()); + CPPUNIT_ASSERT(!pNote->GetThreadedCommentData()); + + m_pDoc->DeleteTab(0); +} + +CPPUNIT_TEST_FIXTURE(Test, testNotePersonList) +{ + m_pDoc->InsertTab(0, u"PostIts"_ustr); + + // Initially empty. + CPPUNIT_ASSERT(m_pDoc->GetPersonList().empty()); + + ScPersonData aPerson1; + aPerson1.maId = u"{AAAA}"_ustr; + aPerson1.maDisplayName = u"Alice"_ustr; + m_pDoc->AddPerson(aPerson1); + CPPUNIT_ASSERT_EQUAL(size_t(1), m_pDoc->GetPersonList().size()); + + // Duplicate by ID is ignored. + ScPersonData aPerson1Dup; + aPerson1Dup.maId = u"{AAAA}"_ustr; + aPerson1Dup.maDisplayName = u"Alice duplicate"_ustr; + m_pDoc->AddPerson(aPerson1Dup); + CPPUNIT_ASSERT_EQUAL(size_t(1), m_pDoc->GetPersonList().size()); + CPPUNIT_ASSERT_EQUAL(u"Alice"_ustr, m_pDoc->GetPersonList()[0].maDisplayName); + + // Different ID is added. + ScPersonData aPerson2; + aPerson2.maId = u"{BBBB}"_ustr; + aPerson2.maDisplayName = u"Bob"_ustr; + m_pDoc->AddPerson(aPerson2); + CPPUNIT_ASSERT_EQUAL(size_t(2), m_pDoc->GetPersonList().size()); + + // Lookup by ID. + const ScPersonData* pFound = m_pDoc->GetPersonById(u"{BBBB}"_ustr); + CPPUNIT_ASSERT(pFound); + CPPUNIT_ASSERT_EQUAL(u"Bob"_ustr, pFound->maDisplayName); + + // Unknown ID returns nullptr. + CPPUNIT_ASSERT(!m_pDoc->GetPersonById(u"{CCCC}"_ustr)); + + m_pDoc->DeleteTab(0); +} + CPPUNIT_TEST_FIXTURE(Test, testNoteBasic) { m_pDoc->InsertTab(0, u"PostIts"_ustr); diff --git a/sc/source/core/data/document.cxx b/sc/source/core/data/document.cxx index dc6df37cab95..962b21cc2f22 100644 --- a/sc/source/core/data/document.cxx +++ b/sc/source/core/data/document.cxx @@ -6970,6 +6970,20 @@ bool ScDocument::ContainsNotesInRange( const ScRangeList& rRangeList ) const return false; } +void ScDocument::AddPerson(const ScPersonData& rPerson) +{ + if (std::none_of(maPersonList.begin(), maPersonList.end(), + [&rPerson](const auto& r) { return r.maId == rPerson.maId; })) + maPersonList.push_back(rPerson); +} + +const ScPersonData* ScDocument::GetPersonById(const OUString& rId) const +{ + auto it = std::find_if(maPersonList.begin(), maPersonList.end(), + [&rId](const auto& r) { return r.maId == rId; }); + return it != maPersonList.end() ? &*it : nullptr; +} + void ScDocument::SetAutoNameCache( std::unique_ptr pCache ) { pAutoNameCache = std::move(pCache); diff --git a/sc/source/core/data/postit.cxx b/sc/source/core/data/postit.cxx index 817c33566ba3..74c2cf1cbc48 100644 --- a/sc/source/core/data/postit.cxx +++ b/sc/source/core/data/postit.cxx @@ -451,6 +451,8 @@ ScPostIt::ScPostIt( ScDocument& rDoc, const ScAddress& rPos, const ScPostIt& rNo maNoteData( rNote.maNoteData ) { mnPostItId = nPostItId == 0 ? mnLastPostItId++ : nPostItId; + if (rNote.mpThreadedCommentData) + mpThreadedCommentData = std::make_unique(*rNote.mpThreadedCommentData); maNoteData.mxCaption.clear(); CreateCaption( rPos, rNote.maNoteData.mxCaption.get() ); } @@ -477,7 +479,16 @@ std::unique_ptr ScPostIt::Clone( const ScAddress& rOwnPos, ScDocument& bCloneCaption = false; CreateCaptionFromInitData( rOwnPos ); sal_uInt32 nPostItId = comphelper::LibreOfficeKit::isActive() ? 0 : mnPostItId; - return bCloneCaption ? std::make_unique( rDestDoc, rDestPos, *this, nPostItId ) : std::make_unique( rDestDoc, rDestPos, maNoteData, false, mnPostItId ); + std::unique_ptr pClone; + if (bCloneCaption) + pClone = std::make_unique(rDestDoc, rDestPos, *this, nPostItId); + else + { + pClone = std::make_unique(rDestDoc, rDestPos, maNoteData, false, mnPostItId); + if (mpThreadedCommentData) + pClone->mpThreadedCommentData = std::make_unique(*mpThreadedCommentData); + } + return pClone; } void ScPostIt::SetDate( const OUString& rDate ) @@ -490,6 +501,18 @@ void ScPostIt::SetAuthor( const OUString& rAuthor ) maNoteData.maAuthor = rAuthor; } +void ScPostIt::SetResolved(bool bResolved) +{ + if (!mpThreadedCommentData) + mpThreadedCommentData = std::make_unique(); + mpThreadedCommentData->mbDone = bResolved; +} + +void ScPostIt::SetThreadedCommentData(std::unique_ptr pData) +{ + mpThreadedCommentData = std::move(pData); +} + void ScPostIt::AutoStamp(bool bCreate) { if (bCreate)