Compare commits

...

2 Commits

Author SHA1 Message Date
Keepers
0efe25bb71
get details working (and other cleanup) (#5116)
details wasn't properly listing backed up items. This fixes the details
display, and contains some code clean-up that occurred along the way.
2024-02-15 14:32:25 -07:00
ryanfkeepers
316e5f0195 lazily fetch messages in chat
once we're past kopia assits, the chat can
download all of its messages and store them in the body
uploded to kopia.
2024-01-30 13:15:24 -07:00
22 changed files with 566 additions and 306 deletions

View File

@ -213,7 +213,6 @@ func runDetailsTeamsChatsCmd(cmd *cobra.Command) error {
opts := utils.MakeTeamsChatsOpts(cmd) opts := utils.MakeTeamsChatsOpts(cmd)
sel := utils.IncludeTeamsChatsRestoreDataSelectors(ctx, opts) sel := utils.IncludeTeamsChatsRestoreDataSelectors(ctx, opts)
sel.Configure(selectors.Config{OnlyMatchItemNames: true})
utils.FilterTeamsChatsRestoreInfoSelectors(sel, opts) utils.FilterTeamsChatsRestoreInfoSelectors(sel, opts)
ds, err := genericDetailsCommand(cmd, flags.BackupIDFV, sel.Selector) ds, err := genericDetailsCommand(cmd, flags.BackupIDFV, sel.Selector)

View File

@ -89,7 +89,10 @@ func IncludeTeamsChatsRestoreDataSelectors(ctx context.Context, opts TeamsChatsO
users = selectors.Any() users = selectors.Any()
} }
return selectors.NewTeamsChatsRestore(users) sel := selectors.NewTeamsChatsRestore(users)
sel.Include(sel.Chats(selectors.Any()))
return sel
} }
// FilterTeamsChatsRestoreInfoSelectors builds the common info-selector filters. // FilterTeamsChatsRestoreInfoSelectors builds the common info-selector filters.

View File

@ -115,11 +115,8 @@ func populateCollection[I chatsItemer](
) )
ctx = clues.AddLabelCounter(ctx, cl.PlainAdder()) ctx = clues.AddLabelCounter(ctx, cl.PlainAdder())
cc := api.CallConfig{
CanMakeDeltaQueries: false,
}
items, err := bh.getItemIDs(ctx, cc) items, err := bh.getItemIDs(ctx)
if err != nil { if err != nil {
errs.AddRecoverable(ctx, clues.Stack(err)) errs.AddRecoverable(ctx, clues.Stack(err))
return collection, clues.Stack(errs.Failure()).OrNil() return collection, clues.Stack(errs.Failure()).OrNil()

View File

@ -49,14 +49,6 @@ type mockBackupHandler struct {
doNotInclude bool doNotInclude bool
} }
//lint:ignore U1000 false linter issue due to generics
func (bh mockBackupHandler) augmentItemInfo(
*details.TeamsChatsInfo,
models.Chatable,
) {
// no-op
}
func (bh mockBackupHandler) container() container[models.Chatable] { func (bh mockBackupHandler) container() container[models.Chatable] {
return chatContainer() return chatContainer()
} }
@ -71,7 +63,6 @@ func (bh mockBackupHandler) getContainer(
func (bh mockBackupHandler) getItemIDs( func (bh mockBackupHandler) getItemIDs(
_ context.Context, _ context.Context,
_ api.CallConfig,
) ([]models.Chatable, error) { ) ([]models.Chatable, error) {
return bh.chats, bh.chatsErr return bh.chats, bh.chatsErr
} }
@ -96,15 +87,13 @@ func (bh mockBackupHandler) CanonicalPath() (path.Path, error) {
func (bh mockBackupHandler) getItem( func (bh mockBackupHandler) getItem(
_ context.Context, _ context.Context,
_ string, _ string,
itemID string, chat models.Chatable,
) (models.Chatable, *details.TeamsChatsInfo, error) { ) (models.Chatable, *details.TeamsChatsInfo, error) {
chat := models.NewChat() chatID := ptr.Val(chat.GetId())
chat.SetId(ptr.To(itemID)) chat.SetMessages(bh.chatMessages[chatID])
chat.SetTopic(ptr.To(itemID))
chat.SetMessages(bh.chatMessages[itemID])
return chat, bh.info[itemID], bh.getMessageErr[itemID] return chat, bh.info[chatID], bh.getMessageErr[chatID]
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -8,6 +8,7 @@ import (
"github.com/alcionai/corso/src/internal/common/ptr" "github.com/alcionai/corso/src/internal/common/ptr"
"github.com/alcionai/corso/src/pkg/backup/details" "github.com/alcionai/corso/src/pkg/backup/details"
"github.com/alcionai/corso/src/pkg/errs/core"
"github.com/alcionai/corso/src/pkg/path" "github.com/alcionai/corso/src/pkg/path"
"github.com/alcionai/corso/src/pkg/selectors" "github.com/alcionai/corso/src/pkg/selectors"
"github.com/alcionai/corso/src/pkg/services/m365/api" "github.com/alcionai/corso/src/pkg/services/m365/api"
@ -45,8 +46,11 @@ func (bh usersChatsBackupHandler) getContainer(
//lint:ignore U1000 required for interface compliance //lint:ignore U1000 required for interface compliance
func (bh usersChatsBackupHandler) getItemIDs( func (bh usersChatsBackupHandler) getItemIDs(
ctx context.Context, ctx context.Context,
cc api.CallConfig,
) ([]models.Chatable, error) { ) ([]models.Chatable, error) {
cc := api.CallConfig{
Expand: []string{"lastMessagePreview"},
}
return bh.ac.GetChats( return bh.ac.GetChats(
ctx, ctx,
bh.protectedResourceID, bh.protectedResourceID,
@ -80,18 +84,29 @@ func (bh usersChatsBackupHandler) CanonicalPath() (path.Path, error) {
func (bh usersChatsBackupHandler) getItem( func (bh usersChatsBackupHandler) getItem(
ctx context.Context, ctx context.Context,
userID string, userID string,
chatID string, chat models.Chatable,
) (models.Chatable, *details.TeamsChatsInfo, error) { ) (models.Chatable, *details.TeamsChatsInfo, error) {
// FIXME: should retrieve and populate all messages in the chat. if chat == nil {
return nil, nil, clues.New("not implemented") return nil, nil, clues.Stack(core.ErrNotFound)
} }
//lint:ignore U1000 false linter issue due to generics chatID := ptr.Val(chat.GetId())
func (bh usersChatsBackupHandler) augmentItemInfo(
dgi *details.TeamsChatsInfo, msgs, err := bh.ac.GetChatMessages(ctx, chatID, api.CallConfig{})
c models.Chatable, if err != nil {
) { return nil, nil, clues.Stack(err)
// no-op }
chat.SetMessages(msgs)
members, err := bh.ac.GetChatMembers(ctx, chatID, api.CallConfig{})
if err != nil {
return nil, nil, clues.Stack(err)
}
chat.SetMembers(members)
return chat, api.TeamsChatInfo(chat), nil
} }
func chatContainer() container[models.Chatable] { func chatContainer() container[models.Chatable] {

View File

@ -66,7 +66,7 @@ func updateStatus(
// or notMoved (if they match). // or notMoved (if they match).
func NewCollection[I chatsItemer]( func NewCollection[I chatsItemer](
baseCol data.BaseCollection, baseCol data.BaseCollection,
getAndAugment getItemAndAugmentInfoer[I], getter getItemer[I],
protectedResource string, protectedResource string,
items []I, items []I,
contains container[I], contains container[I],
@ -76,7 +76,7 @@ func NewCollection[I chatsItemer](
BaseCollection: baseCol, BaseCollection: baseCol,
items: items, items: items,
contains: contains, contains: contains,
getAndAugment: getAndAugment, getter: getter,
statusUpdater: statusUpdater, statusUpdater: statusUpdater,
stream: make(chan data.Item, collectionChannelBufferSize), stream: make(chan data.Item, collectionChannelBufferSize),
protectedResource: protectedResource, protectedResource: protectedResource,
@ -96,7 +96,7 @@ type lazyFetchCollection[I chatsItemer] struct {
items []I items []I
getAndAugment getItemAndAugmentInfoer[I] getter getItemer[I]
statusUpdater support.StatusUpdater statusUpdater support.StatusUpdater
} }
@ -152,33 +152,30 @@ func (col *lazyFetchCollection[I]) streamItems(ctx context.Context, errs *fault.
break break
} }
itemID := ptr.Val(item.GetId())
modTime := ptr.Val(item.GetLastUpdatedDateTime()) modTime := ptr.Val(item.GetLastUpdatedDateTime())
wg.Add(1) wg.Add(1)
semaphoreCh <- struct{}{} semaphoreCh <- struct{}{}
go func(id string, modTime time.Time) { go func(item I, modTime time.Time) {
defer wg.Done() defer wg.Done()
defer func() { <-semaphoreCh }() defer func() { <-semaphoreCh }()
ictx := clues.Add( itemID := ptr.Val(item.GetId())
ctx, ictx := clues.Add(ctx, "item_id", itemID)
"item_id", id,
"parent_path", path.LoggableDir(col.LocationPath().String()))
col.stream <- data.NewLazyItemWithInfo( col.stream <- data.NewLazyItemWithInfo(
ictx, ictx,
&lazyItemGetter[I]{ &lazyItemGetter[I]{
modTime: modTime, modTime: modTime,
getAndAugment: col.getAndAugment, getter: col.getter,
resourceID: col.protectedResource, resourceID: col.protectedResource,
itemID: id, item: item,
containerIDs: col.FullPath().Folders(), containerIDs: col.FullPath().Folders(),
contains: col.contains, contains: col.contains,
parentPath: col.LocationPath().String(), parentPath: col.LocationPath().String(),
}, },
id, itemID,
modTime, modTime,
col.Counter, col.Counter,
el) el)
@ -188,20 +185,20 @@ func (col *lazyFetchCollection[I]) streamItems(ctx context.Context, errs *fault.
if progressMessage != nil { if progressMessage != nil {
progressMessage <- struct{}{} progressMessage <- struct{}{}
} }
}(itemID, modTime) }(item, modTime)
} }
wg.Wait() wg.Wait()
} }
type lazyItemGetter[I chatsItemer] struct { type lazyItemGetter[I chatsItemer] struct {
getAndAugment getItemAndAugmentInfoer[I] getter getItemer[I]
resourceID string resourceID string
itemID string item I
parentPath string parentPath string
containerIDs path.Elements containerIDs path.Elements
modTime time.Time modTime time.Time
contains container[I] contains container[I]
} }
func (lig *lazyItemGetter[I]) GetData( func (lig *lazyItemGetter[I]) GetData(
@ -211,10 +208,10 @@ func (lig *lazyItemGetter[I]) GetData(
writer := kjson.NewJsonSerializationWriter() writer := kjson.NewJsonSerializationWriter()
defer writer.Close() defer writer.Close()
item, info, err := lig.getAndAugment.getItem( item, info, err := lig.getter.getItem(
ctx, ctx,
lig.resourceID, lig.resourceID,
lig.itemID) lig.item)
if err != nil { if err != nil {
// For items that were deleted in flight, add the skip label so that // For items that were deleted in flight, add the skip label so that
// they don't lead to recoverable failures during backup. // they don't lead to recoverable failures during backup.
@ -232,8 +229,6 @@ func (lig *lazyItemGetter[I]) GetData(
return nil, nil, false, err return nil, nil, false, err
} }
lig.getAndAugment.augmentItemInfo(info, lig.contains.container)
if err := writer.WriteObjectValue("", item); err != nil { if err := writer.WriteObjectValue("", item); err != nil {
err = clues.WrapWC(ctx, err, "writing item to serializer").Label(fault.LabelForceNoBackupCreation) err = clues.WrapWC(ctx, err, "writing item to serializer").Label(fault.LabelForceNoBackupCreation)
errs.AddRecoverable(ctx, err) errs.AddRecoverable(ctx, err)

View File

@ -9,6 +9,7 @@ import (
"time" "time"
"github.com/alcionai/clues" "github.com/alcionai/clues"
"github.com/google/uuid"
"github.com/microsoftgraph/msgraph-sdk-go/models" "github.com/microsoftgraph/msgraph-sdk-go/models"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@ -155,20 +156,13 @@ type getAndAugmentChat struct {
func (m getAndAugmentChat) getItem( func (m getAndAugmentChat) getItem(
_ context.Context, _ context.Context,
_ string, _ string,
itemID string, chat models.Chatable,
) (models.Chatable, *details.TeamsChatsInfo, error) { ) (models.Chatable, *details.TeamsChatsInfo, error) {
chat := models.NewChat() chat.SetTopic(chat.GetId())
chat.SetId(ptr.To(itemID))
chat.SetTopic(ptr.To(itemID))
return chat, &details.TeamsChatsInfo{}, m.err return chat, &details.TeamsChatsInfo{}, m.err
} }
//lint:ignore U1000 false linter issue due to generics
func (getAndAugmentChat) augmentItemInfo(*details.TeamsChatsInfo, models.Chatable) {
// no-op
}
func (suite *CollectionUnitSuite) TestLazyFetchCollection_Items_LazyFetch() { func (suite *CollectionUnitSuite) TestLazyFetchCollection_Items_LazyFetch() {
var ( var (
t = suite.T() t = suite.T()
@ -226,7 +220,7 @@ func (suite *CollectionUnitSuite) TestLazyFetchCollection_Items_LazyFetch() {
count.New()), count.New()),
items: test.items, items: test.items,
contains: container[models.Chatable]{}, contains: container[models.Chatable]{},
getAndAugment: getterAugmenter, getter: getterAugmenter,
stream: make(chan data.Item), stream: make(chan data.Item),
statusUpdater: statusUpdater, statusUpdater: statusUpdater,
} }
@ -277,6 +271,8 @@ func (suite *CollectionUnitSuite) TestLazyItem_GetDataErrors() {
ctx, flush := tester.NewContext(t) ctx, flush := tester.NewContext(t)
defer flush() defer flush()
chat := testdata.StubChats(uuid.NewString())[0]
m := getAndAugmentChat{ m := getAndAugmentChat{
err: test.getErr, err: test.getErr,
} }
@ -284,13 +280,13 @@ func (suite *CollectionUnitSuite) TestLazyItem_GetDataErrors() {
li := data.NewLazyItemWithInfo( li := data.NewLazyItemWithInfo(
ctx, ctx,
&lazyItemGetter[models.Chatable]{ &lazyItemGetter[models.Chatable]{
resourceID: "resourceID", resourceID: "resourceID",
itemID: "itemID", item: chat,
getAndAugment: &m, getter: &m,
modTime: now, modTime: now,
parentPath: parentPath, parentPath: parentPath,
}, },
"itemID", ptr.Val(chat.GetId()),
now, now,
count.New(), count.New(),
fault.New(true)) fault.New(true))
@ -319,6 +315,8 @@ func (suite *CollectionUnitSuite) TestLazyItem_ReturnsEmptyReaderOnDeletedInFlig
ctx, flush := tester.NewContext(t) ctx, flush := tester.NewContext(t)
defer flush() defer flush()
chat := testdata.StubChats(uuid.NewString())[0]
m := getAndAugmentChat{ m := getAndAugmentChat{
err: core.ErrNotFound, err: core.ErrNotFound,
} }
@ -326,13 +324,13 @@ func (suite *CollectionUnitSuite) TestLazyItem_ReturnsEmptyReaderOnDeletedInFlig
li := data.NewLazyItemWithInfo( li := data.NewLazyItemWithInfo(
ctx, ctx,
&lazyItemGetter[models.Chatable]{ &lazyItemGetter[models.Chatable]{
resourceID: "resourceID", resourceID: "resourceID",
itemID: "itemID", item: chat,
getAndAugment: &m, getter: &m,
modTime: now, modTime: now,
parentPath: parentPath, parentPath: parentPath,
}, },
"itemID", ptr.Val(chat.GetId()),
now, now,
count.New(), count.New(),
fault.New(true)) fault.New(true))
@ -359,18 +357,19 @@ func (suite *CollectionUnitSuite) TestLazyItem() {
ctx, flush := tester.NewContext(t) ctx, flush := tester.NewContext(t)
defer flush() defer flush()
chat := testdata.StubChats(uuid.NewString())[0]
m := getAndAugmentChat{} m := getAndAugmentChat{}
li := data.NewLazyItemWithInfo( li := data.NewLazyItemWithInfo(
ctx, ctx,
&lazyItemGetter[models.Chatable]{ &lazyItemGetter[models.Chatable]{
resourceID: "resourceID", resourceID: "resourceID",
itemID: "itemID", item: chat,
getAndAugment: &m, getter: &m,
modTime: now, modTime: now,
parentPath: parentPath, parentPath: parentPath,
}, },
"itemID", ptr.Val(chat.GetId()),
now, now,
count.New(), count.New(),
fault.New(true)) fault.New(true))

View File

@ -22,7 +22,7 @@ type chatsItemer interface {
type backupHandler[I chatsItemer] interface { type backupHandler[I chatsItemer] interface {
getContainerer[I] getContainerer[I]
getItemAndAugmentInfoer[I] getItemer[I]
getItemer[I] getItemer[I]
getItemIDser[I] getItemIDser[I]
includeItemer[I] includeItemer[I]
@ -39,22 +39,10 @@ type getContainerer[I chatsItemer] interface {
) (container[I], error) ) (container[I], error)
} }
type getItemAndAugmentInfoer[I chatsItemer] interface {
getItemer[I]
augmentItemInfoer[I]
}
type augmentItemInfoer[I chatsItemer] interface {
// augmentItemInfo completes the teamChatsInfo population with any data
// owned by the container and not accessible to the item.
augmentItemInfo(*details.TeamsChatsInfo, I)
}
// gets all item IDs in the container // gets all item IDs in the container
type getItemIDser[I chatsItemer] interface { type getItemIDser[I chatsItemer] interface {
getItemIDs( getItemIDs(
ctx context.Context, ctx context.Context,
cc api.CallConfig,
) ([]I, error) ) ([]I, error)
} }
@ -62,7 +50,7 @@ type getItemer[I chatsItemer] interface {
getItem( getItem(
ctx context.Context, ctx context.Context,
protectedResource string, protectedResource string,
itemID string, i I,
) (I, *details.TeamsChatsInfo, error) ) (I, *details.TeamsChatsInfo, error)
} }

View File

@ -11,11 +11,21 @@ func StubChats(ids ...string) []models.Chatable {
sl := make([]models.Chatable, 0, len(ids)) sl := make([]models.Chatable, 0, len(ids))
for _, id := range ids { for _, id := range ids {
ch := models.NewChat() chat := models.NewChat()
ch.SetTopic(ptr.To(id)) chat.SetTopic(ptr.To(id))
ch.SetId(ptr.To(id)) chat.SetId(ptr.To(id))
sl = append(sl, ch) // we should expect to get the latest message preview by default
lastMsgPrv := models.NewChatMessageInfo()
lastMsgPrv.SetId(ptr.To(uuid.NewString()))
body := models.NewItemBody()
body.SetContent(ptr.To(id))
lastMsgPrv.SetBody(body)
chat.SetLastMessagePreview(lastMsgPrv)
sl = append(sl, chat)
} }
return sl return sl
@ -24,17 +34,24 @@ func StubChats(ids ...string) []models.Chatable {
func StubChatMessages(ids ...string) []models.ChatMessageable { func StubChatMessages(ids ...string) []models.ChatMessageable {
sl := make([]models.ChatMessageable, 0, len(ids)) sl := make([]models.ChatMessageable, 0, len(ids))
var lastMsg models.ChatMessageable
for _, id := range ids { for _, id := range ids {
cm := models.NewChatMessage() msg := models.NewChatMessage()
cm.SetId(ptr.To(uuid.NewString())) msg.SetId(ptr.To(uuid.NewString()))
body := models.NewItemBody() body := models.NewItemBody()
body.SetContent(ptr.To(id)) body.SetContent(ptr.To(id))
cm.SetBody(body) msg.SetBody(body)
sl = append(sl, cm) sl = append(sl, msg)
lastMsg = msg
} }
lastMsgPrv := models.NewChatMessageInfo()
lastMsgPrv.SetId(lastMsg.GetId())
lastMsgPrv.SetBody(lastMsg.GetBody())
return sl return sl
} }

View File

@ -103,6 +103,12 @@ func (de Entry) ToLocationIDer(backupVersion int) (LocationIDer, error) {
} }
baseLoc = path.Builder{}.Append(p.Root).Append(p.Folders...) baseLoc = path.Builder{}.Append(p.Root).Append(p.Folders...)
case TeamsChat:
baseLoc = &path.Builder{}
default:
return nil, clues.New("undentified item type").With("item_type", de.ItemInfo.infoType())
} }
if baseLoc == nil { if baseLoc == nil {
@ -141,26 +147,23 @@ func (de Entry) MinimumPrintable() any {
// Headers returns the human-readable names of properties in a DetailsEntry // Headers returns the human-readable names of properties in a DetailsEntry
// for printing out to a terminal in a columnar display. // for printing out to a terminal in a columnar display.
func (de Entry) Headers(skipID bool) []string { func (de Entry) Headers(skipID bool) []string {
hs := []string{} var hs []string
if de.ItemInfo.Folder != nil { switch {
case de.ItemInfo.Folder != nil:
hs = de.ItemInfo.Folder.Headers() hs = de.ItemInfo.Folder.Headers()
} case de.ItemInfo.Exchange != nil:
if de.ItemInfo.Exchange != nil {
hs = de.ItemInfo.Exchange.Headers() hs = de.ItemInfo.Exchange.Headers()
} case de.ItemInfo.SharePoint != nil:
if de.ItemInfo.SharePoint != nil {
hs = de.ItemInfo.SharePoint.Headers() hs = de.ItemInfo.SharePoint.Headers()
} case de.ItemInfo.OneDrive != nil:
if de.ItemInfo.OneDrive != nil {
hs = de.ItemInfo.OneDrive.Headers() hs = de.ItemInfo.OneDrive.Headers()
} case de.ItemInfo.Groups != nil:
if de.ItemInfo.Groups != nil {
hs = de.ItemInfo.Groups.Headers() hs = de.ItemInfo.Groups.Headers()
case de.ItemInfo.TeamsChats != nil:
hs = de.ItemInfo.TeamsChats.Headers()
default:
hs = []string{"ERROR - Service not recognized"}
} }
if skipID { if skipID {
@ -172,26 +175,23 @@ func (de Entry) Headers(skipID bool) []string {
// Values returns the values matching the Headers list. // Values returns the values matching the Headers list.
func (de Entry) Values(skipID bool) []string { func (de Entry) Values(skipID bool) []string {
vs := []string{} var vs []string
if de.ItemInfo.Folder != nil { switch {
case de.ItemInfo.Folder != nil:
vs = de.ItemInfo.Folder.Values() vs = de.ItemInfo.Folder.Values()
} case de.ItemInfo.Exchange != nil:
if de.ItemInfo.Exchange != nil {
vs = de.ItemInfo.Exchange.Values() vs = de.ItemInfo.Exchange.Values()
} case de.ItemInfo.SharePoint != nil:
if de.ItemInfo.SharePoint != nil {
vs = de.ItemInfo.SharePoint.Values() vs = de.ItemInfo.SharePoint.Values()
} case de.ItemInfo.OneDrive != nil:
if de.ItemInfo.OneDrive != nil {
vs = de.ItemInfo.OneDrive.Values() vs = de.ItemInfo.OneDrive.Values()
} case de.ItemInfo.Groups != nil:
if de.ItemInfo.Groups != nil {
vs = de.ItemInfo.Groups.Values() vs = de.ItemInfo.Groups.Values()
case de.ItemInfo.TeamsChats != nil:
vs = de.ItemInfo.TeamsChats.Values()
default:
vs = []string{"ERROR - Service not recognized"}
} }
if skipID { if skipID {

View File

@ -91,19 +91,14 @@ func (i ItemInfo) infoType() ItemType {
switch { switch {
case i.Folder != nil: case i.Folder != nil:
return i.Folder.ItemType return i.Folder.ItemType
case i.Exchange != nil: case i.Exchange != nil:
return i.Exchange.ItemType return i.Exchange.ItemType
case i.SharePoint != nil: case i.SharePoint != nil:
return i.SharePoint.ItemType return i.SharePoint.ItemType
case i.OneDrive != nil: case i.OneDrive != nil:
return i.OneDrive.ItemType return i.OneDrive.ItemType
case i.Groups != nil: case i.Groups != nil:
return i.Groups.ItemType return i.Groups.ItemType
case i.TeamsChats != nil: case i.TeamsChats != nil:
return i.TeamsChats.ItemType return i.TeamsChats.ItemType
} }
@ -115,19 +110,14 @@ func (i ItemInfo) size() int64 {
switch { switch {
case i.Exchange != nil: case i.Exchange != nil:
return i.Exchange.Size return i.Exchange.Size
case i.OneDrive != nil: case i.OneDrive != nil:
return i.OneDrive.Size return i.OneDrive.Size
case i.SharePoint != nil: case i.SharePoint != nil:
return i.SharePoint.Size return i.SharePoint.Size
case i.Groups != nil: case i.Groups != nil:
return i.Groups.Size return i.Groups.Size
case i.Folder != nil: case i.Folder != nil:
return i.Folder.Size return i.Folder.Size
case i.TeamsChats != nil: case i.TeamsChats != nil:
return int64(i.TeamsChats.Chat.MessageCount) return int64(i.TeamsChats.Chat.MessageCount)
} }
@ -139,19 +129,14 @@ func (i ItemInfo) Modified() time.Time {
switch { switch {
case i.Exchange != nil: case i.Exchange != nil:
return i.Exchange.Modified return i.Exchange.Modified
case i.OneDrive != nil: case i.OneDrive != nil:
return i.OneDrive.Modified return i.OneDrive.Modified
case i.SharePoint != nil: case i.SharePoint != nil:
return i.SharePoint.Modified return i.SharePoint.Modified
case i.Groups != nil: case i.Groups != nil:
return i.Groups.Modified return i.Groups.Modified
case i.Folder != nil: case i.Folder != nil:
return i.Folder.Modified return i.Folder.Modified
case i.TeamsChats != nil: case i.TeamsChats != nil:
return i.TeamsChats.Modified return i.TeamsChats.Modified
} }
@ -163,19 +148,14 @@ func (i ItemInfo) uniqueLocation(baseLoc *path.Builder) (*uniqueLoc, error) {
switch { switch {
case i.Exchange != nil: case i.Exchange != nil:
return i.Exchange.uniqueLocation(baseLoc) return i.Exchange.uniqueLocation(baseLoc)
case i.OneDrive != nil: case i.OneDrive != nil:
return i.OneDrive.uniqueLocation(baseLoc) return i.OneDrive.uniqueLocation(baseLoc)
case i.SharePoint != nil: case i.SharePoint != nil:
return i.SharePoint.uniqueLocation(baseLoc) return i.SharePoint.uniqueLocation(baseLoc)
case i.Groups != nil: case i.Groups != nil:
return i.Groups.uniqueLocation(baseLoc) return i.Groups.uniqueLocation(baseLoc)
case i.TeamsChats != nil: case i.TeamsChats != nil:
return i.TeamsChats.uniqueLocation(baseLoc) return i.TeamsChats.uniqueLocation(baseLoc)
default: default:
return nil, clues.New("unsupported type") return nil, clues.New("unsupported type")
} }
@ -185,19 +165,14 @@ func (i ItemInfo) updateFolder(f *FolderInfo) error {
switch { switch {
case i.Exchange != nil: case i.Exchange != nil:
return i.Exchange.updateFolder(f) return i.Exchange.updateFolder(f)
case i.OneDrive != nil: case i.OneDrive != nil:
return i.OneDrive.updateFolder(f) return i.OneDrive.updateFolder(f)
case i.SharePoint != nil: case i.SharePoint != nil:
return i.SharePoint.updateFolder(f) return i.SharePoint.updateFolder(f)
case i.Groups != nil: case i.Groups != nil:
return i.Groups.updateFolder(f) return i.Groups.updateFolder(f)
case i.TeamsChats != nil: case i.TeamsChats != nil:
return i.TeamsChats.updateFolder(f) return i.TeamsChats.updateFolder(f)
default: default:
return clues.New("unsupported type") return clues.New("unsupported type")
} }

View File

@ -44,7 +44,7 @@ type ChatInfo struct {
LastMessagePreview string `json:"preview,omitempty"` LastMessagePreview string `json:"preview,omitempty"`
Members []string `json:"members,omitempty"` Members []string `json:"members,omitempty"`
MessageCount int `json:"messageCount,omitempty"` MessageCount int `json:"messageCount,omitempty"`
Name string `json:"name,omitempty"` Topic string `json:"topic,omitempty"`
} }
// Headers returns the human-readable names of properties in a ChatsInfo // Headers returns the human-readable names of properties in a ChatsInfo
@ -52,7 +52,7 @@ type ChatInfo struct {
func (i TeamsChatsInfo) Headers() []string { func (i TeamsChatsInfo) Headers() []string {
switch i.ItemType { switch i.ItemType {
case TeamsChat: case TeamsChat:
return []string{"Name", "Last message", "Last message at", "Message count", "Created", "Members"} return []string{"Topic", "Last message", "Last message at", "Message count", "Created", "Members"}
} }
return []string{} return []string{}
@ -75,7 +75,7 @@ func (i TeamsChatsInfo) Values() []string {
} }
return []string{ return []string{
i.Chat.Name, i.Chat.Topic,
i.Chat.LastMessagePreview, i.Chat.LastMessagePreview,
dttm.FormatToTabularDisplay(i.Chat.LastMessageAt), dttm.FormatToTabularDisplay(i.Chat.LastMessageAt),
strconv.Itoa(i.Chat.MessageCount), strconv.Itoa(i.Chat.MessageCount),

View File

@ -42,7 +42,7 @@ func (suite *ChatsUnitSuite) TestChatsPrintable() {
LastMessagePreview: "last message preview", LastMessagePreview: "last message preview",
Members: []string{"foo@bar.baz", "fnords@smarf.zoomba"}, Members: []string{"foo@bar.baz", "fnords@smarf.zoomba"},
MessageCount: 42, MessageCount: 42,
Name: "chat name", Topic: "chat name",
}, },
}, },
expectHs: []string{"Name", "Last message", "Last message at", "Message count", "Created", "Members"}, expectHs: []string{"Name", "Last message", "Last message at", "Message count", "Created", "Members"},

View File

@ -9,6 +9,7 @@ import (
"github.com/alcionai/corso/src/pkg/backup/details" "github.com/alcionai/corso/src/pkg/backup/details"
"github.com/alcionai/corso/src/pkg/backup/identity" "github.com/alcionai/corso/src/pkg/backup/identity"
"github.com/alcionai/corso/src/pkg/dttm"
"github.com/alcionai/corso/src/pkg/fault" "github.com/alcionai/corso/src/pkg/fault"
"github.com/alcionai/corso/src/pkg/filters" "github.com/alcionai/corso/src/pkg/filters"
"github.com/alcionai/corso/src/pkg/path" "github.com/alcionai/corso/src/pkg/path"
@ -254,21 +255,81 @@ func (sr *TeamsChatsRestore) ChatMember(memberID string) []TeamsChatsScope {
} }
} }
// ChatName produces one or more teamsChats chat name info scopes. // ChatTopic produces one or more teamsChats chat name info scopes.
// Matches any chat whose name contains the provided string. // Matches any chat whose name contains the provided string.
// If any slice contains selectors.Any, that slice is reduced to [selectors.Any] // If any slice contains selectors.Any, that slice is reduced to [selectors.Any]
// If any slice contains selectors.None, that slice is reduced to [selectors.None] // If any slice contains selectors.None, that slice is reduced to [selectors.None]
// If any slice is empty, it defaults to [selectors.None] // If any slice is empty, it defaults to [selectors.None]
func (sr *TeamsChatsRestore) ChatName(memberID string) []TeamsChatsScope { func (sr *TeamsChatsRestore) ChatTopic(topic string) []TeamsChatsScope {
return []TeamsChatsScope{ return []TeamsChatsScope{
makeInfoScope[TeamsChatsScope]( makeInfoScope[TeamsChatsScope](
TeamsChatsChat, TeamsChatsChat,
TeamsChatsInfoChatName, TeamsChatsInfoChatTopic,
[]string{memberID}, []string{topic},
filters.In), filters.In),
} }
} }
// ChatCreatedBefore produces one or more teamsChats chat name info scopes.
// Matches any chat whose creation datetime is before the given datetime.
// If any slice contains selectors.Any, that slice is reduced to [selectors.Any]
// If any slice contains selectors.None, that slice is reduced to [selectors.None]
// If any slice is empty, it defaults to [selectors.None]
func (sr *TeamsChatsRestore) ChatCreatedBefore(datetime string) []TeamsChatsScope {
return []TeamsChatsScope{
makeInfoScope[TeamsChatsScope](
TeamsChatsChat,
TeamsChatsInfoChatCreatedBefore,
[]string{datetime},
filters.Greater),
}
}
// ChatCreatedBefore produces one or more teamsChats chat name info scopes.
// Matches any chat whose creation datetime is after the given datetime.
// If any slice contains selectors.Any, that slice is reduced to [selectors.Any]
// If any slice contains selectors.None, that slice is reduced to [selectors.None]
// If any slice is empty, it defaults to [selectors.None]
func (sr *TeamsChatsRestore) ChatCreatedAfter(datetime string) []TeamsChatsScope {
return []TeamsChatsScope{
makeInfoScope[TeamsChatsScope](
TeamsChatsChat,
TeamsChatsInfoChatCreatedAfter,
[]string{datetime},
filters.Less),
}
}
// ChatLastMessageBefore produces one or more teamsChats chat name info scopes.
// Matches any chat whose most recent message (if it has messages) is before the given datetime.
// If any slice contains selectors.Any, that slice is reduced to [selectors.Any]
// If any slice contains selectors.None, that slice is reduced to [selectors.None]
// If any slice is empty, it defaults to [selectors.None]
func (sr *TeamsChatsRestore) ChatLastMessageBefore(datetime string) []TeamsChatsScope {
return []TeamsChatsScope{
makeInfoScope[TeamsChatsScope](
TeamsChatsChat,
TeamsChatsInfoChatLastMessageBefore,
[]string{datetime},
filters.Greater),
}
}
// ChatCreatedBefore produces one or more teamsChats chat name info scopes.
// Matches any chat whose most recent message (if it has messages) is after the given datetime.
// If any slice contains selectors.Any, that slice is reduced to [selectors.Any]
// If any slice contains selectors.None, that slice is reduced to [selectors.None]
// If any slice is empty, it defaults to [selectors.None]
func (sr *TeamsChatsRestore) ChatLastMessageAfter(datetime string) []TeamsChatsScope {
return []TeamsChatsScope{
makeInfoScope[TeamsChatsScope](
TeamsChatsChat,
TeamsChatsInfoChatLastMesasgeAfter,
[]string{datetime},
filters.Less),
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Categories // Categories
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -288,8 +349,12 @@ const (
TeamsChatsChat teamsChatsCategory = "TeamsChatsChat" TeamsChatsChat teamsChatsCategory = "TeamsChatsChat"
// data contained within details.ItemInfo // data contained within details.ItemInfo
TeamsChatsInfoChatMember teamsChatsCategory = "TeamsChatsInfoChatMember" TeamsChatsInfoChatCreatedBefore teamsChatsCategory = "TeamsChatsInfoChatCreatedBefore"
TeamsChatsInfoChatName teamsChatsCategory = "TeamsChatsInfoChatName" TeamsChatsInfoChatCreatedAfter teamsChatsCategory = "TeamsChatsInfoChatCreatedAfter"
TeamsChatsInfoChatLastMessageBefore teamsChatsCategory = "TeamsChatsInfoChatLastMessageBefore"
TeamsChatsInfoChatLastMesasgeAfter teamsChatsCategory = "TeamsChatsInfoChatLastMesasgeAfter"
TeamsChatsInfoChatMember teamsChatsCategory = "TeamsChatsInfoChatMember"
TeamsChatsInfoChatTopic teamsChatsCategory = "TeamsChatsInfoChatName"
) )
// teamsChatsLeafProperties describes common metadata of the leaf categories // teamsChatsLeafProperties describes common metadata of the leaf categories
@ -317,7 +382,9 @@ func (ec teamsChatsCategory) String() string {
// Ex: TeamsChatsUser.leafCat() => TeamsChatsUser // Ex: TeamsChatsUser.leafCat() => TeamsChatsUser
func (ec teamsChatsCategory) leafCat() categorizer { func (ec teamsChatsCategory) leafCat() categorizer {
switch ec { switch ec {
case TeamsChatsChat, TeamsChatsInfoChatMember, TeamsChatsInfoChatName: case TeamsChatsChat, TeamsChatsInfoChatMember, TeamsChatsInfoChatTopic,
TeamsChatsInfoChatCreatedBefore, TeamsChatsInfoChatCreatedAfter,
TeamsChatsInfoChatLastMessageBefore, TeamsChatsInfoChatLastMesasgeAfter:
return TeamsChatsChat return TeamsChatsChat
} }
@ -505,8 +572,16 @@ func (s TeamsChatsScope) matchesInfo(dii details.ItemInfo) bool {
switch infoCat { switch infoCat {
case TeamsChatsInfoChatMember: case TeamsChatsInfoChatMember:
i = strings.Join(info.Chat.Members, ",") i = strings.Join(info.Chat.Members, ",")
case TeamsChatsInfoChatName: case TeamsChatsInfoChatTopic:
i = info.Chat.Name i = info.Chat.Topic
case TeamsChatsInfoChatCreatedBefore, TeamsChatsInfoChatCreatedAfter:
i = dttm.Format(info.Chat.CreatedAt)
case TeamsChatsInfoChatLastMessageBefore, TeamsChatsInfoChatLastMesasgeAfter:
if info.Chat.MessageCount < 1 {
return false
}
i = dttm.Format(info.Chat.LastMessageAt)
} }
return s.Matches(infoCat, i) return s.Matches(infoCat, i)

View File

@ -12,6 +12,7 @@ import (
"github.com/alcionai/corso/src/internal/tester" "github.com/alcionai/corso/src/internal/tester"
"github.com/alcionai/corso/src/pkg/backup/details" "github.com/alcionai/corso/src/pkg/backup/details"
"github.com/alcionai/corso/src/pkg/dttm"
"github.com/alcionai/corso/src/pkg/fault" "github.com/alcionai/corso/src/pkg/fault"
"github.com/alcionai/corso/src/pkg/filters" "github.com/alcionai/corso/src/pkg/filters"
"github.com/alcionai/corso/src/pkg/path" "github.com/alcionai/corso/src/pkg/path"
@ -252,14 +253,16 @@ func (suite *TeamsChatsSelectorSuite) TestTeamsChatsScope_MatchesInfo() {
cs := NewTeamsChatsRestore(Any()) cs := NewTeamsChatsRestore(Any())
const ( const (
name = "smarf mcfnords" topic = "smarf mcfnords"
member = "cooks@2many.smarf" member = "cooks@2many.smarf"
subject = "I have seen the fnords!" subject = "I have seen the fnords!"
dtype = details.TeamsChat
) )
var ( var (
now = time.Now() now = time.Now()
future = now.Add(1 * time.Minute) past = dttm.Format(now.Add(-1 * time.Minute))
future = dttm.Format(now.Add(1 * time.Minute))
) )
infoWith := func(itype details.ItemType) details.ItemInfo { infoWith := func(itype details.ItemType) details.ItemInfo {
@ -269,11 +272,11 @@ func (suite *TeamsChatsSelectorSuite) TestTeamsChatsScope_MatchesInfo() {
Chat: details.ChatInfo{ Chat: details.ChatInfo{
CreatedAt: now, CreatedAt: now,
HasExternalMembers: false, HasExternalMembers: false,
LastMessageAt: future, LastMessageAt: now,
LastMessagePreview: "preview", LastMessagePreview: "preview",
Members: []string{member}, Members: []string{member},
MessageCount: 1, MessageCount: 1,
Name: name, Topic: topic,
}, },
}, },
} }
@ -285,12 +288,20 @@ func (suite *TeamsChatsSelectorSuite) TestTeamsChatsScope_MatchesInfo() {
scope []TeamsChatsScope scope []TeamsChatsScope
expect assert.BoolAssertionFunc expect assert.BoolAssertionFunc
}{ }{
{"chat with a different member", details.TeamsChat, cs.ChatMember("blarps"), assert.False}, {"chat with a different member", dtype, cs.ChatMember("blarps"), assert.False},
{"chat with the same member", details.TeamsChat, cs.ChatMember(member), assert.True}, {"chat with the same member", dtype, cs.ChatMember(member), assert.True},
{"chat with a member submatch search", details.TeamsChat, cs.ChatMember(member[2:5]), assert.True}, {"chat with a member submatch search", dtype, cs.ChatMember(member[2:5]), assert.True},
{"chat with a different name", details.TeamsChat, cs.ChatName("blarps"), assert.False}, {"chat with a different topic", dtype, cs.ChatTopic("blarps"), assert.False},
{"chat with the same name", details.TeamsChat, cs.ChatName(name), assert.True}, {"chat with the same topic", dtype, cs.ChatTopic(topic), assert.True},
{"chat with a subname search", details.TeamsChat, cs.ChatName(name[2:5]), assert.True}, {"chat with a subtopic search", dtype, cs.ChatTopic(topic[2:5]), assert.True},
{"chat created after", dtype, cs.ChatCreatedAfter(past), assert.True},
{"chat not created after", dtype, cs.ChatCreatedAfter(future), assert.False},
{"chat created before", dtype, cs.ChatCreatedBefore(future), assert.True},
{"chat not created before", dtype, cs.ChatCreatedBefore(past), assert.False},
{"chat last message after", dtype, cs.ChatLastMessageAfter(past), assert.True},
{"chat last message not after", dtype, cs.ChatLastMessageAfter(future), assert.False},
{"chat last message before", dtype, cs.ChatLastMessageBefore(future), assert.True},
{"chat last message not before", dtype, cs.ChatLastMessageBefore(past), assert.False},
} }
for _, test := range table { for _, test := range table {
suite.Run(test.name, func() { suite.Run(test.name, func() {

View File

@ -162,7 +162,7 @@ func channelMessageInfo(
modTime = lastReplyAt modTime = lastReplyAt
} }
preview, contentLen, err := getChatMessageContentPreview(msg) preview, contentLen, err := getChatMessageContentPreview(msg, msg)
if err != nil { if err != nil {
preview = "malformed or unparseable html" + preview preview = "malformed or unparseable html" + preview
} }
@ -180,7 +180,7 @@ func channelMessageInfo(
var lr details.ChannelMessageInfo var lr details.ChannelMessageInfo
if lastReply != nil { if lastReply != nil {
preview, contentLen, err = getChatMessageContentPreview(lastReply) preview, contentLen, err = getChatMessageContentPreview(lastReply, lastReply)
if err != nil { if err != nil {
preview = "malformed or unparseable html: " + preview preview = "malformed or unparseable html: " + preview
} }
@ -239,12 +239,28 @@ func GetChatMessageFrom(msg models.ChatMessageable) string {
return "" return ""
} }
func getChatMessageContentPreview(msg models.ChatMessageable) (string, int64, error) { // a hack for fulfilling getAttachmentser when the model doesn't
content, origSize, err := stripChatMessageHTML(msg) // provide GetAttachments()
type noAttachments struct{}
func (noAttachments) GetAttachments() []models.ChatMessageAttachmentable {
return []models.ChatMessageAttachmentable{}
}
type getBodyer interface {
GetBody() models.ItemBodyable
}
type getAttachmentser interface {
GetAttachments() []models.ChatMessageAttachmentable
}
func getChatMessageContentPreview(msg getBodyer, atts getAttachmentser) (string, int64, error) {
content, origSize, err := stripChatMessageHTML(msg, atts)
return str.Preview(content, 128), origSize, clues.Stack(err).OrNil() return str.Preview(content, 128), origSize, clues.Stack(err).OrNil()
} }
func stripChatMessageHTML(msg models.ChatMessageable) (string, int64, error) { func stripChatMessageHTML(msg getBodyer, atts getAttachmentser) (string, int64, error) {
var ( var (
content string content string
origSize int64 origSize int64
@ -256,7 +272,7 @@ func stripChatMessageHTML(msg models.ChatMessageable) (string, int64, error) {
origSize = int64(len(content)) origSize = int64(len(content))
content = replaceAttachmentMarkup(content, msg.GetAttachments()) content = replaceAttachmentMarkup(content, atts.GetAttachments())
content, err := html2text.FromString(content) content, err := html2text.FromString(content)
return content, origSize, clues.Stack(err).OrNil() return content, origSize, clues.Stack(err).OrNil()

View File

@ -712,7 +712,7 @@ func (suite *ChannelsAPIUnitSuite) TestStripChatMessageContent() {
msg.SetAttachments(test.attachments) msg.SetAttachments(test.attachments)
// not testing len; it's effectively covered by the content assertion // not testing len; it's effectively covered by the content assertion
result, _, err := stripChatMessageHTML(msg) result, _, err := stripChatMessageHTML(msg, msg)
assert.Equal(t, test.expect, result) assert.Equal(t, test.expect, result)
test.expectErr(t, err, clues.ToCore(err)) test.expectErr(t, err, clues.ToCore(err))
}) })

View File

@ -1,89 +0,0 @@
package api
import (
"testing"
"time"
"github.com/microsoftgraph/msgraph-sdk-go/models"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
"github.com/alcionai/corso/src/internal/common/ptr"
"github.com/alcionai/corso/src/internal/tester"
"github.com/alcionai/corso/src/pkg/backup/details"
)
type ChatsAPIUnitSuite struct {
tester.Suite
}
func TestChatsAPIUnitSuite(t *testing.T) {
suite.Run(t, &ChatsAPIUnitSuite{Suite: tester.NewUnitSuite(t)})
}
func (suite *ChatsAPIUnitSuite) TestChatsInfo() {
start := time.Now()
tests := []struct {
name string
chatAndExpected func() (models.Chatable, *details.TeamsChatsInfo)
}{
{
name: "Empty chat",
chatAndExpected: func() (models.Chatable, *details.TeamsChatsInfo) {
chat := models.NewChat()
i := &details.TeamsChatsInfo{
ItemType: details.TeamsChat,
Chat: details.ChatInfo{},
}
return chat, i
},
},
{
name: "All fields",
chatAndExpected: func() (models.Chatable, *details.TeamsChatsInfo) {
now := time.Now()
then := now.Add(1 * time.Hour)
chat := models.NewChat()
chat.SetTopic(ptr.To("Hello world"))
chat.SetCreatedDateTime(&now)
chat.SetLastUpdatedDateTime(&then)
i := &details.TeamsChatsInfo{
ItemType: details.TeamsChat,
Chat: details.ChatInfo{
Name: "Hello world",
},
}
return chat, i
},
},
}
for _, test := range tests {
suite.Run(test.name, func() {
t := suite.T()
chat, expected := test.chatAndExpected()
result := TeamsChatInfo(chat)
assert.Equal(t, expected.Chat.Name, result.Chat.Name)
expectLastUpdated := chat.GetLastUpdatedDateTime()
if expectLastUpdated != nil {
assert.Equal(t, ptr.Val(expectLastUpdated), result.Modified)
} else {
assert.True(t, result.Modified.After(start))
}
expectCreated := chat.GetCreatedDateTime()
if expectCreated != nil {
assert.Equal(t, ptr.Val(expectCreated), result.Chat.CreatedAt)
} else {
assert.True(t, result.Chat.CreatedAt.After(start))
}
})
}
}

View File

@ -2,6 +2,7 @@ package api
import ( import (
"context" "context"
"time"
"github.com/microsoftgraph/msgraph-sdk-go/chats" "github.com/microsoftgraph/msgraph-sdk-go/chats"
"github.com/microsoftgraph/msgraph-sdk-go/models" "github.com/microsoftgraph/msgraph-sdk-go/models"
@ -62,13 +63,47 @@ func (c Chats) GetChatByID(
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
func TeamsChatInfo(chat models.Chatable) *details.TeamsChatsInfo { func TeamsChatInfo(chat models.Chatable) *details.TeamsChatsInfo {
var (
// in case of an empty chat, we want to use Val instead of OrNow
lastModTime = ptr.Val(chat.GetLastUpdatedDateTime())
lastMsgPreview = chat.GetLastMessagePreview()
lastMsgCreatedAt time.Time
members = chat.GetMembers()
memberNames = []string{}
msgs = chat.GetMessages()
preview string
err error
)
if lastMsgPreview != nil {
preview, _, err = getChatMessageContentPreview(lastMsgPreview, noAttachments{})
if err != nil {
preview = "malformed or unparseable html" + preview
}
// in case of an empty mod time, we want to use the chat's mod time
// therefore Val instaed of OrNow
lastMsgCreatedAt = ptr.Val(lastMsgPreview.GetCreatedDateTime())
if lastModTime.Before(lastMsgCreatedAt) {
lastModTime = lastMsgCreatedAt
}
}
for _, m := range members {
memberNames = append(memberNames, ptr.Val(m.GetDisplayName()))
}
return &details.TeamsChatsInfo{ return &details.TeamsChatsInfo{
ItemType: details.TeamsChat, ItemType: details.TeamsChat,
Modified: ptr.OrNow(chat.GetLastUpdatedDateTime()), Modified: lastModTime,
Chat: details.ChatInfo{ Chat: details.ChatInfo{
CreatedAt: ptr.OrNow(chat.GetCreatedDateTime()), CreatedAt: ptr.OrNow(chat.GetCreatedDateTime()),
Name: ptr.Val(chat.GetTopic()), LastMessageAt: lastMsgCreatedAt,
LastMessagePreview: preview,
Members: memberNames,
MessageCount: len(msgs),
Topic: ptr.Val(chat.GetTopic()),
}, },
} }
} }

View File

@ -12,6 +12,79 @@ import (
"github.com/alcionai/corso/src/pkg/services/m365/api/pagers" "github.com/alcionai/corso/src/pkg/services/m365/api/pagers"
) )
// ---------------------------------------------------------------------------
// chat members pager
// ---------------------------------------------------------------------------
// delta queries are not supported
var _ pagers.NonDeltaHandler[models.ConversationMemberable] = &chatMembersPageCtrl{}
type chatMembersPageCtrl struct {
chatID string
gs graph.Servicer
builder *chats.ItemMembersRequestBuilder
options *chats.ItemMembersRequestBuilderGetRequestConfiguration
}
func (p *chatMembersPageCtrl) SetNextLink(nextLink string) {
p.builder = chats.NewItemMembersRequestBuilder(nextLink, p.gs.Adapter())
}
func (p *chatMembersPageCtrl) GetPage(
ctx context.Context,
) (pagers.NextLinkValuer[models.ConversationMemberable], error) {
resp, err := p.builder.Get(ctx, p.options)
return resp, graph.Stack(ctx, err).OrNil()
}
func (p *chatMembersPageCtrl) ValidModTimes() bool {
return true
}
func (c Chats) NewChatMembersPager(
chatID string,
cc CallConfig,
) *chatMembersPageCtrl {
builder := c.Stable.
Client().
Chats().
ByChatId(chatID).
Members()
options := &chats.ItemMembersRequestBuilderGetRequestConfiguration{
QueryParameters: &chats.ItemMembersRequestBuilderGetQueryParameters{},
Headers: newPreferHeaders(preferPageSize(maxNonDeltaPageSize)),
}
if len(cc.Select) > 0 {
options.QueryParameters.Select = cc.Select
}
if len(cc.Expand) > 0 {
options.QueryParameters.Expand = cc.Expand
}
return &chatMembersPageCtrl{
chatID: chatID,
builder: builder,
gs: c.Stable,
options: options,
}
}
// GetChatMembers fetches a delta of all members in the chat.
func (c Chats) GetChatMembers(
ctx context.Context,
chatID string,
cc CallConfig,
) ([]models.ConversationMemberable, error) {
ctx = clues.Add(ctx, "chat_id", chatID)
pager := c.NewChatMembersPager(chatID, cc)
items, err := pagers.BatchEnumerateItems[models.ConversationMemberable](ctx, pager)
return items, graph.Stack(ctx, err).OrNil()
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// chat message pager // chat message pager
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -85,26 +158,6 @@ func (c Chats) GetChatMessages(
return items, graph.Stack(ctx, err).OrNil() return items, graph.Stack(ctx, err).OrNil()
} }
// GetChatMessageIDs fetches a delta of all messages in the chat.
// returns two maps: addedItems, deletedItems
func (c Chats) GetChatMessageIDs(
ctx context.Context,
chatID string,
cc CallConfig,
) (pagers.AddedAndRemoved, error) {
aar, err := pagers.GetAddedAndRemovedItemIDs[models.ChatMessageable](
ctx,
c.NewChatMessagePager(chatID, CallConfig{}),
nil,
"",
false, // delta queries are not supported
0,
pagers.AddedAndRemovedByDeletedDateTime[models.ChatMessageable],
IsNotSystemMessage)
return aar, clues.Stack(err).OrNil()
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// chat pager // chat pager
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -61,6 +61,11 @@ func (suite *ChatsPagerIntgSuite) TestEnumerateChats() {
ac, ac,
chatID, chatID,
chat.GetLastMessagePreview()) chat.GetLastMessagePreview())
testEnumerateChatMembers(
suite.T(),
ac,
chatID)
}) })
} }
} }
@ -123,3 +128,22 @@ func testEnumerateChatMessages(
} }
} }
} }
func testEnumerateChatMembers(
t *testing.T,
ac Chats,
chatID string,
) {
ctx, flush := tester.NewContext(t)
defer flush()
cc := CallConfig{}
members, err := ac.GetChatMembers(ctx, chatID, cc)
require.NoError(t, err, clues.ToCore(err))
// no good way to test members right now. Even though
// the graph api response contains the `userID` and `email`
// properties, we can't access them in the sdk model
assert.NotEmpty(t, members)
}

View File

@ -0,0 +1,158 @@
package api
import (
"testing"
"time"
"github.com/google/uuid"
"github.com/microsoftgraph/msgraph-sdk-go/models"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
"github.com/alcionai/corso/src/internal/common/ptr"
"github.com/alcionai/corso/src/internal/m365/collection/teamschats/testdata"
"github.com/alcionai/corso/src/internal/tester"
"github.com/alcionai/corso/src/pkg/backup/details"
)
type ChatsAPIUnitSuite struct {
tester.Suite
}
func TestChatsAPIUnitSuite(t *testing.T) {
suite.Run(t, &ChatsAPIUnitSuite{Suite: tester.NewUnitSuite(t)})
}
func (suite *ChatsAPIUnitSuite) TestChatsInfo() {
start := time.Now()
tests := []struct {
name string
expected func() (models.Chatable, *details.TeamsChatsInfo)
}{
{
name: "Empty chat",
expected: func() (models.Chatable, *details.TeamsChatsInfo) {
chat := models.NewChat()
i := &details.TeamsChatsInfo{
ItemType: details.TeamsChat,
Modified: ptr.Val(chat.GetLastUpdatedDateTime()),
Chat: details.ChatInfo{},
}
return chat, i
},
},
{
name: "All fields",
expected: func() (models.Chatable, *details.TeamsChatsInfo) {
now := time.Now()
then := now.Add(1 * time.Hour)
id := uuid.NewString()
chat := testdata.StubChats(id)[0]
chat.SetTopic(ptr.To("Hello world"))
chat.SetCreatedDateTime(&now)
chat.SetLastUpdatedDateTime(&now)
chat.GetLastMessagePreview().SetCreatedDateTime(&then)
msgs := testdata.StubChatMessages(ptr.Val(chat.GetLastMessagePreview().GetId()))
chat.SetMessages(msgs)
i := &details.TeamsChatsInfo{
ItemType: details.TeamsChat,
Modified: then,
Chat: details.ChatInfo{
Topic: "Hello world",
LastMessageAt: then,
LastMessagePreview: id,
Members: []string{},
MessageCount: 1,
},
}
return chat, i
},
},
{
name: "last message preview, but no messages",
expected: func() (models.Chatable, *details.TeamsChatsInfo) {
now := time.Now()
then := now.Add(1 * time.Hour)
id := uuid.NewString()
chat := testdata.StubChats(id)[0]
chat.SetTopic(ptr.To("Hello world"))
chat.SetCreatedDateTime(&now)
chat.SetLastUpdatedDateTime(&now)
chat.GetLastMessagePreview().SetCreatedDateTime(&then)
i := &details.TeamsChatsInfo{
ItemType: details.TeamsChat,
Modified: then,
Chat: details.ChatInfo{
Topic: "Hello world",
LastMessageAt: then,
LastMessagePreview: id,
Members: []string{},
MessageCount: 0,
},
}
return chat, i
},
},
{
name: "chat only, no messages",
expected: func() (models.Chatable, *details.TeamsChatsInfo) {
now := time.Now()
then := now.Add(1 * time.Hour)
chat := testdata.StubChats(uuid.NewString())[0]
chat.SetTopic(ptr.To("Hello world"))
chat.SetCreatedDateTime(&now)
chat.SetLastUpdatedDateTime(&then)
chat.SetLastMessagePreview(nil)
chat.SetMessages(nil)
i := &details.TeamsChatsInfo{
ItemType: details.TeamsChat,
Modified: then,
Chat: details.ChatInfo{
Topic: "Hello world",
LastMessageAt: time.Time{},
LastMessagePreview: "",
Members: []string{},
MessageCount: 0,
},
}
return chat, i
},
},
}
for _, test := range tests {
suite.Run(test.name, func() {
t := suite.T()
chat, expected := test.expected()
result := TeamsChatInfo(chat)
assert.Equal(t, expected.Chat.Topic, result.Chat.Topic)
expectCreated := chat.GetCreatedDateTime()
if expectCreated != nil {
assert.Equal(t, ptr.Val(expectCreated), result.Chat.CreatedAt)
} else {
assert.True(t, result.Chat.CreatedAt.After(start))
}
assert.Truef(
t,
expected.Modified.Equal(result.Modified),
"modified time doesn't match\nexpected %v\ngot %v",
expected.Modified,
result.Modified)
})
}
}