diff --git a/src/cli/backup/groups.go b/src/cli/backup/groups.go index 9f7f928c8..cef2bbf49 100644 --- a/src/cli/backup/groups.go +++ b/src/cli/backup/groups.go @@ -37,26 +37,25 @@ const ( groupsServiceCommandDetailsUseSuffix = "--backup " ) -// TODO: correct examples const ( - groupsServiceCommandCreateExamples = `# Backup all Groups data for Alice -corso backup create groups --group alice@example.com + groupsServiceCommandCreateExamples = `# Backup all Groups and Teams data for the Marketing group +corso backup create groups --group Marketing -# Backup only Groups contacts for Alice and Bob -corso backup create groups --group engineering,sales --data contacts +# Backup only Teams conversations messages +corso backup create groups --group Marketing --data messages -# Backup all Groups data for all M365 users +# Backup all Groups and Teams data for all groups corso backup create groups --group '*'` groupsServiceCommandDeleteExamples = `# Delete Groups backup with ID 1234abcd-12ab-cd34-56de-1234abcd corso backup delete groups --backup 1234abcd-12ab-cd34-56de-1234abcd` - groupsServiceCommandDetailsExamples = `# Explore items in Alice's latest backup (1234abcd...) + groupsServiceCommandDetailsExamples = `# Explore items in Marketing's latest backup (1234abcd...) corso backup details groups --backup 1234abcd-12ab-cd34-56de-1234abcd -# Explore calendar events occurring after start of 2022 +# Explore Marketing messages posted after the start of 2022 corso backup details groups --backup 1234abcd-12ab-cd34-56de-1234abcd \ - --event-starts-after 2022-01-01T00:00:00` + --last-message-reply-after 2022-01-01T00:00:00` ) // called by backup.go to map subcommands to provider-specific handling. @@ -107,6 +106,7 @@ func addGroupsCommands(cmd *cobra.Command) *cobra.Command { // Flags addition ordering should follow the order we want them to appear in help and docs: // More generic (ex: --user) and more frequently used flags take precedence. flags.AddBackupIDFlag(c, true) + flags.AddGroupDetailsAndRestoreFlags(c) flags.AddCorsoPassphaseFlags(c) flags.AddAWSCredsFlags(c) flags.AddAzureCredsFlags(c) diff --git a/src/cli/export/groups.go b/src/cli/export/groups.go index 130f3bd66..d46e05d41 100644 --- a/src/cli/export/groups.go +++ b/src/cli/export/groups.go @@ -27,6 +27,7 @@ func addGroupsCommands(cmd *cobra.Command) *cobra.Command { fs.SortFlags = false flags.AddBackupIDFlag(c, true) + flags.AddGroupDetailsAndRestoreFlags(c) flags.AddExportConfigFlags(c) flags.AddFailFastFlag(c) flags.AddCorsoPassphaseFlags(c) @@ -36,23 +37,22 @@ func addGroupsCommands(cmd *cobra.Command) *cobra.Command { return c } -// TODO: correct examples const ( groupsServiceCommand = "groups" teamsServiceCommand = "teams" groupsServiceCommandUseSuffix = " --backup " //nolint:lll - groupsServiceCommandExportExamples = `# Export file with ID 98765abcdef in Bob's last backup (1234abcd...) to my-exports directory -corso export groups my-exports --backup 1234abcd-12ab-cd34-56de-1234abcd --file 98765abcdef + groupsServiceCommandExportExamples = `# Export a message in Marketing's last backup (1234abcd...) to my-exports directory +corso export groups my-exports --backup 1234abcd-12ab-cd34-56de-1234abcd --message 98765abcdef -# Export files named "FY2021 Planning.xlsx" in "Documents/Finance Reports" to current directory +# Export all messages named in channel "Finance Reports" to the current directory corso export groups . --backup 1234abcd-12ab-cd34-56de-1234abcd \ - --file "FY2021 Planning.xlsx" --folder "Documents/Finance Reports" + --message '*' --channel "Finance Reports" -# Export all files and folders in folder "Documents/Finance Reports" that were created before 2020 to my-exports +# Export all messages in channel "Finance Reports" that were created before 2020 to my-exports corso export groups my-exports --backup 1234abcd-12ab-cd34-56de-1234abcd - --folder "Documents/Finance Reports" --file-created-before 2020-01-01T00:00:00` + --channel "Finance Reports" --message-created-before 2020-01-01T00:00:00` ) // `corso export groups [...] ` diff --git a/src/cli/flags/groups.go b/src/cli/flags/groups.go index e8ecae9ae..0ac365ac6 100644 --- a/src/cli/flags/groups.go +++ b/src/cli/flags/groups.go @@ -6,12 +6,60 @@ import ( const DataMessages = "messages" -const GroupFN = "group" +const ( + ChannelFN = "channel" + GroupFN = "group" + MessageFN = "message" -var GroupFV []string + MessageCreatedAfterFN = "message-created-after" + MessageCreatedBeforeFN = "message-created-before" + MessageLastReplyAfterFN = "message-last-reply-after" + MessageLastReplyBeforeFN = "message-last-reply-before" +) + +var ( + ChannelFV []string + GroupFV []string + MessageFV []string + + MessageCreatedAfterFV string + MessageCreatedBeforeFV string + MessageLastReplyAfterFV string + MessageLastReplyBeforeFV string +) func AddGroupDetailsAndRestoreFlags(cmd *cobra.Command) { - // TODO: implement groups specific flags + fs := cmd.Flags() + + fs.StringSliceVar( + &ChannelFV, + ChannelFN, nil, + "Select data within a Team's Channel.") + + fs.StringSliceVar( + &MessageFV, + MessageFN, nil, + "Select messages by reference.") + + fs.StringVar( + &MessageCreatedAfterFV, + MessageCreatedAfterFN, "", + "Select messages created after this datetime.") + + fs.StringVar( + &MessageCreatedBeforeFV, + MessageCreatedBeforeFN, "", + "Select messages created before this datetime.") + + fs.StringVar( + &MessageLastReplyAfterFV, + MessageLastReplyAfterFN, "", + "Select messages with replies after this datetime.") + + fs.StringVar( + &MessageLastReplyBeforeFV, + MessageLastReplyBeforeFN, "", + "Select messages with replies before this datetime.") } // AddGroupFlag adds the --group flag, which accepts id or name values. diff --git a/src/cli/flags/onedrive.go b/src/cli/flags/onedrive.go index 62f69f0b7..c91c57d43 100644 --- a/src/cli/flags/onedrive.go +++ b/src/cli/flags/onedrive.go @@ -43,6 +43,7 @@ func AddOneDriveDetailsAndRestoreFlags(cmd *cobra.Command) { &FileCreatedAfterFV, FileCreatedAfterFN, "", "Select files created after this datetime.") + fs.StringVar( &FileCreatedBeforeFV, FileCreatedBeforeFN, "", diff --git a/src/cli/utils/groups.go b/src/cli/utils/groups.go index f48fda5fd..258c9526e 100644 --- a/src/cli/utils/groups.go +++ b/src/cli/utils/groups.go @@ -11,7 +11,14 @@ import ( ) type GroupsOpts struct { - Groups []string + Groups []string + Channels []string + Messages []string + + MessageCreatedAfter string + MessageCreatedBefore string + MessageLastReplyAfter string + MessageLastReplyBefore string SiteID []string Library string @@ -60,17 +67,22 @@ func AddGroupsCategories(sel *selectors.GroupsBackup, cats []string) *selectors. func MakeGroupsOpts(cmd *cobra.Command) GroupsOpts { return GroupsOpts{ - Groups: flags.GroupFV, + Groups: flags.GroupFV, + Channels: flags.ChannelFV, + Messages: flags.MessageFV, + SiteID: flags.SiteIDFV, - SiteID: flags.SiteIDFV, - - Library: flags.LibraryFV, - FileName: flags.FileNameFV, - FolderPath: flags.FolderPathFV, - FileCreatedAfter: flags.FileCreatedAfterFV, - FileCreatedBefore: flags.FileCreatedBeforeFV, - FileModifiedAfter: flags.FileModifiedAfterFV, - FileModifiedBefore: flags.FileModifiedBeforeFV, + Library: flags.LibraryFV, + FileName: flags.FileNameFV, + FolderPath: flags.FolderPathFV, + FileCreatedAfter: flags.FileCreatedAfterFV, + FileCreatedBefore: flags.FileCreatedBeforeFV, + FileModifiedAfter: flags.FileModifiedAfterFV, + FileModifiedBefore: flags.FileModifiedBeforeFV, + MessageCreatedAfter: flags.MessageCreatedAfterFV, + MessageCreatedBefore: flags.MessageCreatedBeforeFV, + MessageLastReplyAfter: flags.MessageLastReplyAfterFV, + MessageLastReplyBefore: flags.MessageLastReplyBeforeFV, ListFolder: flags.ListFolderFV, ListItem: flags.ListItemFV, @@ -110,12 +122,30 @@ func ValidateGroupsRestoreFlags(backupID string, opts GroupsOpts) error { return clues.New("invalid time format for " + flags.FileModifiedBeforeFN) } + if _, ok := opts.Populated[flags.MessageCreatedAfterFN]; ok && !IsValidTimeFormat(opts.MessageCreatedAfter) { + return clues.New("invalid time format for " + flags.MessageCreatedAfterFN) + } + + if _, ok := opts.Populated[flags.MessageCreatedBeforeFN]; ok && !IsValidTimeFormat(opts.MessageCreatedBefore) { + return clues.New("invalid time format for " + flags.MessageCreatedBeforeFN) + } + + if _, ok := opts.Populated[flags.MessageLastReplyAfterFN]; ok && !IsValidTimeFormat(opts.MessageLastReplyAfter) { + return clues.New("invalid time format for " + flags.MessageLastReplyAfterFN) + } + + if _, ok := opts.Populated[flags.MessageLastReplyBeforeFN]; ok && !IsValidTimeFormat(opts.MessageLastReplyBefore) { + return clues.New("invalid time format for " + flags.MessageLastReplyBeforeFN) + } + + // TODO(meain): selectors (refer sharepoint) + return validateRestoreConfigFlags(flags.CollisionsFV, opts.RestoreCfg) } -// AddGroupInfo adds the scope of the provided values to the selector's +// AddGroupsFilter adds the scope of the provided values to the selector's // filter set -func AddGroupInfo( +func AddGroupsFilter( sel *selectors.GroupsRestore, v string, f func(string) []selectors.GroupsScope, @@ -130,16 +160,15 @@ func AddGroupInfo( // IncludeGroupsRestoreDataSelectors builds the common data-selector // inclusions for Group commands. func IncludeGroupsRestoreDataSelectors(ctx context.Context, opts GroupsOpts) *selectors.GroupsRestore { - groups := opts.Groups - - lg := len(opts.Groups) - - // TODO(meain): handle sites once we add non-root site backup - // ls := len(opts.SiteID) - - lfp, lfn := len(opts.FolderPath), len(opts.FileName) - slp, sli := len(opts.ListFolder), len(opts.ListItem) - pf, pi := len(opts.PageFolder), len(opts.Page) + var ( + groups = opts.Groups + lfp, lfn = len(opts.FolderPath), len(opts.FileName) + llf, lli = len(opts.ListFolder), len(opts.ListItem) + lpf, lpi = len(opts.PageFolder), len(opts.Page) + lg, lch, lm = len(opts.Groups), len(opts.Channels), len(opts.Messages) + // TODO(meain): handle sites once we add non-root site backup + // ls := len(opts.SiteID) + ) if lg == 0 { groups = selectors.Any() @@ -147,59 +176,80 @@ func IncludeGroupsRestoreDataSelectors(ctx context.Context, opts GroupsOpts) *se sel := selectors.NewGroupsRestore(groups) - if lfp+lfn+slp+sli+pf+pi == 0 { + if lfp+lfn+llf+lli+lpf+lpi+lch+lm == 0 { sel.Include(sel.AllData()) return sel } - if lfp+lfn > 0 { - if lfn == 0 { - opts.FileName = selectors.Any() + // sharepoint site selectors + + if lfp+lfn+llf+lli+lpf+lpi > 0 { + if lfp+lfn > 0 { + if lfn == 0 { + opts.FileName = selectors.Any() + } + + opts.FolderPath = trimFolderSlash(opts.FolderPath) + containsFolders, prefixFolders := splitFoldersIntoContainsAndPrefix(opts.FolderPath) + + if len(containsFolders) > 0 { + sel.Include(sel.LibraryItems(containsFolders, opts.FileName)) + } + + if len(prefixFolders) > 0 { + sel.Include(sel.LibraryItems(prefixFolders, opts.FileName, selectors.PrefixMatch())) + } } - opts.FolderPath = trimFolderSlash(opts.FolderPath) - containsFolders, prefixFolders := splitFoldersIntoContainsAndPrefix(opts.FolderPath) + if llf+lli > 0 { + if lli == 0 { + opts.ListItem = selectors.Any() + } - if len(containsFolders) > 0 { - sel.Include(sel.LibraryItems(containsFolders, opts.FileName)) + opts.ListFolder = trimFolderSlash(opts.ListFolder) + containsFolders, prefixFolders := splitFoldersIntoContainsAndPrefix(opts.ListFolder) + + if len(containsFolders) > 0 { + sel.Include(sel.ListItems(containsFolders, opts.ListItem)) + } + + if len(prefixFolders) > 0 { + sel.Include(sel.ListItems(prefixFolders, opts.ListItem, selectors.PrefixMatch())) + } } - if len(prefixFolders) > 0 { - sel.Include(sel.LibraryItems(prefixFolders, opts.FileName, selectors.PrefixMatch())) + if lpf+lpi > 0 { + if lpi == 0 { + opts.Page = selectors.Any() + } + + opts.PageFolder = trimFolderSlash(opts.PageFolder) + containsFolders, prefixFolders := splitFoldersIntoContainsAndPrefix(opts.PageFolder) + + if len(containsFolders) > 0 { + sel.Include(sel.PageItems(containsFolders, opts.Page)) + } + + if len(prefixFolders) > 0 { + sel.Include(sel.PageItems(prefixFolders, opts.Page, selectors.PrefixMatch())) + } } } - if slp+sli > 0 { - if sli == 0 { - opts.ListItem = selectors.Any() + // channel and message selectors + + if lch+lm > 0 { + // if no channel is specified, include all channels + if lch == 0 { + opts.Channels = selectors.Any() } - opts.ListFolder = trimFolderSlash(opts.ListFolder) - containsFolders, prefixFolders := splitFoldersIntoContainsAndPrefix(opts.ListFolder) - - if len(containsFolders) > 0 { - sel.Include(sel.ListItems(containsFolders, opts.ListItem)) - } - - if len(prefixFolders) > 0 { - sel.Include(sel.ListItems(prefixFolders, opts.ListItem, selectors.PrefixMatch())) - } - } - - if pf+pi > 0 { - if pi == 0 { - opts.Page = selectors.Any() - } - - opts.PageFolder = trimFolderSlash(opts.PageFolder) - containsFolders, prefixFolders := splitFoldersIntoContainsAndPrefix(opts.PageFolder) - - if len(containsFolders) > 0 { - sel.Include(sel.PageItems(containsFolders, opts.Page)) - } - - if len(prefixFolders) > 0 { - sel.Include(sel.PageItems(prefixFolders, opts.Page, selectors.PrefixMatch())) + // if no message is specified, only select channels + // otherwise, look for channel/message pairs + if lm == 0 { + sel.Include(sel.Channels(opts.Channels)) + } else { + sel.Include(sel.ChannelMessages(opts.Channels, opts.Messages)) } } @@ -211,9 +261,13 @@ func FilterGroupsRestoreInfoSelectors( sel *selectors.GroupsRestore, opts GroupsOpts, ) { - AddGroupInfo(sel, opts.Library, sel.Library) - AddGroupInfo(sel, opts.FileCreatedAfter, sel.CreatedAfter) - AddGroupInfo(sel, opts.FileCreatedBefore, sel.CreatedBefore) - AddGroupInfo(sel, opts.FileModifiedAfter, sel.ModifiedAfter) - AddGroupInfo(sel, opts.FileModifiedBefore, sel.ModifiedBefore) + AddGroupsFilter(sel, opts.Library, sel.Library) + AddGroupsFilter(sel, opts.FileCreatedAfter, sel.CreatedAfter) + AddGroupsFilter(sel, opts.FileCreatedBefore, sel.CreatedBefore) + AddGroupsFilter(sel, opts.FileModifiedAfter, sel.ModifiedAfter) + AddGroupsFilter(sel, opts.FileModifiedBefore, sel.ModifiedBefore) + AddGroupsFilter(sel, opts.MessageCreatedAfter, sel.MessageCreatedAfter) + AddGroupsFilter(sel, opts.MessageCreatedBefore, sel.MessageCreatedBefore) + AddGroupsFilter(sel, opts.MessageLastReplyAfter, sel.MessageLastReplyAfter) + AddGroupsFilter(sel, opts.MessageLastReplyBefore, sel.MessageLastReplyBefore) } diff --git a/src/cli/utils/groups_test.go b/src/cli/utils/groups_test.go index 66e6b64b1..65585d97e 100644 --- a/src/cli/utils/groups_test.go +++ b/src/cli/utils/groups_test.go @@ -22,8 +22,6 @@ func TestGroupsUtilsSuite(t *testing.T) { suite.Run(t, &GroupsUtilsSuite{Suite: tester.NewUnitSuite(t)}) } -// Tests selector build for Groups properly -// differentiates between the 3 categories: Pages, Libraries and Lists CLI func (suite *GroupsUtilsSuite) TestIncludeGroupsRestoreDataSelectors() { var ( empty = []string{} @@ -40,6 +38,7 @@ func (suite *GroupsUtilsSuite) TestIncludeGroupsRestoreDataSelectors() { opts utils.GroupsOpts expectIncludeLen int }{ + // resource { name: "no inputs", opts: utils.GroupsOpts{}, @@ -66,6 +65,7 @@ func (suite *GroupsUtilsSuite) TestIncludeGroupsRestoreDataSelectors() { }, expectIncludeLen: 2, }, + // sharepoint { name: "library folder contains", opts: utils.GroupsOpts{ @@ -165,6 +165,50 @@ func (suite *GroupsUtilsSuite) TestIncludeGroupsRestoreDataSelectors() { }, expectIncludeLen: 1, }, + // channels + { + name: "multiple channel multiple message", + opts: utils.GroupsOpts{ + Groups: single, + Channels: multi, + Messages: multi, + }, + expectIncludeLen: 1, + }, + { + name: "single channel multiple message", + opts: utils.GroupsOpts{ + Groups: single, + Channels: single, + Messages: multi, + }, + expectIncludeLen: 1, + }, + { + name: "single channel and message", + opts: utils.GroupsOpts{ + Groups: single, + Channels: single, + Messages: single, + }, + expectIncludeLen: 1, + }, + { + name: "multiple channel only", + opts: utils.GroupsOpts{ + Groups: single, + Channels: multi, + }, + expectIncludeLen: 1, + }, + { + name: "single channel only", + opts: utils.GroupsOpts{ + Groups: single, + Channels: single, + }, + expectIncludeLen: 1, + }, } for _, test := range table { suite.Run(test.name, func() { @@ -174,7 +218,7 @@ func (suite *GroupsUtilsSuite) TestIncludeGroupsRestoreDataSelectors() { defer flush() sel := utils.IncludeGroupsRestoreDataSelectors(ctx, test.opts) - assert.Len(suite.T(), sel.Includes, test.expectIncludeLen) + assert.Len(t, sel.Includes, test.expectIncludeLen) }) } } @@ -202,20 +246,29 @@ func (suite *GroupsUtilsSuite) TestValidateGroupsRestoreFlags() { name: "all valid", backupID: "id", opts: utils.GroupsOpts{ - FileCreatedAfter: dttm.Now(), - FileCreatedBefore: dttm.Now(), - FileModifiedAfter: dttm.Now(), - FileModifiedBefore: dttm.Now(), + FileCreatedAfter: dttm.Now(), + FileCreatedBefore: dttm.Now(), + FileModifiedAfter: dttm.Now(), + FileModifiedBefore: dttm.Now(), + MessageCreatedAfter: dttm.Now(), + MessageCreatedBefore: dttm.Now(), + MessageLastReplyAfter: dttm.Now(), + MessageLastReplyBefore: dttm.Now(), Populated: flags.PopulatedFlags{ - flags.SiteFN: struct{}{}, - flags.FileCreatedAfterFN: struct{}{}, - flags.FileCreatedBeforeFN: struct{}{}, - flags.FileModifiedAfterFN: struct{}{}, - flags.FileModifiedBeforeFN: struct{}{}, + flags.SiteFN: struct{}{}, + flags.FileCreatedAfterFN: struct{}{}, + flags.FileCreatedBeforeFN: struct{}{}, + flags.FileModifiedAfterFN: struct{}{}, + flags.FileModifiedBeforeFN: struct{}{}, + flags.MessageCreatedAfterFN: struct{}{}, + flags.MessageCreatedBeforeFN: struct{}{}, + flags.MessageLastReplyAfterFN: struct{}{}, + flags.MessageLastReplyBeforeFN: struct{}{}, }, }, expect: assert.NoError, }, + // sharepoint { name: "invalid file created after", backupID: "id", @@ -238,6 +291,17 @@ func (suite *GroupsUtilsSuite) TestValidateGroupsRestoreFlags() { }, expect: assert.Error, }, + { + name: "invalid file modified before", + backupID: "id", + opts: utils.GroupsOpts{ + FileModifiedBefore: "1235", + Populated: flags.PopulatedFlags{ + flags.FileModifiedBeforeFN: struct{}{}, + }, + }, + expect: assert.Error, + }, { name: "invalid file modified after", backupID: "id", @@ -249,13 +313,47 @@ func (suite *GroupsUtilsSuite) TestValidateGroupsRestoreFlags() { }, expect: assert.Error, }, + // channels { - name: "invalid file modified before", + name: "invalid message last reply before", backupID: "id", opts: utils.GroupsOpts{ - FileModifiedBefore: "1235", + MessageLastReplyBefore: "1235", Populated: flags.PopulatedFlags{ - flags.FileModifiedBeforeFN: struct{}{}, + flags.MessageLastReplyBeforeFN: struct{}{}, + }, + }, + expect: assert.Error, + }, + { + name: "invalid message last reply after", + backupID: "id", + opts: utils.GroupsOpts{ + MessageLastReplyAfter: "1235", + Populated: flags.PopulatedFlags{ + flags.MessageLastReplyAfterFN: struct{}{}, + }, + }, + expect: assert.Error, + }, + { + name: "invalid message created before", + backupID: "id", + opts: utils.GroupsOpts{ + MessageCreatedBefore: "1235", + Populated: flags.PopulatedFlags{ + flags.MessageCreatedBeforeFN: struct{}{}, + }, + }, + expect: assert.Error, + }, + { + name: "invalid message created after", + backupID: "id", + opts: utils.GroupsOpts{ + MessageCreatedAfter: "1235", + Populated: flags.PopulatedFlags{ + flags.MessageCreatedAfterFN: struct{}{}, }, }, expect: assert.Error, diff --git a/src/cli/utils/onedrive.go b/src/cli/utils/onedrive.go index e12f35230..dd0a8ad1e 100644 --- a/src/cli/utils/onedrive.go +++ b/src/cli/utils/onedrive.go @@ -52,19 +52,19 @@ func ValidateOneDriveRestoreFlags(backupID string, opts OneDriveOpts) error { } if _, ok := opts.Populated[flags.FileCreatedAfterFN]; ok && !IsValidTimeFormat(opts.FileCreatedAfter) { - return clues.New("invalid time format for created-after") + return clues.New("invalid time format for " + flags.FileCreatedAfterFN) } if _, ok := opts.Populated[flags.FileCreatedBeforeFN]; ok && !IsValidTimeFormat(opts.FileCreatedBefore) { - return clues.New("invalid time format for created-before") + return clues.New("invalid time format for " + flags.FileCreatedBeforeFN) } if _, ok := opts.Populated[flags.FileModifiedAfterFN]; ok && !IsValidTimeFormat(opts.FileModifiedAfter) { - return clues.New("invalid time format for modified-after") + return clues.New("invalid time format for " + flags.FileModifiedAfterFN) } if _, ok := opts.Populated[flags.FileModifiedBeforeFN]; ok && !IsValidTimeFormat(opts.FileModifiedBefore) { - return clues.New("invalid time format for modified-before") + return clues.New("invalid time format for " + flags.FileModifiedBeforeFN) } return validateRestoreConfigFlags(flags.CollisionsFV, opts.RestoreCfg)