Skip to content

Commit 23d404f

Browse files
committed
Added support to skip shared folders and it's records from delete-all execution.
1 parent 977f0f9 commit 23d404f

File tree

1 file changed

+95
-29
lines changed

1 file changed

+95
-29
lines changed

keepercommander/commands/utils.py

Lines changed: 95 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252

5353
BREACHWATCH_MAX = 5
5454
KEEPER_API_BATCH_LIMIT = 999 # Maximum objects per pre_delete API request
55+
KEEPER_API_FOLDER_BATCH_LIMIT = 500 # Maximum folders per pre_delete API request
5556

5657
is_windows = sys.platform.startswith('win')
5758

@@ -634,7 +635,14 @@ def print_device_info(params: KeeperParams):
634635

635636
class RecordDeleteAllCommand(Command):
636637
"""
637-
Delete all records and folders from the vault.
638+
Delete all records and folders from the user vault.
639+
640+
IMPORTANT: This command automatically skips shared folders and their records.
641+
For shared folders, use the recommended workflow:
642+
1. 'transform-folder <folder_name>' to move records to user folders (fast)
643+
2. Run delete-all to clean remaining user vault content
644+
645+
The command will alert you about any skipped shared folder content.
638646
639647
Args:
640648
params: KeeperParams instance
@@ -643,9 +651,10 @@ class RecordDeleteAllCommand(Command):
643651
Returns:
644652
None
645653
646-
Note:
647-
Records in multiple folders will be processed using the first
648-
folder found. Deletions are processed in batches of 999 items.
654+
Note:
655+
Only processes user folders and records. Uses batches of up to 999 items per
656+
API call. Records in multiple folders will be processed using the first user
657+
folder found.
649658
"""
650659

651660
def get_parser(self):
@@ -699,15 +708,68 @@ def _confirm_user_wants_deletion(self, kwargs):
699708
return True
700709

701710
def _process_record_deletion(self, params):
702-
"""Collect and delete all records."""
703-
records_with_folders = self._collect_records_with_folders(params)
711+
"""Collect and delete all records, avoiding shared folder records."""
712+
records_with_folders, skipped_stats = self._collect_records_with_folders_safe(params)
704713
if not records_with_folders:
705714
logging.info('No records found to delete')
706715
return DeletionStats()
707716

708-
logging.info('Preparing to delete %s records from Keeper', len(records_with_folders))
717+
if skipped_stats['shared_folders'] > 0 or skipped_stats['shared_records'] > 0:
718+
print(f"\nSHARED FOLDER CONTENT SKIPPED:")
719+
print(f" • {skipped_stats['shared_folders']} shared folders avoided")
720+
print(f" • {skipped_stats['shared_records']} records in shared folders avoided")
721+
print(f"\nFor shared folders with many records, use this workflow:")
722+
print(f" 1. Run 'transform-folder <shared_folder_uid>' to convert shared folder to user folder (fast)")
723+
print(f" 2. Then run delete-all to clean remaining user vault content\n")
724+
725+
logging.info('Preparing to delete %s records from user folders', len(records_with_folders))
709726
return self._delete_objects_in_batches(params, records_with_folders, 'records')
710727

728+
def _collect_records_with_folders_safe(self, params):
729+
"""Collect records from user folders only, avoiding shared folders."""
730+
from ..subfolder import BaseFolderNode
731+
732+
safe_records = []
733+
skipped_stats = {
734+
'shared_folders': 0,
735+
'shared_records': 0
736+
}
737+
738+
shared_folder_uids = set()
739+
shared_record_uids = set()
740+
741+
for folder_uid, folder in params.folder_cache.items():
742+
if folder.type in (BaseFolderNode.SharedFolderType, BaseFolderNode.SharedFolderFolderType):
743+
shared_folder_uids.add(folder_uid)
744+
skipped_stats['shared_folders'] += 1
745+
746+
folder_records = params.subfolder_record_cache.get(folder_uid, set())
747+
shared_record_uids.update(folder_records)
748+
749+
skipped_stats['shared_records'] = len(shared_record_uids)
750+
751+
for record_uid in params.record_cache:
752+
if record_uid in shared_record_uids:
753+
continue
754+
755+
record_folder = None
756+
for folder_uid in params.subfolder_record_cache:
757+
if record_uid in params.subfolder_record_cache.get(folder_uid, set()):
758+
folder = params.folder_cache.get(folder_uid)
759+
if folder and folder.type == BaseFolderNode.UserFolderType:
760+
record_folder = folder
761+
break
762+
763+
if record_folder is None:
764+
root_records = params.subfolder_record_cache.get('', set())
765+
if record_uid in root_records:
766+
record_folder = params.root_folder
767+
768+
if record_folder is not None:
769+
safe_records.append((record_folder, record_uid))
770+
771+
return safe_records, skipped_stats
772+
711773
def _collect_records_with_folders(self, params):
712774
"""Collect all records with their folder contexts."""
713775
from ..subfolder import find_all_folders
@@ -738,7 +800,7 @@ def _collect_records_with_folders(self, params):
738800
return records_with_folders
739801

740802
def _process_folder_deletion(self, params):
741-
"""Delete all empty folders."""
803+
"""Delete all empty user folders, avoiding shared folders."""
742804
return self._delete_empty_folders(params)
743805

744806
def _delete_objects_in_batches(self, params, objects_with_context, object_type_name):
@@ -863,7 +925,7 @@ def _finalize_deletion(self, params, record_stats, folder_stats):
863925

864926
def _delete_empty_folders(self, params):
865927
"""
866-
Delete all empty folders after records have been deleted.
928+
Delete all empty user folders after records have been deleted.
867929
868930
Args:
869931
params: KeeperParams instance
@@ -872,9 +934,8 @@ def _delete_empty_folders(self, params):
872934
int: Number of folders successfully deleted
873935
874936
Note:
875-
Only deletes folders that are completely empty (no records and no subfolders).
876-
Root folder is never deleted. Folders are sorted by depth (deepest first)
877-
to avoid parent-child dependency issues.
937+
Only deletes user folders that are completely empty.
938+
Skips shared folders entirely. Root folder is never deleted.
878939
"""
879940
from ..subfolder import BaseFolderNode
880941

@@ -886,41 +947,46 @@ def _delete_empty_folders(self, params):
886947
# Sync down to get latest folder state after record deletion
887948
api.sync_down(params)
888949

889-
# Find all folders that are safe to delete
890-
empty_folders = []
950+
empty_user_folders = []
951+
skipped_shared_folders = 0
952+
891953
for folder_uid, folder in params.folder_cache.items():
892954
# Security check: Skip if folder or folder_uid is invalid
893955
if not folder_uid or not folder or not hasattr(folder, 'type'):
894956
logging.warning("Skipping invalid folder entry")
895957
continue
896958

897-
# Security check: Do not attempt to delete root folder
898-
# Delete all other folder types (user folders, shared folders, shared folder subfolders)
899959
if folder.type == BaseFolderNode.RootFolderType:
900960
continue
901961

902-
# Check if folder is empty (no records)
903-
records_in_folder = params.subfolder_record_cache.get(folder_uid, set())
904-
if len(records_in_folder) == 0:
905-
# Also check if it has any subfolders - only delete if completely empty
906-
if not folder.subfolders or len(folder.subfolders) == 0:
907-
empty_folders.append(folder)
962+
if folder.type in (BaseFolderNode.SharedFolderType, BaseFolderNode.SharedFolderFolderType):
963+
skipped_shared_folders += 1
964+
continue
965+
966+
if folder.type == BaseFolderNode.UserFolderType:
967+
records_in_folder = params.subfolder_record_cache.get(folder_uid, set())
968+
if len(records_in_folder) == 0:
969+
if not folder.subfolders or len(folder.subfolders) == 0:
970+
empty_user_folders.append(folder)
971+
972+
if skipped_shared_folders > 0:
973+
logging.info(f"Skipped {skipped_shared_folders} shared folders (use transform-folder workflow instead)")
908974

909-
if not empty_folders:
910-
logging.info("No folders found to delete")
975+
if not empty_user_folders:
976+
logging.info("No empty user folders found to delete")
911977
return 0
912978

913-
logging.info('Found %s folders to delete', len(empty_folders))
979+
logging.info('Found %s empty user folders to delete', len(empty_user_folders))
914980

915981
# Sort folders by depth (deepest first) to avoid parent-child dependency issues
916982
# Folders with longer paths are deeper in the hierarchy
917983
from ..subfolder import get_folder_path
918-
empty_folders.sort(key=lambda f: len(get_folder_path(params, f.uid).split('/')), reverse=True)
984+
empty_user_folders.sort(key=lambda f: len(get_folder_path(params, f.uid).split('/')), reverse=True)
919985

920986
total_folders_deleted = 0
921987
total_folders_failed = 0
922988

923-
folders_to_delete = empty_folders[:]
989+
folders_to_delete = empty_user_folders[:]
924990
while len(folders_to_delete) > 0:
925991
# Prepare pre_delete request for folders
926992
rq = {
@@ -929,8 +995,8 @@ def _delete_empty_folders(self, params):
929995
}
930996

931997
# Process chunk of folders
932-
chunk = folders_to_delete[:KEEPER_API_BATCH_LIMIT]
933-
folders_to_delete = folders_to_delete[KEEPER_API_BATCH_LIMIT:]
998+
chunk = folders_to_delete[:KEEPER_API_FOLDER_BATCH_LIMIT]
999+
folders_to_delete = folders_to_delete[KEEPER_API_FOLDER_BATCH_LIMIT:]
9341000

9351001
for folder in chunk:
9361002
# Security check: Validate folder has required attributes

0 commit comments

Comments
 (0)