From 9e63e610980a75578edcdb96f2aeee8f94e2c5c8 Mon Sep 17 00:00:00 2001 From: K Date: Sat, 13 Apr 2024 18:16:10 -0400 Subject: [PATCH] Only clear dirty bit after nodes are persisted --- src/gkeepapi/__init__.py | 29 ++++-- src/gkeepapi/node.py | 186 ++++++++++++++++++++++++++------------- 2 files changed, 143 insertions(+), 72 deletions(-) diff --git a/src/gkeepapi/__init__.py b/src/gkeepapi/__init__.py index 0654fe4..eb2f721 100644 --- a/src/gkeepapi/__init__.py +++ b/src/gkeepapi/__init__.py @@ -700,7 +700,9 @@ def login( Raises: LoginException: If there was a problem logging in. """ - logger.warning("'Keep.login' is deprecated. Please use 'Keep.authenticate' instead") + logger.warning( + "'Keep.login' is deprecated. Please use 'Keep.authenticate' instead" + ) auth = APIAuth(self.OAUTH_SCOPES) if device_id is None: device_id = f"{get_mac():x}" @@ -716,7 +718,9 @@ def resume( sync: bool = True, device_id: str | None = None, ) -> None: - logger.warning("'Keep.resume' has been renamed to 'Keep.authenticate'. Please update your code") + logger.warning( + "'Keep.resume' has been renamed to 'Keep.authenticate'. Please update your code" + ) self.authenticate(email, master_token, state, sync, device_id) def authenticate( @@ -787,8 +791,8 @@ def dump(self) -> dict: nodes.extend(node.children) return { "keep_version": self._keep_version, - "labels": [label.save(False) for label in self.labels()], - "nodes": [node.save(False) for node in nodes], + "labels": [label.save(False, True) for label in self.labels()], + "nodes": [node.save(False, True) for node in nodes], } def restore(self, state: dict) -> None: @@ -1018,7 +1022,7 @@ def labels(self) -> list[_node.Label]: """ return list(self._labels.values()) - def __UNSTABLE_API_uploadMedia(self, fh: IO)-> None: + def __UNSTABLE_API_uploadMedia(self, fh: IO) -> None: pass def getMediaLink(self, blob: _node.Blob) -> str: @@ -1083,15 +1087,22 @@ def _sync_notes(self) -> None: logger.debug("Starting keep sync: %s", self._keep_version) # Collect any changes and send them up to the server. - labels_updated = any(i.dirty for i in self._labels.values()) + updated_nodes = self._findDirtyNodes() + updated_labels = [label for label in self._labels.values() if label.dirty] changes = self._keep_api.changes( target_version=self._keep_version, - nodes=[i.save() for i in self._findDirtyNodes()], - labels=[i.save() for i in self._labels.values()] - if labels_updated + nodes=[i.save(False) for i in updated_nodes], + labels=[i.save(False) for i in self._labels.values()] + if updated_labels else None, ) + # Clear + for node in updated_nodes: + node.clean() + for label in updated_labels: + label.clean() + if changes.get("forceFullResync"): raise exception.ResyncRequiredException("Full resync required") diff --git a/src/gkeepapi/node.py b/src/gkeepapi/node.py index 90d3678..fbc2356 100644 --- a/src/gkeepapi/node.py +++ b/src/gkeepapi/node.py @@ -186,7 +186,7 @@ def __init__(self) -> None: self._dirty = False def _find_discrepancies(self, raw: dict | list) -> None: # pragma: no cover - s_raw = self.save(False) + s_raw = self.save(False, True) if isinstance(raw, dict): for key, val in raw.items(): if key in ["parentServerId", "lastSavedSessionId"]: @@ -249,22 +249,27 @@ def _load(self, raw: dict) -> None: """ self._dirty = raw.get("_dirty", False) - def save(self, clean: bool = True) -> dict: + def save(self, clean: bool = True, local: bool = False) -> dict: """Serialize into raw representation. Clears the dirty bit by default. Args: clean: Whether to clear the dirty bit. + local: Whether to include local metadata. Returns: Raw. """ ret = {} if clean: - self._dirty = False - else: + self.clean() + if local: ret["_dirty"] = self._dirty return ret + def clean(self) -> None: + """Mark as clean.""" + self._dirty = False + @property def dirty(self) -> bool: """Get dirty state. @@ -289,18 +294,19 @@ def _load(self, raw: dict) -> None: super()._load(raw) self.id = raw.get("id") - def save(self, clean: bool = True) -> dict: + def save(self, clean: bool = True, local: bool = False) -> dict: """Save the annotation""" ret = {} if self.id is not None: - ret = super().save(clean) + ret = super().save(clean, local) + # FIXME: Why does this check self.id again? if self.id is not None: ret["id"] = self.id return ret @classmethod def _generateAnnotationId(cls) -> str: - return "{:08x}-{:04x}-{:04x}-{:04x}-{:012x}".format( # noqa: UP032 + return "{:08x}-{:04x}-{:04x}-{:04x}-{:012x}".format( random.randint(0x00000000, 0xFFFFFFFF), # noqa: S311 random.randint(0x0000, 0xFFFF), # noqa: S311 random.randint(0x0000, 0xFFFF), # noqa: S311 @@ -331,9 +337,9 @@ def _load(self, raw: dict) -> None: self._provenance_url = raw["webLink"]["provenanceUrl"] self._description = raw["webLink"].get("description", self.description) - def save(self, clean: bool = True) -> dict: + def save(self, clean: bool = True, local: bool = False) -> dict: """Save the weblink""" - ret = super().save(clean) + ret = super().save(clean, local) ret["webLink"] = { "title": self._title, "url": self._url, @@ -428,9 +434,9 @@ def _load(self, raw: dict) -> None: super()._load(raw) self._category = CategoryValue(raw["topicCategory"]["category"]) - def save(self, clean: bool = True) -> dict: + def save(self, clean: bool = True, local: bool = False) -> dict: """Save the category annotation""" - ret = super().save(clean) + ret = super().save(clean, local) ret["topicCategory"] = {"category": self._category.value} return ret @@ -463,9 +469,9 @@ def _load(self, raw: dict) -> None: super()._load(raw) self._suggest = raw["taskAssist"]["suggestType"] - def save(self, clean: bool = True) -> dict: + def save(self, clean: bool = True, local: bool = False) -> dict: """Save the taskassist annotation""" - ret = super().save(clean) + ret = super().save(clean, local) ret["taskAssist"] = {"suggestType": self._suggest} return ret @@ -500,15 +506,22 @@ def _load(self, raw: dict) -> None: for key, entry in raw.get("context", {}).items(): self._entries[key] = NodeAnnotations.from_json({key: entry}) - def save(self, clean: bool = True) -> dict: + def save(self, clean: bool = True, local: bool = False) -> dict: """Save the context annotation""" - ret = super().save(clean) + ret = super().save(clean, local) context = {} for entry in self._entries.values(): - context.update(entry.save(clean)) + context.update(entry.save(clean, local)) ret["context"] = context return ret + def clean(self) -> None: + """Mark as clean.""" + super().clean() + + for entry in self._entries.values(): + entry.clean() + def all(self) -> list[Annotation]: """Get all sub annotations. @@ -583,16 +596,24 @@ def _load(self, raw: dict) -> None: annotation = self.from_json(raw_annotation) self._annotations[annotation.id] = annotation - def save(self, clean: bool = True) -> dict: + def save(self, clean: bool = True, local: bool = False) -> dict: """Save the annotations container""" - ret = super().save(clean) + ret = super().save(clean, local) ret["kind"] = "notes#annotationsGroup" if self._annotations: ret["annotations"] = [ - annotation.save(clean) for annotation in self._annotations.values() + annotation.save(clean, local) + for annotation in self._annotations.values() ] return ret + def clean(self) -> None: + """Mark as clean.""" + super().clean() + + for annotation in self._annotations.values(): + annotation.clean() + def _get_category_node(self) -> Category | None: for annotation in self._annotations.values(): if isinstance(annotation, Category): @@ -697,9 +718,9 @@ def _load(self, raw: dict) -> None: self.str_to_dt(raw["userEdited"]) if "userEdited" in raw else None ) - def save(self, clean: bool = True) -> dict: + def save(self, clean: bool = True, local: bool = False) -> dict: """Save the timestamps container""" - ret = super().save(clean) + ret = super().save(clean, local) ret["kind"] = "notes#timestamps" ret["created"] = self.dt_to_str(self._created) if self._deleted is not None: @@ -858,9 +879,9 @@ def _load(self, raw: dict) -> None: raw["checkedListItemsPolicy"] ) - def save(self, clean: bool = True) -> dict: + def save(self, clean: bool = True, local: bool = False) -> dict: """Save the settings container""" - ret = super().save(clean) + ret = super().save(clean, local) ret["newListItemPlacement"] = self._new_listitem_placement.value ret["graveyardState"] = self._graveyard_state.value ret["checkedListItemsPolicy"] = self._checked_listitems_policy.value @@ -927,7 +948,7 @@ def load(self, collaborators_raw: list, requests_raw: list) -> None: # noqa: D1 if requests_raw and isinstance(requests_raw[-1], bool): self._dirty = requests_raw.pop() else: - self._dirty = False + self.clean() self._collaborators = {} for collaborator in collaborators_raw: self._collaborators[collaborator["email"]] = RoleValue(collaborator["role"]) @@ -936,7 +957,7 @@ def load(self, collaborators_raw: list, requests_raw: list) -> None: # noqa: D1 collaborator["type"] ) - def save(self, clean: bool = True) -> tuple[list, list]: + def save(self, clean: bool = True, local: bool = False) -> tuple[list, list]: """Save the collaborators container""" # Parent method not called. collaborators = [] @@ -948,10 +969,10 @@ def save(self, clean: bool = True) -> tuple[list, list]: collaborators.append( {"email": email, "role": action.value, "auxiliary_type": "None"} ) - if not clean: + if clean: + self.clean() + if local: requests.append(self._dirty) - else: - self._dirty = False return (collaborators, requests) def add(self, email: str) -> None: @@ -1087,15 +1108,20 @@ def _load(self, raw: dict) -> None: self.timestamps.load(raw["timestamps"]) self._merged = NodeTimestamps.str_to_dt(raw.get("lastMerged")) - def save(self, clean: bool = True) -> dict: + def save(self, clean: bool = True, local: bool = False) -> dict: """Save the label""" - ret = super().save(clean) + ret = super().save(clean, local) ret["mainId"] = self.id ret["name"] = self._name - ret["timestamps"] = self.timestamps.save(clean) + ret["timestamps"] = self.timestamps.save(clean, local) ret["lastMerged"] = NodeTimestamps.dt_to_str(self._merged) return ret + def clean(self) -> None: + """Mark as clean.""" + super().clean() + self.timestamps.clean() + @property def name(self) -> str: """Get the label name. @@ -1150,12 +1176,15 @@ def _load(self, raw: list) -> None: if raw and isinstance(raw[-1], bool): self._dirty = raw.pop() else: - self._dirty = False + self.clean() self._labels = {} for raw_label in raw: self._labels[raw_label["labelId"]] = None - def save(self, clean: bool = True) -> tuple[dict] | tuple[dict, bool]: # noqa: D102 + def save( + self, clean: bool = True, local: bool = False + ) -> tuple[dict] | tuple[dict, bool]: + """Mark as clean.""" # Parent method not called. ret = [ { @@ -1168,10 +1197,10 @@ def save(self, clean: bool = True) -> tuple[dict] | tuple[dict, bool]: # noqa: } for label_id, label in self._labels.items() ] - if not clean: + if clean: + self.clean() + if local: ret.append(self._dirty) - else: - self._dirty = False return ret def add(self, label: Label) -> None: @@ -1258,7 +1287,7 @@ def __init__( @classmethod def _generateId(cls, tz: float) -> str: - return "{:x}.{:016x}".format( # noqa: UP032 + return "{:x}.{:016x}".format( int(tz * 1000), random.randint(0x0000000000000000, 0xFFFFFFFFFFFFFFFF), # noqa: S311 ) @@ -1283,8 +1312,8 @@ def _load(self, raw: dict) -> None: self.settings.load(raw["nodeSettings"]) self.annotations.load(raw["annotationsGroup"]) - def save(self, clean: bool = True) -> dict: # noqa: D102 - ret = super().save(clean) + def save(self, clean: bool = True, local: bool = False) -> dict: # noqa: D102 + ret = super().save(clean, local) ret["id"] = self.id ret["kind"] = "notes#node" ret["type"] = self.type.value @@ -1295,11 +1324,18 @@ def save(self, clean: bool = True) -> dict: # noqa: D102 ret["text"] = self._text if self.server_id is not None: ret["serverId"] = self.server_id - ret["timestamps"] = self.timestamps.save(clean) - ret["nodeSettings"] = self.settings.save(clean) - ret["annotationsGroup"] = self.annotations.save(clean) + ret["timestamps"] = self.timestamps.save(clean, local) + ret["nodeSettings"] = self.settings.save(clean, local) + ret["annotationsGroup"] = self.annotations.save(clean, local) return ret + def clean(self) -> None: + """Mark as clean.""" + super().clean() + self.timestamps.clean() + self.settings.clean() + self.annotations.clean() + @property def sort(self) -> int: """Get the sort id. @@ -1457,15 +1493,15 @@ def _load(self, raw: dict) -> None: ) self._moved = "moved" in raw - def save(self, clean: bool = True) -> dict: # noqa: D102 - ret = super().save(clean) + def save(self, clean: bool = True, local: bool = False) -> dict: # noqa: D102 + ret = super().save(clean, local) ret["color"] = self._color.value ret["isArchived"] = self._archived ret["isPinned"] = self._pinned ret["title"] = self._title - labels = self.labels.save(clean) + labels = self.labels.save(clean, local) - collaborators, requests = self.collaborators.save(clean) + collaborators, requests = self.collaborators.save(clean, local) if labels: ret["labelIds"] = labels ret["collaborators"] = collaborators @@ -1473,6 +1509,12 @@ def save(self, clean: bool = True) -> dict: # noqa: D102 ret["shareRequests"] = requests return ret + def clean(self) -> None: + """Mark as clean.""" + super().clean() + self.labels.clean() + self.collaborators.clean() + @property def color(self) -> ColorValue: """Get the node color. @@ -1605,8 +1647,8 @@ def _load(self, raw: dict) -> None: self.super_list_item_id = raw.get("superListItemId") or None self._checked = raw.get("checked", False) - def save(self, clean: bool = True) -> dict: # noqa: D102 - ret = super().save(clean) + def save(self, clean: bool = True, local: bool = False) -> dict: # noqa: D102 + ret = super().save(clean, local) ret["parentServerId"] = self.parent_server_id ret["superListItemId"] = self.super_list_item_id ret["checked"] = self._checked @@ -1922,9 +1964,9 @@ def _load(self, raw: dict) -> None: self._media_id = raw.get("media_id") self._mimetype = raw.get("mimetype") - def save(self, clean: bool = True) -> dict: + def save(self, clean: bool = True, local: bool = False) -> dict: """Save the node blob""" - ret = super().save(clean) + ret = super().save(clean, local) ret["kind"] = "notes#blob" ret["type"] = self.type.value if self.blob_id is not None: @@ -1951,9 +1993,9 @@ def _load(self, raw: dict) -> None: super()._load(raw) self._length = raw.get("length") - def save(self, clean: bool = True) -> dict: + def save(self, clean: bool = True, local: bool = False) -> dict: """Save the node audio blob""" - ret = super().save(clean) + ret = super().save(clean, local) if self._length is not None: ret["length"] = self._length return ret @@ -2001,9 +2043,9 @@ def _load(self, raw: dict) -> None: self._extracted_text = raw.get("extracted_text") self._extraction_status = raw.get("extraction_status") - def save(self, clean: bool = True) -> dict: + def save(self, clean: bool = True, local: bool = False) -> dict: """Save the node image blob""" - ret = super().save(clean) + ret = super().save(clean, local) ret["width"] = self._width ret["height"] = self._height ret["byte_size"] = self._byte_size @@ -2081,15 +2123,21 @@ def _load(self, raw: dict) -> None: drawing_info.load(raw["drawingInfo"]) self._drawing_info = drawing_info - def save(self, clean: bool = True) -> dict: + def save(self, clean: bool = True, local: bool = False) -> dict: """Save the node drawing blob""" - ret = super().save(clean) + ret = super().save(clean, local) ret["extracted_text"] = self._extracted_text ret["extraction_status"] = self._extraction_status if self._drawing_info is not None: - ret["drawingInfo"] = self._drawing_info.save(clean) + ret["drawingInfo"] = self._drawing_info.save(clean, local) return ret + def clean(self) -> None: + """Mark as clean.""" + super().clean() + if self._drawing_info is not None: + self._drawing_info.clean() + @property def extracted_text(self) -> str: """Get text extracted from image @@ -2141,10 +2189,10 @@ def _load(self, raw: dict) -> None: "snapshotProtoFprint", self._snapshot_proto_fprint ) - def save(self, clean: bool = True) -> dict: # noqa: D102 - ret = super().save(clean) + def save(self, clean: bool = True, local: bool = False) -> dict: # noqa: D102 + ret = super().save(clean, local) ret["drawingId"] = self.drawing_id - ret["snapshotData"] = self.snapshot.save(clean) + ret["snapshotData"] = self.snapshot.save(clean, local) ret["snapshotFingerprint"] = self._snapshot_fingerprint ret["thumbnailGeneratedTime"] = NodeTimestamps.dt_to_str( self._thumbnail_generated_time @@ -2153,6 +2201,11 @@ def save(self, clean: bool = True) -> dict: # noqa: D102 ret["snapshotProtoFprint"] = self._snapshot_proto_fprint return ret + def clean(self) -> None: + """Mark as clean.""" + super().clean() + self.snapshot.clean() + class Blob(Node): """Represents a Google Keep blob.""" @@ -2204,13 +2257,20 @@ def _load(self, raw: dict) -> None: super()._load(raw) self.blob = self.from_json(raw.get("blob")) - def save(self, clean: bool = True) -> dict: + def save(self, clean: bool = True, local: bool = False) -> dict: """Save the blob""" - ret = super().save(clean) + ret = super().save(clean, local) if self.blob is not None: - ret["blob"] = self.blob.save(clean) + ret["blob"] = self.blob.save(clean, local) return ret + def clean(self) -> None: + """Mark as clean.""" + super().clean() + + if self.blob is not None: + self.blob.clean() + _type_map = { NodeType.Note: Note,