Skip to content

Commit

Permalink
allow adding / removing multiple excluded items at once
Browse files Browse the repository at this point in the history
  • Loading branch information
samschott committed Aug 27, 2023
1 parent cb82d69 commit af46dd8
Show file tree
Hide file tree
Showing 5 changed files with 127 additions and 133 deletions.
27 changes: 15 additions & 12 deletions src/maestral/cli/cli_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,38 +68,41 @@ def excluded_list(m: Maestral) -> None:

@excluded.command(
name="add",
help="Add a file or folder to the excluded list and re-sync.",
help="Add files or folders to the excluded list and re-sync.",
)
@click.argument("dropbox_path", type=DropboxPath())
@click.argument("dropbox_path", type=DropboxPath(), nargs=-1)
@inject_proxy(fallback=True, existing_config=True)
@convert_api_errors
def excluded_add(m: Maestral, dropbox_path: str) -> None:
if dropbox_path == "/":
def excluded_add(m: Maestral, dropbox_paths: list[str]) -> None:
if any(p == "/" for p in dropbox_paths):
raise CliException("Cannot exclude the root directory.")

m.exclude_item(dropbox_path)
ok(f"Excluded '{dropbox_path}'.")
m.exclude_items(*dropbox_paths)
for path in dropbox_paths:
ok(f"Excluded '{path}'")


@excluded.command(
name="remove",
help="""
Remove a file or folder from the excluded list and re-sync.
Remove files or folders from the excluded list and re-sync.
It is safe to call this method with items which have already been included, they will
not be downloaded again. If the given path lies inside an excluded folder, the parent
folder will be included as well (but no other items inside it).
""",
)
@click.argument("dropbox_path", type=DropboxPath())
@click.argument("dropbox_path", type=DropboxPath(), nargs=-1)
@inject_proxy(fallback=False, existing_config=True)
@convert_api_errors
def excluded_remove(m: Maestral, dropbox_path: str) -> None:
if dropbox_path == "/":
def excluded_remove(m: Maestral, dropbox_paths: str) -> None:
if any(p == "/" for p in dropbox_paths):
return echo("The root directory is always included")

m.include_item(dropbox_path)
ok(f"Included '{dropbox_path}'. Now downloading...")
m.include_items(*dropbox_paths)
for path in dropbox_paths:
ok(f"Included '{path}'")
ok("Downloading...")


@click.group(help="Manage desktop notifications.")
Expand Down
131 changes: 58 additions & 73 deletions src/maestral/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
LinkAccessLevel,
UpdateCheckResult,
)
from .sync import SyncDirection, SyncEngine
from .sync import SyncDirection, SyncEngine, pf_repr
from .manager import SyncManager
from .models import SyncEvent, SyncErrorEntry, SyncStatus
from .notify import MaestralDesktopNotifier
Expand Down Expand Up @@ -396,10 +396,15 @@ def dropbox_path(self) -> str:
@property
def excluded_items(self) -> set[str]:
"""
The list of files and folders excluded by selective sync. Any changes to this
list will be applied immediately if we have already performed the initial sync.
I.e., paths which have been added to the list will be deleted from the local
drive and paths which have been removed will be downloaded.
The list of files and folders excluded by selective sync.
Any changes to this list will be applied immediately if we have already
performed the initial sync. Paths which have been added to the list will be
deleted from the local drive and paths which have been removed will be
downloaded.
If the initial was not yet performed, changes will only be saved for the initial
sync.
Use :meth:`exclude_item` and :meth:`include_item` to add or remove individual
items from selective sync.
Expand Down Expand Up @@ -1075,19 +1080,19 @@ def reset_sync_state(self) -> None:
"""
Resets the sync index and state. Only call this to clean up leftover state
information if a Dropbox was improperly unlinked (e.g., auth token has been
manually deleted). Otherwise leave state management to Maestral.
manually deleted). Otherwise, leave state management to Maestral.
:raises NotLinkedError: if no Dropbox account is linked.
"""
self._check_linked()
self.manager.reset_sync_state()

def exclude_item(self, dbx_path: str) -> None:
def exclude_items(self, *dbx_paths: str) -> None:
"""
Excludes file or folder from sync and deletes it locally. It is safe to call
Excludes files or folders from sync and deletes it locally. It is safe to call
this method with items which have already been excluded.
:param dbx_path: Dropbox path of item to exclude.
:param dbx_paths: Dropbox paths of items to exclude.
:raises NotFoundError: if there is nothing at the given path.
:raises ConnectionError: if the connection to Dropbox fails.
:raises DropboxAuthError: in case of an invalid access token.
Expand All @@ -1099,38 +1104,34 @@ def exclude_item(self, dbx_path: str) -> None:
self._check_linked()
self._check_dropbox_dir()

dbx_path_lower = normalize(dbx_path.rstrip("/"))
dbx_paths_lower = {normalize(p.rstrip("/")) for p in dbx_paths}

# ---- input validation --------------------------------------------------------
# ---- input cleanup -----------------------------------------------------------
excluded_items_old = self.sync.excluded_items
excluded_items_new = self.sync.clean_excluded_items_list(
excluded_items_old | dbx_paths_lower
)

md = self.client.get_metadata(dbx_path_lower)
excluded_items_added = excluded_items_new - excluded_items_old

if not md:
raise NotFoundError(
"Cannot exclude item", f'"{dbx_path_lower}" does not exist on Dropbox'
)

if self.sync.is_excluded_by_user(dbx_path_lower):
if len(excluded_items_added) == 0:
return

# ---- apply changes -----------------------------------------------------------
if self.sync.sync_lock.acquire(blocking=False):
try:
# ---- update excluded items list --------------------------------------
excluded_items = self.sync.excluded_items
excluded_items.add(dbx_path_lower)

self.sync.excluded_items = excluded_items
self.sync.excluded_items = excluded_items_new

# ---- remove item from local Dropbox ----------------------------------
self._remove_after_excluded(dbx_path_lower)
for dbx_path_lower in excluded_items_added:
self._remove_after_excluded(dbx_path_lower)
self._logger.info("Excluded %s", dbx_path_lower)

self._logger.info("Excluded %s", dbx_path_lower)
self._logger.info(IDLE)
finally:
self.sync.sync_lock.release()

else:
raise BusyError("Cannot exclude item", "Please try again when idle.")
raise BusyError("Cannot add excluded items", "Please try again when idle.")

def _remove_after_excluded(self, dbx_path_lower: str) -> None:
# Perform housekeeping.
Expand All @@ -1149,9 +1150,9 @@ def _remove_after_excluded(self, dbx_path_lower: str) -> None:
with self.manager.sync.fs_events.ignore(event_cls(local_path)):
delete(local_path)

def include_item(self, dbx_path: str) -> None:
def include_items(self, *dbx_paths: str) -> None:
"""
Includes a file or folder in sync and downloads it in the background. It is safe
Includes files or folders in sync and downloads it in the background. It is safe
to call this method with items which have already been included, they will not
be downloaded again.
Expand All @@ -1163,7 +1164,7 @@ def include_item(self, dbx_path: str) -> None:
Any downloads will be carried out by the sync threads. Errors during the
download can be accessed through :attr:`sync_errors` or :attr:`maestral_errors`.
:param dbx_path: Dropbox path of item to include.
:param dbx_paths: Dropbox paths of items to include.
:raises NotFoundError: if there is nothing at the given path.
:raises DropboxAuthError: in case of an invalid access token.
:raises DropboxServerError: for internal Dropbox errors.
Expand All @@ -1174,56 +1175,40 @@ def include_item(self, dbx_path: str) -> None:
self._check_linked()
self._check_dropbox_dir()

dbx_path_lower = normalize(dbx_path.rstrip("/"))
dbx_paths_lower = {normalize(p.rstrip("/")) for p in dbx_paths}

# ---- input validation --------------------------------------------------------
md = self.client.get_metadata(dbx_path_lower)

if not md:
raise NotFoundError(
"Cannot include item",
f"'{dbx_path_lower}' does not exist on Dropbox",
)

if not self.sync.is_excluded_by_user(dbx_path_lower):
return

# ---- update excluded items list ----------------------------------------------
excluded_items = self.sync.excluded_items
newly_included_items = {p for p in dbx_paths_lower if self.sync.is_excluded_by_user(p)}

# Remove dbx_path from list.
excluded_items.discard(dbx_path_lower)

excluded_parent: str | None = None

for folder in excluded_items.copy():
# Include all parents which are required to download dbx_path.
if is_child(dbx_path_lower, folder):
# Remove parent folders from excluded list.
excluded_items.remove(folder)
# Re-add their children (except parents of dbx_path).
for res in self.client.list_folder_iterator(folder):
for entry in res.entries:
if not is_equal_or_child(dbx_path_lower, entry.path_lower):
excluded_items.add(entry.path_lower)

excluded_parent = folder

# Include all children of dbx_path.
if is_child(folder, dbx_path_lower):
excluded_items.remove(folder)

# ---- update excluded items list ----------------------------------------------
# Find parent folders that should also be newly included.
for dbx_path_lower in newly_included_items.copy():
for folder in excluded_items.copy():
# Include all parents which are required to download dbx_path_lower.
if is_child(dbx_path_lower, folder):
# Remove parent folder from excluded list.
excluded_items.discard(folder)
# Re-add their children (except parents of dbx_path_lower).
for res in self.client.list_folder_iterator(folder):
for entry in res.entries:
if not is_equal_or_child(dbx_path_lower, entry.path_lower):
excluded_items.add(entry.path_lower)

# Add the parent to the download list.
newly_included_items.add(folder)

# Include all children of dbx_path_lower.
if is_child(folder, dbx_path_lower):
excluded_items.discard(folder)

# ---- download items from Dropbox ---------------------------------------------
if self.sync.sync_lock.acquire(blocking=False):
self._logger.debug("Excluded items old: %s", pf_repr(self.excluded_items))
self._logger.debug("Excluded items new: %s", pf_repr(excluded_items))
try:
self.sync.excluded_items = excluded_items

# ---- download item from Dropbox --------------------------------------
if excluded_parent:
self._logger.info(
"Included '%s' and parent directories", dbx_path_lower
)
self.manager.download_queue.put(excluded_parent)
else:
for dbx_path_lower in newly_included_items:
self._logger.info("Included '%s'", dbx_path_lower)
self.manager.download_queue.put(dbx_path_lower)
finally:
Expand Down
3 changes: 2 additions & 1 deletion src/maestral/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@
"SyncEngine",
"ActivityNode",
"ActivityTree",
"pf_repr",
]

umask = os.umask(0o22)
Expand Down Expand Up @@ -687,7 +688,7 @@ def excluded_items(self) -> set[str]:
removed from the list. If only children are given but not the parent folder, any
new items added to the parent will be synced. Change this property *before*
downloading newly included items or deleting excluded items."""
return self._excluded_items
return self._excluded_items.copy()

@excluded_items.setter
def excluded_items(self, dbx_paths: Collection[str]) -> None:
Expand Down
Loading

0 comments on commit af46dd8

Please sign in to comment.