Repository: kiwiz/gkeepapi (language: python)

Issue:
deprecate py2: replace pylint with ruff, auto-improving lots of code
Since the repo is being modernized, I figured I would direct some attention to [ruff](https://beta.ruff.rs/docs/), a single tool that replaces a huge portion of the python ecosystem's linting and auto-upgrade tools, while running 10-100x faster than most of them due to it being written in Rust.

I took the liberty of replacing `pylint` with ruff's equivalent rule `PL`, and enabled a bunch of optional rules for upgrading to modern python syntaxes (namely `isort`, `pyupgrade`, `flake8-comprehensions`, `flake8-pie`, `flake8-simplify`, `pylint`, and `ruff`'s own ruleset). Their [list of rules](https://beta.ruff.rs/docs/rules/) keeps growing, and you can enable more of them in `pyproject.toml`.

They don't (yet) reimplement `black`, so I disabled ruff's line-length complaint and let black handle it in the `Makefile` lint script.

Besides the 33 auto-fixed rule violations, there are 6 `PL` suggestions that probably don't need attention, but the line containing `F811 Redefinition` looks very important.

```bash
(gkeepapi) ~/gkeepapi>$ make
ruff --fix src
src/gkeepapi/__init__.py:234:33: PLR2004 Magic value used in comparison, consider replacing 401 with a constant variable
src/gkeepapi/__init__.py:621:9: F811 Redefinition of unused `update` from line 466
src/gkeepapi/__init__.py:676:9: PLR0913 Too many arguments to function call (6 > 5)
src/gkeepapi/__init__.py:703:9: PLR0913 Too many arguments to function call (6 > 5)
src/gkeepapi/__init__.py:815:9: PLR0913 Too many arguments to function call (8 > 5)
src/gkeepapi/__init__.py:1096:9: PLR0912 Too many branches (17 > 12)
Found 6 errors.
make: [Makefile:4: lint] Error 1 (ignored)
black src
All done! ✨ 🍰 ✨
3 files left unchanged.
```


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
new file mode 100644
index 0000000..da3dfb6
--- /dev/null
+++ b/.github/workflows/lint.yml
@@ -0,0 +1,29 @@
+on:
+  push:
+    branches-ignore:
+    - main
+jobs:
+  build:
+    name: Lint & Test
+    runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        python-version: ["3.10"]
+    steps:
+    - uses: actions/checkout@v3
+    - name: Set up Python ${{ matrix.python-version }}
+      uses: actions/setup-python@v3
+      with:
+        python-version: ${{ matrix.python-version }}
+    - name: Install tools
+      run: |
+        python -m pip install . '.[dev]'
+    - name: Lint with ruff
+      run: |
+        ruff src
+    - name: Check format with black
+      run: |
+        black --check src
+    - name: Run tests
+      run: |
+        python -m unittest discover
diff --git a/.github/workflows/semgrep.yml b/.github/workflows/semgrep.yml
index 7680497..f60188d 100644
--- a/.github/workflows/semgrep.yml
+++ b/.github/workflows/semgrep.yml
@@ -1,16 +1,14 @@
 on:
-  pull_request: {}
   push:
-    branches:
+    branches-ignore:
     - main
-    - master
 name: Semgrep
 jobs:
   semgrep:
     name: Scan
     runs-on: ubuntu-latest
     steps:
-    - uses: actions/checkout@v2
+    - uses: actions/checkout@v3
     - uses: returntocorp/semgrep-action@v1
       with:
         auditOn: push
diff --git a/.gitignore b/.gitignore
index 2809739..ae6550e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,4 @@ dist/
 *.pyc
 *.egg-info/
 .coverage
+.ruff_cache/
diff --git a/.pylintrc b/.pylintrc
deleted file mode 100644
index 6d8ab85..0000000
--- a/.pylintrc
+++ /dev/null
@@ -1,4 +0,0 @@
-[BASIC]
-good-names=logger
-[MESSAGES CONTROL]
-disable=line-too-long,bad-continuation,too-many-instance-attributes,invalid-name,too-many-lines,useless-object-inheritance
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 591d363..0000000
--- a/.travis.yml
+++ /dev/null
@@ -1,17 +0,0 @@
-language: python
-python:
-- "2.7"
-- "3.5"
-- "3.6"
-install:
-- pip install -r requirements.txt
-- pip install coverage
-before_script:
-- curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter
-- chmod +x ./cc-test-reporter
-- ./cc-test-reporter before-build
-script:
-- coverage run --source gkeepapi -m unittest discover
-after_script:
-- coverage xml
-- ./cc-test-reporter after-build --exit-code $TRAVIS_TEST_RESULT
diff --git a/Makefile b/Makefile
index 8ee9d2c..bbcb711 100644
--- a/Makefile
+++ b/Makefile
@@ -1,23 +1,24 @@
 .PHONY: lint test coverage build clean upload all
 
 lint:
-	pylint gkeepapi
+	-ruff --fix src
+	black src
 
 test:
 	python3 -m unittest discover
 
 coverage:
-	coverage run --source gkeepapi -m unittest discover
+	coverage run --source src -m unittest discover
 	coverage report
 	coverage html
 
-build: gkeepapi/*.py
-	python3 setup.py bdist_wheel --universal
+build: src/gkeepapi/*.py
+	python3 -m build
 
 clean:
-	rm -f dist/*.whl
+	rm -rf build dist
 
 upload:
 	twine upload dist/*.whl
 
-all: build upload
+all: lint test build upload
diff --git a/docs/Makefile b/docs/Makefile
index 5fe8f0f..41d63e4 100644
--- a/docs/Makefile
+++ b/docs/Makefile
@@ -3,18 +3,18 @@
 
 # You can set these variables from the command line.
 SPHINXOPTS    =
-SPHINXBUILD   = python -msphinx
+SPHINXBUILD   = python3 -msphinx
 SPHINXPROJ    = gkeepapi
 SOURCEDIR     = .
 BUILDDIR      = _build
 
 # Put it first so that "make" without argument is like "make help".
 help:
-	@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
+	@$(SPHINXBUILD) -M html "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
 
 .PHONY: help Makefile
 
 # Catch-all target: route all unknown targets to Sphinx using the new
 # "make mode" option.  $(O) is meant as a shortcut for $(SPHINXOPTS).
 %: Makefile
-	@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
\ No newline at end of file
+	@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
diff --git a/docs/conf.py b/docs/conf.py
index b9a5df7..4712015 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -18,7 +18,8 @@
 #
 import os
 import sys
-sys.path.insert(0, os.path.abspath('..'))
+
+sys.path.insert(0, os.path.abspath(".."))
 import gkeepapi
 
 
@@ -31,28 +32,30 @@
 # Add any Sphinx extension module names here, as strings. They can be
 # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
 # ones.
-extensions = ['sphinx.ext.coverage',
-    'sphinx.ext.autodoc',
-    'sphinx.ext.viewcode',
-    'sphinx.ext.napoleon',
-    'sphinx.ext.githubpages']
+extensions = [
+    "sphinx.ext.napoleon",
+    "sphinx.ext.coverage",
+    # "sphinx.ext.autodoc",
+    "sphinx.ext.viewcode",
+    "sphinx.ext.githubpages",
+]
 
 # Add any paths that contain templates here, relative to this directory.
-templates_path = ['_templates']
+templates_path = ["_templates"]
 
 # The suffix(es) of source filenames.
 # You can specify multiple suffix as a list of string:
 #
 # source_suffix = ['.rst', '.md']
-source_suffix = '.rst'
+source_suffix = ".rst"
 
 # The master toctree document.
-master_doc = 'index'
+master_doc = "index"
 
 # General information about the project.
-project = u'gkeepapi'
-copyright = u'2017, Kai'
-author = u'Kai'
+project = "gkeepapi"
+copyright = "2017, Kai"
+author = "Kai"
 
 # The version info for the project you're documenting, acts as replacement for
 # |version| and |release|, also used in various other places throughout the
@@ -68,15 +71,15 @@
 #
 # This is also used if you do content translation via gettext catalogs.
 # Usually you set "language" from the command line for these cases.
-language = None
+language = "en"
 
 # List of patterns, relative to source directory, that match files and
 # directories to ignore when looking for source files.
 # This patterns also effect to html_static_path and html_extra_path
-exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
+exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
 
 # The name of the Pygments (syntax highlighting) style to use.
-pygments_style = 'sphinx'
+pygments_style = "sphinx"
 
 # If true, `todo` and `todoList` produce output, else they produce nothing.
 todo_include_todos = False
@@ -87,7 +90,7 @@
 # The theme to use for HTML and HTML Help pages.  See the documentation for
 # a list of builtin themes.
 #
-html_theme = 'alabaster'
+html_theme = "alabaster"
 
 # Theme options are theme-specific and customize the look and feel of a theme
 # further.  For a list of options available for each theme, see the
@@ -98,7 +101,7 @@
 # Add any paths that contain custom static files (such as style sheets) here,
 # relative to this directory. They are copied after the builtin static files,
 # so a file named "default.css" will overwrite the builtin "default.css".
-html_static_path = ['_static']
+html_static_path = []
 
 # Custom sidebar templates, must be a dictionary that maps document names
 # to template names.
@@ -106,12 +109,12 @@
 # This is required for the alabaster theme
 # refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars
 html_sidebars = {
-    '**': [
-        'about.html',
-        'navigation.html',
-        'relations.html',  # needs 'show_related': True theme option to display
-        'searchbox.html',
-        'donate.html',
+    "**": [
+        "about.html",
+        "navigation.html",
+        "relations.html",  # needs 'show_related': True theme option to display
+        "searchbox.html",
+        "donate.html",
     ]
 }
 
@@ -119,7 +122,7 @@
 # -- Options for HTMLHelp output ------------------------------------------
 
 # Output file base name for HTML help builder.
-htmlhelp_basename = 'gkeepapidoc'
+htmlhelp_basename = "gkeepapidoc"
 
 
 # -- Options for LaTeX output ---------------------------------------------
@@ -128,15 +131,12 @@
     # The paper size ('letterpaper' or 'a4paper').
     #
     # 'papersize': 'letterpaper',
-
     # The font size ('10pt', '11pt' or '12pt').
     #
     # 'pointsize': '10pt',
-
     # Additional stuff for the LaTeX preamble.
     #
     # 'preamble': '',
-
     # Latex figure (float) alignment
     #
     # 'figure_align': 'htbp',
@@ -146,8 +146,7 @@
 # (source start file, target name, title,
 #  author, documentclass [howto, manual, or own class]).
 latex_documents = [
-    (master_doc, 'gkeepapi.tex', u'gkeepapi Documentation',
-     u'Kai', 'manual'),
+    (master_doc, "gkeepapi.tex", "gkeepapi Documentation", "Kai", "manual"),
 ]
 
 
@@ -155,10 +154,7 @@
 
 # One entry per manual page. List of tuples
 # (source start file, name, description, authors, manual section).
-man_pages = [
-    (master_doc, 'gkeepapi', u'gkeepapi Documentation',
-     [author], 1)
-]
+man_pages = [(master_doc, "gkeepapi", "gkeepapi Documentation", [author], 1)]
 
 
 # -- Options for Texinfo output -------------------------------------------
@@ -167,10 +163,13 @@
 # (source start file, target name, title, author,
 #  dir menu entry, description, category)
 texinfo_documents = [
-    (master_doc, 'gkeepapi', u'gkeepapi Documentation',
-     author, 'gkeepapi', 'One line description of project.',
-     'Miscellaneous'),
+    (
+        master_doc,
+        "gkeepapi",
+        "gkeepapi Documentation",
+        author,
+        "gkeepapi",
+        "One line description of project.",
+        "Miscellaneous",
+    ),
 ]
-
-
-
diff --git a/docs/index.rst b/docs/index.rst
index cb929f4..4cd863c 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -427,15 +427,14 @@ FAQ
 This usually occurs when Google thinks the login request looks suspicious. Here are some steps you can take to resolve this:
 
 1. Make sure you have the newest version of gkeepapi installed.
-2. Instead of logging in every time, cache the authentication token and reuse it on subsequent runs. See `here <https://github.com/kiwiz/keep-cli/blob/master/keep#L106-L128>`_ for an example implementation.
+2. Instead of logging in every time, cache the authentication token and reuse it on subsequent runs. See `here <https://github.com/kiwiz/keep-cli/blob/master/src/keep_cli/__main__.py#L106-L128>`__ for an example implementation.
 3. If you have 2-Step Verification turned on, generating an App Password for gkeepapi is highly recommended.
-4. Allowing access through this `link <https://accounts.google.com/DisplayUnlockCaptcha>`_ has worked for some people.
-5. Upgrading to a newer version of Python (3.7+) has worked for some people. See this `issue <https://gitlab.com/AuroraOSS/AuroraStore/issues/217#note_249390026>`_ for more information.
-6. If all else fails, try testing gkeepapi on a separate IP address and/or user to see if you can isolate the problem.
+4. Upgrading to a newer version of Python (3.7+) has worked for some people. See this `issue <https://gitlab.com/AuroraOSS/AuroraStore/issues/217#note_249390026>`__ for more information.
+5. If all else fails, try testing gkeepapi on a separate IP address and/or user to see if you can isolate the problem.
 
 2. I get a "DeviceManagementRequiredOrSyncDisabled" :py:class:`exception.LoginException` when I try to log in.
 
-This is due to the enforcement of Android device policies on your G-Suite account. To resolve this, you can try disabling that setting `here <https://admin.google.com/AdminHome?hl=no#MobileSettings:section=advanced&flyout=security>`_.
+This is due to the enforcement of Android device policies on your G-Suite account. To resolve this, you can try disabling that setting `here <https://admin.google.com/AdminHome?hl=no#MobileSettings:section=advanced&flyout=security>`__.
 
 Known Issues
 ============
diff --git a/gkeepapi/node.py b/gkeepapi/node.py
deleted file mode 100644
index 375f870..0000000
--- a/gkeepapi/node.py
+++ /dev/null
@@ -1,1939 +0,0 @@
-# -*- coding: utf-8 -*-
-"""
-.. automodule:: gkeepapi
-   :members:
-   :inherited-members:
-
-.. moduleauthor:: Kai <z@kwi.li>
-"""
-
-import datetime
-import logging
-import time
-import random
-import enum
-import itertools
-from operator import attrgetter
-
-from future.utils import raise_from
-from . import exception
-
-DEBUG = False
-
-logger = logging.getLogger(__name__)
-
-class NodeType(enum.Enum):
-    """Valid note types."""
-
-    Note = 'NOTE'
-    """A Note"""
-
-    List = 'LIST'
-    """A List"""
-
-    ListItem = 'LIST_ITEM'
-    """A List item"""
-
-    Blob = 'BLOB'
-    """A blob (attachment)"""
-
-class BlobType(enum.Enum):
-    """Valid blob types."""
-
-    Audio = 'AUDIO'
-    """Audio"""
-
-    Image = 'IMAGE'
-    """Image"""
-
-    Drawing = 'DRAWING'
-    """Drawing"""
-
-class ColorValue(enum.Enum):
-    """Valid note colors."""
-
-    White = 'DEFAULT'
-    """White"""
-
-    Red = 'RED'
-    """Red"""
-
-    Orange = 'ORANGE'
-    """Orange"""
-
-    Yellow = 'YELLOW'
-    """Yellow"""
-
-    Green = 'GREEN'
-    """Green"""
-
-    Teal = 'TEAL'
-    """Teal"""
-
-    Blue = 'BLUE'
-    """Blue"""
-
-    DarkBlue = 'CERULEAN'
-    """Dark blue"""
-
-    Purple = 'PURPLE'
-    """Purple"""
-
-    Pink = 'PINK'
-    """Pink"""
-
-    Brown = 'BROWN'
-    """Brown"""
-
-    Gray = 'GRAY'
-    """Gray"""
-
-class CategoryValue(enum.Enum):
-    """Valid note categories."""
-
-    Books = 'BOOKS'
-    """Books"""
-
-    Food = 'FOOD'
-    """Food"""
-
-    Movies = 'MOVIES'
-    """Movies"""
-
-    Music = 'MUSIC'
-    """Music"""
-
-    Places = 'PLACES'
-    """Places"""
-
-    Quotes = 'QUOTES'
-    """Quotes"""
-
-    Travel = 'TRAVEL'
-    """Travel"""
-
-    TV = 'TV'
-    """TV"""
-
-class SuggestValue(enum.Enum):
-    """Valid task suggestion categories."""
-
-    GroceryItem = 'GROCERY_ITEM'
-    """Grocery item"""
-
-class NewListItemPlacementValue(enum.Enum):
-    """Target location to put new list items."""
-
-    Top = 'TOP'
-    """Top"""
-
-    Bottom = 'BOTTOM'
-    """Bottom"""
-
-class GraveyardStateValue(enum.Enum):
-    """Visibility setting for the graveyard."""
-
-    Expanded = 'EXPANDED'
-    """Expanded"""
-
-    Collapsed = 'COLLAPSED'
-    """Collapsed"""
-
-class CheckedListItemsPolicyValue(enum.Enum):
-    """Movement setting for checked list items."""
-
-    Default = 'DEFAULT'
-    """Default"""
-
-    Graveyard = 'GRAVEYARD'
-    """Graveyard"""
-
-class ShareRequestValue(enum.Enum):
-    """Collaborator change type."""
-
-    Add = 'WR'
-    """Grant access."""
-
-    Remove = 'RM'
-    """Remove access."""
-
-class RoleValue(enum.Enum):
-    """Collaborator role type."""
-
-    Owner = 'O'
-    """Note owner."""
-
-    User = 'W'
-    """Note collaborator."""
-
-class Element(object):
-    """Interface for elements that can be serialized and deserialized."""
-    def __init__(self):
-        self._dirty = False
-
-    def _find_discrepancies(self, raw): # pragma: no cover
-        s_raw = self.save(False)
-        if isinstance(raw, dict):
-            for key, val in raw.items():
-                if key in ['parentServerId', 'lastSavedSessionId']:
-                    continue
-                if key not in s_raw:
-                    logger.info('Missing key for %s key %s', type(self), key)
-                    continue
-
-                if isinstance(val, (list, dict)):
-                    continue
-
-                val_a = raw[key]
-                val_b = s_raw[key]
-                # Python strftime's 'z' format specifier includes microseconds, but the response from GKeep
-                # only has milliseconds. This causes a string mismatch, so we construct datetime objects
-                # to properly compare
-                if isinstance(val_a, str) and isinstance(val_b, str):
-                    try:
-                        tval_a = NodeTimestamps.str_to_dt(val_a)
-                        tval_b = NodeTimestamps.str_to_dt(val_b)
-                        val_a, val_b = tval_a, tval_b
-                    except (KeyError, ValueError):
-                        pass
-                if val_a != val_b:
-                    logger.info('Different value for %s key %s: %s != %s', type(self), key, raw[key], s_raw[key])
-        elif isinstance(raw, list):
-            if len(raw) != len(s_raw):
-                logger.info('Different length for %s: %d != %d', type(self), len(raw), len(s_raw))
-
-    def load(self, raw):
-        """Unserialize from raw representation. (Wrapper)
-
-        Args:
-            raw (dict): Raw.
-        Raises:
-            ParseException: If there was an error parsing data.
-        """
-        try:
-            self._load(raw)
-        except (KeyError, ValueError) as e:
-            raise_from(exception.ParseException('Parse error in %s' % (type(self)), raw), e)
-
-    def _load(self, raw):
-        """Unserialize from raw representation. (Implementation logic)
-
-        Args:
-            raw (dict): Raw.
-        """
-        self._dirty = raw.get('_dirty', False)
-
-    def save(self, clean=True):
-        """Serialize into raw representation. Clears the dirty bit by default.
-
-        Args:
-            clean (bool): Whether to clear the dirty bit.
-
-        Returns:
-            dict: Raw.
-        """
-        ret = {}
-        if clean:
-            self._dirty = False
-        else:
-            ret['_dirty'] = self._dirty
-        return ret
-
-    @property
-    def dirty(self):
-        """Get dirty state.
-
-        Returns:
-            str: Whether this element is dirty.
-        """
-        return self._dirty
-
-class Annotation(Element):
-    """Note annotations base class."""
-    def __init__(self):
-        super(Annotation, self).__init__()
-        self.id = self._generateAnnotationId()
-
-    def _load(self, raw):
-        super(Annotation, self)._load(raw)
-        self.id = raw.get('id')
-
-    def save(self, clean=True):
-        ret = {}
-        if self.id is not None:
-            ret = super(Annotation, self).save(clean)
-        if self.id is not None:
-            ret['id'] = self.id
-        return ret
-
-    @classmethod
-    def _generateAnnotationId(cls):
-        return '%08x-%04x-%04x-%04x-%012x' % (
-            random.randint(0x00000000, 0xffffffff),
-            random.randint(0x0000, 0xffff),
-            random.randint(0x0000, 0xffff),
-            random.randint(0x0000, 0xffff),
-            random.randint(0x000000000000, 0xffffffffffff)
-        )
-
-class WebLink(Annotation):
-    """Represents a link annotation on a :class:`TopLevelNode`."""
-    def __init__(self):
-        super(WebLink, self).__init__()
-        self._title = ''
-        self._url = ''
-        self._image_url = None
-        self._provenance_url = ''
-        self._description = ''
-
-    def _load(self, raw):
-        super(WebLink, self)._load(raw)
-        self._title = raw['webLink']['title']
-        self._url = raw['webLink']['url']
-        self._image_url = raw['webLink']['imageUrl'] if 'imageUrl' in raw['webLink'] else self.image_url
-        self._provenance_url = raw['webLink']['provenanceUrl']
-        self._description = raw['webLink']['description']
-
-    def save(self, clean=True):
-        ret = super(WebLink, self).save(clean)
-        ret['webLink'] = {
-            'title': self._title,
-            'url': self._url,
-            'imageUrl': self._image_url,
-            'provenanceUrl': self._provenance_url,
-            'description': self._description,
-        }
-        return ret
-
-    @property
-    def title(self):
-        """Get the link title.
-
-        Returns:
-            str: The link title.
-        """
-        return self._title
-
-    @title.setter
-    def title(self, value):
-        self._title = value
-        self._dirty = True
-
-    @property
-    def url(self):
-        """Get the link url.
-
-        Returns:
-            str: The link url.
-        """
-        return self._url
-
-    @url.setter
-    def url(self, value):
-        self._url = value
-        self._dirty = True
-
-    @property
-    def image_url(self):
-        """Get the link image url.
-
-        Returns:
-            str: The image url or None.
-        """
-        return self._image_url
-
-    @image_url.setter
-    def image_url(self, value):
-        self._image_url = value
-        self._dirty = True
-
-    @property
-    def provenance_url(self):
-        """Get the provenance url.
-
-        Returns:
-            url: The provenance url.
-        """
-        return self._provenance_url
-
-    @provenance_url.setter
-    def provenance_url(self, value):
-        self._provenance_url = value
-        self._dirty = True
-
-    @property
-    def description(self):
-        """Get the link description.
-
-        Returns:
-            str: The link description.
-        """
-        return self._description
-
-    @description.setter
-    def description(self, value):
-        self._description = value
-        self._dirty = True
-
-class Category(Annotation):
-    """Represents a category annotation on a :class:`TopLevelNode`."""
-    def __init__(self):
-        super(Category, self).__init__()
-        self._category = None
-
-    def _load(self, raw):
-        super(Category, self)._load(raw)
-        self._category = CategoryValue(raw['topicCategory']['category'])
-
-    def save(self, clean=True):
-        ret = super(Category, self).save(clean)
-        ret['topicCategory'] = {
-            'category': self._category.value
-        }
-        return ret
-
-    @property
-    def category(self):
-        """Get the category.
-
-        Returns:
-            gkeepapi.node.CategoryValue: The category.
-        """
-        return self._category
-
-    @category.setter
-    def category(self, value):
-        self._category = value
-        self._dirty = True
-
-class TaskAssist(Annotation):
-    """Unknown."""
-    def __init__(self):
-        super(TaskAssist, self).__init__()
-        self._suggest = None
-
-    def _load(self, raw):
-        super(TaskAssist, self)._load(raw)
-        self._suggest = raw['taskAssist']['suggestType']
-
-    def save(self, clean=True):
-        ret = super(TaskAssist, self).save(clean)
-        ret['taskAssist'] = {
-            'suggestType': self._suggest
-        }
-        return ret
-
-    @property
-    def suggest(self):
-        """Get the suggestion.
-
-        Returns:
-            str: The suggestion.
-        """
-        return self._suggest
-
-    @suggest.setter
-    def suggest(self, value):
-        self._suggest = value
-        self._dirty = True
-
-class Context(Annotation):
-    """Represents a context annotation, which may contain other annotations."""
-    def __init__(self):
-        super(Context, self).__init__()
-        self._entries = {}
-
-    def _load(self, raw):
-        super(Context, self)._load(raw)
-        self._entries = {}
-        for key, entry in raw.get('context', {}).items():
-            self._entries[key] = NodeAnnotations.from_json({key: entry})
-
-    def save(self, clean=True):
-        ret = super(Context, self).save(clean)
-        context = {}
-        for entry in self._entries.values():
-            context.update(entry.save(clean))
-        ret['context'] = context
-        return ret
-
-    def all(self):
-        """Get all sub annotations.
-
-        Returns:
-            List[gkeepapi.node.Annotation]: Sub Annotations.
-        """
-        return self._entries.values()
-
-    @property
-    def dirty(self):
-        return super(Context, self).dirty or any((annotation.dirty for annotation in self._entries.values()))
-
-class NodeAnnotations(Element):
-    """Represents the annotation container on a :class:`TopLevelNode`."""
-    def __init__(self):
-        super(NodeAnnotations, self).__init__()
-        self._annotations = {}
-
-    def __len__(self):
-        return len(self._annotations)
-
-    @classmethod
-    def from_json(cls, raw):
-        """Helper to construct an annotation from a dict.
-
-        Args:
-            raw (dict): Raw annotation representation.
-
-        Returns:
-            Node: An Annotation object or None.
-        """
-        bcls = None
-        if 'webLink' in raw:
-            bcls = WebLink
-        elif 'topicCategory' in raw:
-            bcls = Category
-        elif 'taskAssist' in raw:
-            bcls = TaskAssist
-        elif 'context' in raw:
-            bcls = Context
-
-        if bcls is None:
-            logger.warning('Unknown annotation type: %s', raw.keys())
-            return None
-        annotation = bcls()
-        annotation.load(raw)
-
-        return annotation
-
-    def all(self):
-        """Get all annotations.
-
-        Returns:
-            List[gkeepapi.node.Annotation]: Annotations.
-        """
-        return self._annotations.values()
-
-    def _load(self, raw):
-        super(NodeAnnotations, self)._load(raw)
-        self._annotations = {}
-        if 'annotations' not in raw:
-            return
-
-        for raw_annotation in raw['annotations']:
-            annotation = self.from_json(raw_annotation)
-            self._annotations[annotation.id] = annotation
-
-    def save(self, clean=True):
-        ret = super(NodeAnnotations, self).save(clean)
-        ret['kind'] = 'notes#annotationsGroup'
-        if self._annotations:
-            ret['annotations'] = [annotation.save(clean) for annotation in self._annotations.values()]
-        return ret
-
-    def _get_category_node(self):
-        for annotation in self._annotations.values():
-            if isinstance(annotation, Category):
-                return annotation
-        return None
-
-    @property
-    def category(self):
-        """Get the category.
-
-        Returns:
-            Union[gkeepapi.node.CategoryValue, None]: The category or None.
-        """
-        node = self._get_category_node()
-
-        return node.category if node is not None else None
-
-    @category.setter
-    def category(self, value):
-        node = self._get_category_node()
-        if value is None:
-            if node is not None:
-                del self._annotations[node.id]
-        else:
-            if node is None:
-                node = Category()
-                self._annotations[node.id] = node
-
-            node.category = value
-        self._dirty = True
-
-    @property
-    def links(self):
-        """Get all links.
-
-        Returns:
-            list[gkeepapi.node.WebLink]: A list of links.
-        """
-        return [annotation for annotation in self._annotations.values()
-            if isinstance(annotation, WebLink)
-        ]
-
-    def append(self, annotation):
-        """Add an annotation.
-
-        Args:
-            annotation (gkeepapi.node.Annotation): An Annotation object.
-
-        Returns:
-            gkeepapi.node.Annotation: The Annotation.
-        """
-        self._annotations[annotation.id] = annotation
-        self._dirty = True
-        return annotation
-
-    def remove(self, annotation):
-        """Removes an annotation.
-
-        Args:
-            annotation (gkeepapi.node.Annotation): An Annotation object.
-        """
-        if annotation.id in self._annotations:
-            del self._annotations[annotation.id]
-        self._dirty = True
-
-    @property
-    def dirty(self):
-        return super(NodeAnnotations, self).dirty or any((annotation.dirty for annotation in self._annotations.values()))
-
-class NodeTimestamps(Element):
-    """Represents the timestamps associated with a :class:`TopLevelNode`."""
-    TZ_FMT = '%Y-%m-%dT%H:%M:%S.%fZ'
-
-    def __init__(self, create_time=None):
-        super(NodeTimestamps, self).__init__()
-        if create_time is None:
-            create_time = time.time()
-
-        self._created = self.int_to_dt(create_time)
-        self._deleted = self.int_to_dt(0)
-        self._trashed = self.int_to_dt(0)
-        self._updated = self.int_to_dt(create_time)
-        self._edited = self.int_to_dt(create_time)
-
-    def _load(self, raw):
-        super(NodeTimestamps, self)._load(raw)
-        if 'created' in raw:
-            self._created = self.str_to_dt(raw['created'])
-        self._deleted = self.str_to_dt(raw['deleted']) \
-            if 'deleted' in raw else None
-        self._trashed = self.str_to_dt(raw['trashed']) \
-            if 'trashed' in raw else None
-        self._updated = self.str_to_dt(raw['updated'])
-        self._edited = self.str_to_dt(raw['userEdited']) \
-            if 'userEdited' in raw else None
-
-    def save(self, clean=True):
-        ret = super(NodeTimestamps, self).save(clean)
-        ret['kind'] = 'notes#timestamps'
-        ret['created'] = self.dt_to_str(self._created)
-        if self._deleted is not None:
-            ret['deleted'] = self.dt_to_str(self._deleted)
-        if self._trashed is not None:
-            ret['trashed'] = self.dt_to_str(self._trashed)
-        ret['updated'] = self.dt_to_str(self._updated)
-        if self._edited is not None:
-            ret['userEdited'] = self.dt_to_str(self._edited)
-        return ret
-
-    @classmethod
-    def str_to_dt(cls, tzs):
-        """Convert a datetime string into an object.
-
-        Params:
-            tsz (str): Datetime string.
-
-        Returns:
-            datetime.datetime: Datetime.
-        """
-        return datetime.datetime.strptime(tzs, cls.TZ_FMT)
-
-    @classmethod
-    def int_to_dt(cls, tz):
-        """Convert a unix timestamp into an object.
-
-        Params:
-            ts (int): Unix timestamp.
-
-        Returns:
-            datetime.datetime: Datetime.
-        """
-        return datetime.datetime.utcfromtimestamp(tz)
-
-    @classmethod
-    def dt_to_str(cls, dt):
-        """Convert a datetime to a str.
-
-        Returns:
-            str: Datetime string.
-        """
-        return dt.strftime(cls.TZ_FMT)
-
-    @classmethod
-    def int_to_str(cls, tz):
-        """Convert a unix timestamp to a str.
-
-        Returns:
-            str: Datetime string.
-        """
-        return cls.dt_to_str(cls.int_to_dt(tz))
-
-    @property
-    def created(self):
-        """Get the creation datetime.
-
-        Returns:
-            datetime.datetime: Datetime.
-        """
-        return self._created
-
-    @created.setter
-    def created(self, value):
-        self._created = value
-        self._dirty = True
-
-    @property
-    def deleted(self):
-        """Get the deletion datetime.
-
-        Returns:
-            datetime.datetime: Datetime.
-        """
-        return self._deleted
-
-    @deleted.setter
-    def deleted(self, value):
-        self._deleted = value
-        self._dirty = True
-
-    @property
-    def trashed(self):
-        """Get the move-to-trash datetime.
-
-        Returns:
-            datetime.datetime: Datetime.
-        """
-        return self._trashed
-
-    @trashed.setter
-    def trashed(self, value):
-        self._trashed = value
-        self._dirty = True
-
-    @property
-    def updated(self):
-        """Get the updated datetime.
-
-        Returns:
-            datetime.datetime: Datetime.
-        """
-        return self._updated
-
-    @updated.setter
-    def updated(self, value):
-        self._updated = value
-        self._dirty = True
-
-    @property
-    def edited(self):
-        """Get the user edited datetime.
-
-        Returns:
-            datetime.datetime: Datetime.
-        """
-        return self._edited
-
-    @edited.setter
-    def edited(self, value):
-        self._edited = value
-        self._dirty = True
-
-class NodeSettings(Element):
-    """Represents the settings associated with a :class:`TopLevelNode`."""
-    def __init__(self):
-        super(NodeSettings, self).__init__()
-        self._new_listitem_placement = NewListItemPlacementValue.Bottom
-        self._graveyard_state = GraveyardStateValue.Collapsed
-        self._checked_listitems_policy = CheckedListItemsPolicyValue.Graveyard
-
-    def _load(self, raw):
-        super(NodeSettings, self)._load(raw)
-        self._new_listitem_placement = NewListItemPlacementValue(raw['newListItemPlacement'])
-        self._graveyard_state = GraveyardStateValue(raw['graveyardState'])
-        self._checked_listitems_policy = CheckedListItemsPolicyValue(raw['checkedListItemsPolicy'])
-
-    def save(self, clean=True):
-        ret = super(NodeSettings, self).save(clean)
-        ret['newListItemPlacement'] = self._new_listitem_placement.value
-        ret['graveyardState'] = self._graveyard_state.value
-        ret['checkedListItemsPolicy'] = self._checked_listitems_policy.value
-        return ret
-
-    @property
-    def new_listitem_placement(self):
-        """Get the default location to insert new listitems.
-
-        Returns:
-            gkeepapi.node.NewListItemPlacementValue: Placement.
-        """
-        return self._new_listitem_placement
-
-    @new_listitem_placement.setter
-    def new_listitem_placement(self, value):
-        self._new_listitem_placement = value
-        self._dirty = True
-
-    @property
-    def graveyard_state(self):
-        """Get the visibility state for the list graveyard.
-
-        Returns:
-            gkeepapi.node.GraveyardStateValue: Visibility.
-        """
-        return self._graveyard_state
-
-    @graveyard_state.setter
-    def graveyard_state(self, value):
-        self._graveyard_state = value
-        self._dirty = True
-
-    @property
-    def checked_listitems_policy(self):
-        """Get the policy for checked listitems.
-
-        Returns:
-            gkeepapi.node.CheckedListItemsPolicyValue: Policy.
-        """
-        return self._checked_listitems_policy
-
-    @checked_listitems_policy.setter
-    def checked_listitems_policy(self, value):
-        self._checked_listitems_policy = value
-        self._dirty = True
-
-class NodeCollaborators(Element):
-    """Represents the collaborators on a :class:`TopLevelNode`."""
-    def __init__(self):
-        super(NodeCollaborators, self).__init__()
-        self._collaborators = {}
-
-    def __len__(self):
-        return len(self._collaborators)
-
-    def load(self, collaborators_raw, requests_raw): # pylint: disable=arguments-differ
-        # Parent method not called.
-        if requests_raw and isinstance(requests_raw[-1], bool):
-            self._dirty = requests_raw.pop()
-        else:
-            self._dirty = False
-        self._collaborators = {}
-        for collaborator in collaborators_raw:
-            self._collaborators[collaborator['email']] = RoleValue(collaborator['role'])
-        for collaborator in requests_raw:
-            self._collaborators[collaborator['email']] = ShareRequestValue(collaborator['type'])
-
-    def save(self, clean=True):
-        # Parent method not called.
-        collaborators = []
-        requests = []
-        for email, action in self._collaborators.items():
-            if isinstance(action, ShareRequestValue):
-                requests.append({'email': email, 'type': action.value})
-            else:
-                collaborators.append({'email': email, 'role': action.value, 'auxiliary_type': 'None'})
-        if not clean:
-            requests.append(self._dirty)
-        else:
-            self._dirty = False
-        return (collaborators, requests)
-
-    def add(self, email):
-        """Add a collaborator.
-
-        Args:
-            str : Collaborator email address.
-        """
-        if email not in self._collaborators:
-            self._collaborators[email] = ShareRequestValue.Add
-        self._dirty = True
-
-    def remove(self, email):
-        """Remove a Collaborator.
-
-        Args:
-            str : Collaborator email address.
-        """
-        if email in self._collaborators:
-            if self._collaborators[email] == ShareRequestValue.Add:
-                del self._collaborators[email]
-            else:
-                self._collaborators[email] = ShareRequestValue.Remove
-        self._dirty = True
-
-    def all(self):
-        """Get all collaborators.
-
-        Returns:
-            List[str]: Collaborators.
-        """
-        return [email for email, action in self._collaborators.items() if action in [RoleValue.Owner, RoleValue.User, ShareRequestValue.Add]]
-
-class NodeLabels(Element):
-    """Represents the labels on a :class:`TopLevelNode`."""
-    def __init__(self):
-        super(NodeLabels, self).__init__()
-        self._labels = {}
-
-    def __len__(self):
-        return len(self._labels)
-
-    def _load(self, raw):
-        # Parent method not called.
-        if raw and isinstance(raw[-1], bool):
-            self._dirty = raw.pop()
-        else:
-            self._dirty = False
-        self._labels = {}
-        for raw_label in raw:
-            self._labels[raw_label['labelId']] = None
-
-    def save(self, clean=True):
-        # Parent method not called.
-        ret = [
-            {'labelId': label_id, 'deleted': NodeTimestamps.dt_to_str(datetime.datetime.utcnow()) if label is None else NodeTimestamps.int_to_str(0)}
-        for label_id, label in self._labels.items()]
-        if not clean:
-            ret.append(self._dirty)
-        else:
-            self._dirty = False
-        return ret
-
-    def add(self, label):
-        """Add a label.
-
-        Args:
-            label (gkeepapi.node.Label): The Label object.
-        """
-        self._labels[label.id] = label
-        self._dirty = True
-
-    def remove(self, label):
-        """Remove a label.
-
-        Args:
-            label (gkeepapi.node.Label): The Label object.
-        """
-        if label.id in self._labels:
-            self._labels[label.id] = None
-        self._dirty = True
-
-    def get(self, label_id):
-        """Get a label by ID.
-
-        Args:
-            label_id (str): The label ID.
-        """
-        return self._labels.get(label_id)
-
-    def all(self):
-        """Get all labels.
-
-        Returns:
-            List[gkeepapi.node.Label]: Labels.
-        """
-        return [label for _, label in self._labels.items() if label is not None]
-
-class TimestampsMixin(object):
-    """A mixin to add methods for updating timestamps."""
-    def touch(self, edited=False):
-        """Mark the node as dirty.
-
-        Args:
-            edited (bool): Whether to set the edited time.
-        """
-        self._dirty = True
-        dt = datetime.datetime.utcnow()
-        self.timestamps.updated = dt
-        if edited:
-            self.timestamps.edited = dt
-
-    @property
-    def trashed(self):
-        """Get the trashed state.
-
-        Returns:
-            bool: Whether this item is trashed.
-        """
-        return self.timestamps.trashed is not None and self.timestamps.trashed > NodeTimestamps.int_to_dt(0)
-
-    def trash(self):
-        """Mark the item as trashed."""
-        self.timestamps.trashed = datetime.datetime.utcnow()
-
-    def untrash(self):
-        """Mark the item as untrashed."""
-        self.timestamps.trashed = self.timestamps.int_to_dt(0)
-
-    @property
-    def deleted(self):
-        """Get the deleted state.
-
-        Returns:
-            bool: Whether this item is deleted.
-        """
-        return self.timestamps.deleted is not None and self.timestamps.deleted > NodeTimestamps.int_to_dt(0)
-
-    def delete(self):
-        """Mark the item as deleted."""
-        self.timestamps.deleted = datetime.datetime.utcnow()
-
-    def undelete(self):
-        """Mark the item as undeleted."""
-        self.timestamps.deleted = None
-
-class Node(Element, TimestampsMixin):
-    """Node base class."""
-    def __init__(self, id_=None, type_=None, parent_id=None):
-        super(Node, self).__init__()
-
-        create_time = time.time()
-
-        self.parent = None
-        self.id = self._generateId(create_time) if id_ is None else id_
-        self.server_id = None
-        self.parent_id = parent_id
-        self.type = type_
-        self._sort = random.randint(1000000000, 9999999999)
-        self._version = None
-        self._text = ''
-        self._children = {}
-        self.timestamps = NodeTimestamps(create_time)
-        self.settings = NodeSettings()
-        self.annotations = NodeAnnotations()
-
-        # Set if there is no baseVersion in the raw data
-        self._moved = False
-
-    @classmethod
-    def _generateId(cls, tz):
-        return '%x.%016x' % (
-            int(tz * 1000),
-            random.randint(0x0000000000000000, 0xffffffffffffffff)
-        )
-
-    def _load(self, raw):
-        super(Node, self)._load(raw)
-        # Verify this is a valid type
-        NodeType(raw['type'])
-        if raw['kind'] not in ['notes#node']:
-            logger.warning('Unknown node kind: %s', raw['kind'])
-
-        if 'mergeConflict' in raw:
-            raise exception.MergeException(raw)
-
-        self.id = raw['id']
-        self.server_id = raw['serverId'] if 'serverId' in raw else self.server_id
-        self.parent_id = raw['parentId']
-        self._sort = raw['sortValue'] if 'sortValue' in raw else self.sort
-        self._version = raw['baseVersion'] if 'baseVersion' in raw else self._version
-        self._text = raw['text'] if 'text' in raw else self._text
-        self.timestamps.load(raw['timestamps'])
-        self.settings.load(raw['nodeSettings'])
-        self.annotations.load(raw['annotationsGroup'])
-
-    def save(self, clean=True):
-        ret = super(Node, self).save(clean)
-        ret['id'] = self.id
-        ret['kind'] = 'notes#node'
-        ret['type'] = self.type.value
-        ret['parentId'] = self.parent_id
-        ret['sortValue'] = self._sort
-        if not self._moved and self._version is not None:
-            ret['baseVersion'] = self._version
-        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)
-        return ret
-
-    @property
-    def sort(self):
-        """Get the sort id.
-
-        Returns:
-            int: Sort id.
-        """
-        return int(self._sort)
-
-    @sort.setter
-    def sort(self, value):
-        self._sort = value
-        self.touch()
-
-    @property
-    def version(self):
-        """Get the node version.
-
-        Returns:
-            int: Version.
-        """
-        return self._version
-
-    @property
-    def text(self):
-        """Get the text value.
-
-        Returns:
-            str: Text value.
-        """
-        return self._text
-
-    @text.setter
-    def text(self, value):
-        """Set the text value.
-
-        Args:
-            value (str): Text value.
-        """
-        self._text = value
-        self.timestamps.edited = datetime.datetime.utcnow()
-        self.touch(True)
-
-    @property
-    def children(self):
-        """Get all children.
-
-        Returns:
-            list[gkeepapi.Node]: Children nodes.
-        """
-        return list(self._children.values())
-
-    def get(self, node_id):
-        """Get child node with the given ID.
-
-        Args:
-            node_id (str): The node ID.
-
-        Returns:
-            gkeepapi.Node: Child node.
-        """
-        return self._children.get(node_id)
-
-    def append(self, node, dirty=True):
-        """Add a new child node.
-
-        Args:
-            node (gkeepapi.Node): Node to add.
-            dirty (bool): Whether this node should be marked dirty.
-        """
-        self._children[node.id] = node
-        node.parent = self
-        if dirty:
-            self.touch()
-
-        return node
-
-    def remove(self, node, dirty=True):
-        """Remove the given child node.
-
-        Args:
-            node (gkeepapi.Node): Node to remove.
-            dirty (bool): Whether this node should be marked dirty.
-        """
-        if node.id in self._children:
-            self._children[node.id].parent = None
-            del self._children[node.id]
-        if dirty:
-            self.touch()
-
-    @property
-    def new(self):
-        """Get whether this node has been persisted to the server.
-
-        Returns:
-            bool: True if node is new.
-        """
-        return self.server_id is None
-
-    @property
-    def dirty(self):
-        return super(Node, self).dirty or self.timestamps.dirty or self.annotations.dirty or self.settings.dirty or any((node.dirty for node in self.children))
-
-class Root(Node):
-    """Internal root node."""
-    ID = 'root'
-    def __init__(self):
-        super(Root, self).__init__(id_=self.ID)
-
-    @property
-    def dirty(self):
-        return False
-
-class TopLevelNode(Node):
-    """Top level node base class."""
-    _TYPE = None
-    def __init__(self, **kwargs):
-        super(TopLevelNode, self).__init__(parent_id=Root.ID, **kwargs)
-        self._color = ColorValue.White
-        self._archived = False
-        self._pinned = False
-        self._title = ''
-        self.labels = NodeLabels()
-        self.collaborators = NodeCollaborators()
-
-    def _load(self, raw):
-        super(TopLevelNode, self)._load(raw)
-        self._color = ColorValue(raw['color']) if 'color' in raw else ColorValue.White
-        self._archived = raw['isArchived'] if 'isArchived' in raw else False
-        self._pinned = raw['isPinned'] if 'isPinned' in raw else False
-        self._title = raw['title'] if 'title' in raw else ''
-        self.labels.load(raw['labelIds'] if 'labelIds' in raw else [])
-
-        self.collaborators.load(
-            raw['roleInfo'] if 'roleInfo' in raw else [],
-            raw['shareRequests'] if 'shareRequests' in raw else [],
-        )
-        self._moved = 'moved' in raw
-
-    def save(self, clean=True):
-        ret = super(TopLevelNode, self).save(clean)
-        ret['color'] = self._color.value
-        ret['isArchived'] = self._archived
-        ret['isPinned'] = self._pinned
-        ret['title'] = self._title
-        labels = self.labels.save(clean)
-
-        collaborators, requests = self.collaborators.save(clean)
-        if labels:
-            ret['labelIds'] = labels
-        ret['collaborators'] = collaborators
-        if requests:
-            ret['shareRequests'] = requests
-        return ret
-
-    @property
-    def color(self):
-        """Get the node color.
-
-        Returns:
-            gkeepapi.node.Color: Color.
-        """
-        return self._color
-
-    @color.setter
-    def color(self, value):
-        self._color = value
-        self.touch(True)
-
-    @property
-    def archived(self):
-        """Get the archive state.
-
-        Returns:
-            bool: Whether this node is archived.
-        """
-        return self._archived
-
-    @archived.setter
-    def archived(self, value):
-        self._archived = value
-        self.touch(True)
-
-    @property
-    def pinned(self):
-        """Get the pin state.
-
-        Returns:
-            bool: Whether this node is pinned.
-        """
-        return self._pinned
-
-    @pinned.setter
-    def pinned(self, value):
-        self._pinned = value
-        self.touch(True)
-
-    @property
-    def title(self):
-        """Get the title.
-
-        Returns:
-            str: Title.
-        """
-        return self._title
-
-    @title.setter
-    def title(self, value):
-        self._title = value
-        self.touch(True)
-
-    @property
-    def url(self):
-        """Get the url for this node.
-
-        Returns:
-            str: Google Keep url.
-        """
-        return 'https://keep.google.com/u/0/#' + self._TYPE.value + '/' + self.id
-
-    @property
-    def dirty(self):
-        return super(TopLevelNode, self).dirty or self.labels.dirty or self.collaborators.dirty
-
-    @property
-    def blobs(self):
-        """Get all media blobs.
-
-        Returns:
-            list[gkeepapi.node.Blob]: Media blobs.
-        """
-        return [node for node in self.children if isinstance(node, Blob)]
-
-    @property
-    def images(self):
-        return [blob for blob in self.blobs if isinstance(blob.blob, NodeImage)]
-
-    @property
-    def drawings(self):
-        return [blob for blob in self.blobs if isinstance(blob.blob, NodeDrawing)]
-
-    @property
-    def audio(self):
-        return [blob for blob in self.blobs if isinstance(blob.blob, NodeAudio)]
-
-class Note(TopLevelNode):
-    """Represents a Google Keep note."""
-    _TYPE = NodeType.Note
-    def __init__(self, **kwargs):
-        super(Note, self).__init__(type_=self._TYPE, **kwargs)
-
-    def _get_text_node(self):
-        node = None
-        for child_node in self.children:
-            if isinstance(child_node, ListItem):
-                node = child_node
-                break
-
-        return node
-
-    @property
-    def text(self):
-        node = self._get_text_node()
-
-        if node is None:
-            return self._text
-        return node.text
-
-    @text.setter
-    def text(self, value):
-        node = self._get_text_node()
-        if node is None:
-            node = ListItem(parent_id=self.id)
-            self.append(node, True)
-        node.text = value
-        self.touch(True)
-
-    def __str__(self):
-        return '\n'.join([self.title, self.text])
-
-class List(TopLevelNode):
-    """Represents a Google Keep list."""
-    _TYPE = NodeType.List
-    SORT_DELTA = 10000 # Arbitrary constant
-    def __init__(self, **kwargs):
-        super(List, self).__init__(type_=self._TYPE, **kwargs)
-
-    def add(self, text, checked=False, sort=None):
-        """Add a new item to the list.
-
-        Args:
-            text (str): The text.
-            checked (bool): Whether this item is checked.
-            sort (Union[gkeepapi.node.NewListItemPlacementValue, int]): Item id for sorting or a placement policy.
-        """
-        node = ListItem(parent_id=self.id, parent_server_id=self.server_id)
-        node.checked = checked
-        node.text = text
-
-        items = list(self.items)
-        if isinstance(sort, int):
-            node.sort = sort
-        elif isinstance(sort, NewListItemPlacementValue) and len(items):
-            func = max
-            delta = self.SORT_DELTA
-            if sort == NewListItemPlacementValue.Bottom:
-                func = min
-                delta *= -1
-
-            node.sort = func((int(item.sort) for item in items)) + delta
-
-        self.append(node, True)
-        self.touch(True)
-        return node
-
-    @property
-    def text(self):
-        return '\n'.join((str(node) for node in self.items))
-
-    @classmethod
-    def sorted_items(cls, items):
-        """Generate a list of sorted list items, taking into account parent items.
-
-        Args:
-            items (list[gkeepapi.node.ListItem]): Items to sort.
-        Returns:
-            list[gkeepapi.node.ListItem]: Sorted items.
-        """
-        class t(tuple):
-            """Tuple with element-based sorting"""
-            def __cmp__(self, other):
-                for a, b in itertools.zip_longest(self, other):
-                    if a != b:
-                        if a is None:
-                            return 1
-                        if b is None:
-                            return -1
-                        return a - b
-                return 0
-
-            def __lt__(self, other): # pragma: no cover
-                return self.__cmp__(other) < 0
-            def __gt_(self, other): # pragma: no cover
-                return self.__cmp__(other) > 0
-            def __le__(self, other): # pragma: no cover
-                return self.__cmp__(other) <= 0
-            def __ge_(self, other): # pragma: no cover
-                return self.__cmp__(other) >= 0
-            def __eq__(self, other): # pragma: no cover
-                return self.__cmp__(other) == 0
-            def __ne__(self, other): # pragma: no cover
-                return self.__cmp__(other) != 0
-
-        def key_func(x):
-            if x.indented:
-                return t((int(x.parent_item.sort), int(x.sort)))
-            return t((int(x.sort), ))
-
-        return sorted(items, key=key_func, reverse=True)
-
-    def _items(self, checked=None):
-        return [
-            node for node in self.children
-            if isinstance(node, ListItem) and not node.deleted and (
-                checked is None or node.checked == checked
-            )
-        ]
-
-    def sort_items(self, key=attrgetter('text'), reverse=False):
-        """Sort list items in place. By default, the items are alphabetized,
-        but a custom function can be specified.
-
-        Args:
-            key (callable): A filter function.
-            reverse (bool): Whether to reverse the output.
-        """
-        sorted_children = sorted(self._items(), key=key, reverse=reverse)
-        sort_value = random.randint(1000000000, 9999999999)
-
-        for node in sorted_children:
-            node.sort = sort_value
-            sort_value -= self.SORT_DELTA
-
-    def __str__(self):
-        return '\n'.join(([self.title] + [str(node) for node in self.items]))
-
-    @property
-    def items(self):
-        """Get all listitems.
-
-        Returns:
-            list[gkeepapi.node.ListItem]: List items.
-        """
-        return self.sorted_items(self._items())
-
-    @property
-    def checked(self):
-        """Get all checked listitems.
-
-        Returns:
-            list[gkeepapi.node.ListItem]: List items.
-        """
-        return self.sorted_items(self._items(True))
-
-    @property
-    def unchecked(self):
-        """Get all unchecked listitems.
-
-        Returns:
-            list[gkeepapi.node.ListItem]: List items.
-        """
-        return self.sorted_items(self._items(False))
-
-class ListItem(Node):
-    """Represents a Google Keep listitem.
-    Interestingly enough, :class:`Note`s store their content in a single
-    child :class:`ListItem`.
-    """
-    def __init__(self, parent_id=None, parent_server_id=None, super_list_item_id=None, **kwargs):
-        super(ListItem, self).__init__(type_=NodeType.ListItem, parent_id=parent_id, **kwargs)
-        self.parent_item = None
-        self.parent_server_id = parent_server_id
-        self.super_list_item_id = super_list_item_id
-        self.prev_super_list_item_id = None
-        self._subitems = {}
-        self._checked = False
-
-    def _load(self, raw):
-        super(ListItem, self)._load(raw)
-        self.prev_super_list_item_id = self.super_list_item_id
-        self.super_list_item_id = raw.get('superListItemId') or None
-        self._checked = raw.get('checked', False)
-
-    def save(self, clean=True):
-        ret = super(ListItem, self).save(clean)
-        ret['parentServerId'] = self.parent_server_id
-        ret['superListItemId'] = self.super_list_item_id
-        ret['checked'] = self._checked
-        return ret
-
-    def add(self, text, checked=False, sort=None):
-        """Add a new sub item to the list. This item must already be attached to a list.
-
-        Args:
-            text (str): The text.
-            checked (bool): Whether this item is checked.
-            sort (int): Item id for sorting.
-        """
-        if self.parent is None:
-            raise exception.InvalidException('Item has no parent')
-        node = self.parent.add(text, checked, sort)
-        self.indent(node)
-        return node
-
-    def indent(self, node, dirty=True):
-        """Indent an item. Does nothing if the target has subitems.
-
-        Args:
-            node (gkeepapi.node.ListItem): Item to indent.
-            dirty (bool): Whether this node should be marked dirty.
-        """
-        if node.subitems:
-            return
-
-        self._subitems[node.id] = node
-        node.super_list_item_id = self.id
-        node.parent_item = self
-        if dirty:
-            node.touch(True)
-
-    def dedent(self, node, dirty=True):
-        """Dedent an item. Does nothing if the target is not indented under this item.
-
-        Args:
-            node (gkeepapi.node.ListItem): Item to dedent.
-            dirty (bool): Whether this node should be marked dirty.
-        """
-        if node.id not in self._subitems:
-            return
-
-        del self._subitems[node.id]
-        node.super_list_item_id = ""
-        node.parent_item = None
-        if dirty:
-            node.touch(True)
-
-    @property
-    def subitems(self):
-        """Get subitems for this item.
-
-        Returns:
-            list[gkeepapi.node.ListItem]: Subitems.
-        """
-        return List.sorted_items(
-            self._subitems.values()
-        )
-
-    @property
-    def indented(self):
-        """Get indentation state.
-
-        Returns:
-            bool: Whether this item is indented.
-        """
-        return self.parent_item is not None
-
-    @property
-    def checked(self):
-        """Get the checked state.
-
-        Returns:
-            bool: Whether this item is checked.
-        """
-        return self._checked
-
-    @checked.setter
-    def checked(self, value):
-        self._checked = value
-        self.touch(True)
-
-    def __str__(self):
-        return u'%s%s %s' % (
-            '  ' if self.indented else '',
-            u'☑' if self.checked else u'☐',
-            self.text
-        )
-
-class NodeBlob(Element):
-    """Represents a blob descriptor."""
-    _TYPE = None
-    def __init__(self, type_=None):
-        super(NodeBlob, self).__init__()
-        self.blob_id = None
-        self.type = type_
-        self._media_id = None
-        self._mimetype = ''
-        self._is_uploaded = False
-
-    def _load(self, raw):
-        super(NodeBlob, self)._load(raw)
-        # Verify this is a valid type
-        BlobType(raw['type'])
-        self.blob_id = raw.get('blob_id')
-        self._media_id = raw.get('media_id')
-        self._mimetype = raw.get('mimetype')
-
-    def save(self, clean=True):
-        ret = super(NodeBlob, self).save(clean)
-        ret['kind'] = 'notes#blob'
-        ret['type'] = self.type.value
-        if self.blob_id is not None:
-            ret['blob_id'] = self.blob_id
-        if self._media_id is not None:
-            ret['media_id'] = self._media_id
-        ret['mimetype'] = self._mimetype
-        return ret
-
-class NodeAudio(NodeBlob):
-    """Represents an audio blob."""
-    _TYPE = BlobType.Audio
-    def __init__(self):
-        super(NodeAudio, self).__init__(type_=self._TYPE)
-        self._length = None
-
-    def _load(self, raw):
-        super(NodeAudio, self)._load(raw)
-        self._length = raw.get('length')
-
-    def save(self, clean=True):
-        ret = super(NodeAudio, self).save(clean)
-        if self._length is not None:
-            ret['length'] = self._length
-        return ret
-
-    @property
-    def length(self):
-        """Get length of the audio clip.
-        Returns:
-            int: Audio length.
-        """
-        return self._length
-
-class NodeImage(NodeBlob):
-    """Represents an image blob."""
-    _TYPE = BlobType.Image
-    def __init__(self):
-        super(NodeImage, self).__init__(type_=self._TYPE)
-        self._is_uploaded = False
-        self._width = 0
-        self._height = 0
-        self._byte_size = 0
-        self._extracted_text = ''
-        self._extraction_status = ''
-
-    def _load(self, raw):
-        super(NodeImage, self)._load(raw)
-        self._is_uploaded = raw.get('is_uploaded') or False
-        self._width = raw.get('width')
-        self._height = raw.get('height')
-        self._byte_size = raw.get('byte_size')
-        self._extracted_text = raw.get('extracted_text')
-        self._extraction_status = raw.get('extraction_status')
-
-    def save(self, clean=True):
-        ret = super(NodeImage, self).save(clean)
-        ret['width'] = self._width
-        ret['height'] = self._height
-        ret['byte_size'] = self._byte_size
-        ret['extracted_text'] = self._extracted_text
-        ret['extraction_status'] = self._extraction_status
-        return ret
-
-    @property
-    def width(self):
-        """Get width of image.
-        Returns:
-            int: Image width.
-        """
-        return self._width
-
-    @property
-    def height(self):
-        """Get height of image.
-        Returns:
-            int: Image height.
-        """
-        return self._height
-
-    @property
-    def byte_size(self):
-        """Get size of image in bytes.
-        Returns:
-            int: Image byte size.
-        """
-        return self._byte_size
-
-    @property
-    def extracted_text(self):
-        """Get text extracted from image
-        Returns:
-            str: Extracted text.
-        """
-        return self._extracted_text
-
-    @property
-    def url(self):
-        """Get a url to the image.
-        Returns:
-            str: Image url.
-        """
-        raise NotImplementedError()
-
-class NodeDrawing(NodeBlob):
-    """Represents a drawing blob."""
-    _TYPE = BlobType.Drawing
-    def __init__(self):
-        super(NodeDrawing, self).__init__(type_=self._TYPE)
-        self._extracted_text = ''
-        self._extraction_status = ''
-        self._drawing_info = None
-
-    def _load(self, raw):
-        super(NodeDrawing, self)._load(raw)
-        self._extracted_text = raw.get('extracted_text')
-        self._extraction_status = raw.get('extraction_status')
-        drawing_info = None
-        if 'drawingInfo' in raw:
-            drawing_info = NodeDrawingInfo()
-            drawing_info.load(raw['drawingInfo'])
-        self._drawing_info = drawing_info
-
-    def save(self, clean=True):
-        ret = super(NodeDrawing, self).save(clean)
-        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)
-        return ret
-
-    @property
-    def extracted_text(self):
-        """Get text extracted from image
-        Returns:
-            str: Extracted text.
-        """
-        return self._drawing_info.snapshot.extracted_text \
-            if self._drawing_info is not None else ''
-
-class NodeDrawingInfo(Element):
-    """Represents information about a drawing blob."""
-    def __init__(self):
-        super(NodeDrawingInfo, self).__init__()
-        self.drawing_id = ''
-        self.snapshot = NodeImage()
-        self._snapshot_fingerprint = ''
-        self._thumbnail_generated_time = NodeTimestamps.int_to_dt(0)
-        self._ink_hash = ''
-        self._snapshot_proto_fprint = ''
-
-    def _load(self, raw):
-        super(NodeDrawingInfo, self)._load(raw)
-        self.drawing_id = raw['drawingId']
-        self.snapshot.load(raw['snapshotData'])
-        self._snapshot_fingerprint = raw['snapshotFingerprint'] if 'snapshotFingerprint' in raw else self._snapshot_fingerprint
-        self._thumbnail_generated_time = NodeTimestamps.str_to_dt(raw['thumbnailGeneratedTime']) if 'thumbnailGeneratedTime' in raw else NodeTimestamps.int_to_dt(0)
-        self._ink_hash = raw['inkHash'] if 'inkHash' in raw else ''
-        self._snapshot_proto_fprint = raw['snapshotProtoFprint'] if 'snapshotProtoFprint' in raw else self._snapshot_proto_fprint
-
-    def save(self, clean=True):
-        ret = super(NodeDrawingInfo, self).save(clean)
-        ret['drawingId'] = self.drawing_id
-        ret['snapshotData'] = self.snapshot.save(clean)
-        ret['snapshotFingerprint'] = self._snapshot_fingerprint
-        ret['thumbnailGeneratedTime'] = NodeTimestamps.dt_to_str(self._thumbnail_generated_time)
-        ret['inkHash'] = self._ink_hash
-        ret['snapshotProtoFprint'] = self._snapshot_proto_fprint
-        return ret
-
-class Blob(Node):
-    """Represents a Google Keep blob."""
-    _blob_type_map = {
-        BlobType.Audio: NodeAudio,
-        BlobType.Image: NodeImage,
-        BlobType.Drawing: NodeDrawing,
-    }
-
-    def __init__(self, parent_id=None, **kwargs):
-        super(Blob, self).__init__(type_=NodeType.Blob, parent_id=parent_id, **kwargs)
-        self.blob = None
-
-    @classmethod
-    def from_json(cls, raw):
-        """Helper to construct a blob from a dict.
-
-        Args:
-            raw (dict): Raw blob representation.
-
-        Returns:
-            NodeBlob: A NodeBlob object or None.
-        """
-        if raw is None:
-            return None
-
-        _type = raw.get('type')
-        if _type is None:
-            return None
-
-        bcls = None
-        try:
-            bcls = cls._blob_type_map[BlobType(_type)]
-        except (KeyError, ValueError) as e:
-            logger.warning('Unknown blob type: %s', _type)
-            if DEBUG: # pragma: no cover
-                raise_from(exception.ParseException('Parse error for %s' % (_type), raw), e)
-            return None
-        blob = bcls()
-        blob.load(raw)
-
-        return blob
-
-    def _load(self, raw):
-        super(Blob, self)._load(raw)
-        self.blob = self.from_json(raw.get('blob'))
-
-    def save(self, clean=True):
-        ret = super(Blob, self).save(clean)
-        if self.blob is not None:
-            ret['blob'] = self.blob.save(clean)
-        return ret
-
-class Label(Element, TimestampsMixin):
-    """Represents a label."""
-    def __init__(self):
-        super(Label, self).__init__()
-
-        create_time = time.time()
-
-        self.id = self._generateId(create_time)
-        self._name = ''
-        self.timestamps = NodeTimestamps(create_time)
-        self._merged = NodeTimestamps.int_to_dt(0)
-
-    @classmethod
-    def _generateId(cls, tz):
-        return 'tag.%s.%x' % (
-            ''.join([random.choice('abcdefghijklmnopqrstuvwxyz0123456789') for _ in range(12)]),
-            int(tz * 1000)
-        )
-
-    def _load(self, raw):
-        super(Label, self)._load(raw)
-        self.id = raw['mainId']
-        self._name = raw['name']
-        self.timestamps.load(raw['timestamps'])
-        self._merged = NodeTimestamps.str_to_dt(raw['lastMerged']) if 'lastMerged' in raw else NodeTimestamps.int_to_dt(0)
-
-    def save(self, clean=True):
-        ret = super(Label, self).save(clean)
-        ret['mainId'] = self.id
-        ret['name'] = self._name
-        ret['timestamps'] = self.timestamps.save(clean)
-        ret['lastMerged'] = NodeTimestamps.dt_to_str(self._merged)
-        return ret
-
-    @property
-    def name(self):
-        """Get the label name.
-
-        Returns:
-            str: Label name.
-        """
-        return self._name
-
-    @name.setter
-    def name(self, value):
-        self._name = value
-        self.touch(True)
-
-    @property
-    def merged(self):
-        """Get last merge datetime.
-
-        Returns:
-            datetime: Datetime.
-        """
-        return self._merged
-
-    @merged.setter
-    def merged(self, value):
-        self._merged = value
-        self.touch()
-
-    @property
-    def dirty(self):
-        return super(Label, self).dirty or self.timestamps.dirty
-
-    def __str__(self):
-        return self.name
-
-_type_map = {
-    NodeType.Note: Note,
-    NodeType.List: List,
-    NodeType.ListItem: ListItem,
-    NodeType.Blob: Blob,
-}
-
-def from_json(raw):
-    """Helper to construct a node from a dict.
-
-    Args:
-        raw (dict): Raw node representation.
-
-    Returns:
-        Node: A Node object or None.
-    """
-    ncls = None
-    _type = raw.get('type')
-    try:
-        ncls = _type_map[NodeType(_type)]
-    except (KeyError, ValueError) as e:
-        logger.warning('Unknown node type: %s', _type)
-        if DEBUG: # pragma: no cover
-            raise_from(exception.ParseException('Parse error for %s' % (_type), raw), e)
-        return None
-    node = ncls()
-    node.load(raw)
-
-    return node
-
-if DEBUG: # pragma: no cover
-    Node.__load = Node._load # pylint: disable=protected-access
-    def _load(self, raw): # pylint: disable=missing-docstring
-        self.__load(raw) # pylint: disable=protected-access
-        self._find_discrepancies(raw) # pylint: disable=protected-access
-    Node._load = _load # pylint: disable=protected-access
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..d6bebae
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,124 @@
+[build-system]
+requires = [
+    "flit_core >=3.2,<4",
+]
+build-backend = "flit_core.buildapi"
+
+[project]
+name = "gkeepapi"
+version = "0.15.0"
+authors = [
+  { name="Kai", email="z@kwi.li" },
+]
+description = "An unofficial Google Keep API client"
+readme = "README.md"
+requires-python = ">=3.10"
+classifiers = [
+  "Programming Language :: Python :: 3",
+  "License :: OSI Approved :: MIT License",
+  "Development Status :: 4 - Beta",
+  "Operating System :: OS Independent",
+  "Intended Audience :: Developers",
+  "Topic :: Internet :: WWW/HTTP",
+  "Topic :: Software Development :: Libraries :: Python Modules",
+]
+dependencies = [
+  "gpsoauth >= 1.0.3",
+  "future >= 0.16.0",
+]
+
+[project.optional-dependencies]
+dev = [
+  "black>=23.3.0",
+  "ruff>=0.0.284",
+  "coverage>=7.2.5",
+]
+
+[project.urls]
+"Homepage" = "https://github.com/kiwiz/gkeepapi"
+"Bug Tracker" = "https://github.com/kiwiz/gkeepapi/issues"
+
+
+[tool.black]
+target-version = ["py310"]
+
+[tool.ruff]
+target-version = "py310"
+select = [
+  # https://beta.ruff.rs/docs/rules/
+  "F",  # pyflakes
+  "E",  # pycodestyle error
+  "W",  # pycodestyle warning
+  "C90",  # mccabe
+  "I",  # isort
+  "N",  # pep8-naming
+  "D",  # pydocstyle
+  "UP",  # pyupgrade
+  "YTT",  # flake8-2020
+  "ANN",  # flake8-annotations
+  "ASYNC",  # flake8-async
+  "S",  # flake8-bandit
+  "BLE",  # flake8-blind-except
+  # "FBT",  # flake8-boolean-trap
+  "B",  # flake8-bugbear
+  "A",  # flake8-builtins
+  "COM",  # flake8-commas
+  "CPY",  # flake8-copyright
+  "C4",  # flake8-comprehensions
+  "DTZ",  # flake8-datetimez
+  "T10",  # flake8-debugger
+  "DJ",  # flake8-django
+  "EM",  # flake8-errmsg
+  "EXE",  # flake8-executable
+  "FA",  # flake8-future-annotations
+  "ISC",  # flake8-implicit-str-concat
+  "ICN",  # flake8-import-conventions
+  "G",  # flake8-logging-format
+  "INP",  # flake8-no-pep420
+  "PIE",  # flake8-pie
+  "T20",  # flake8-print
+  "PYI",  # flake8-pyi
+  "PT",  # flake8-pytest-style
+  "Q",  # flake8-quotes
+  "RSE",  # flake8-raise
+  "RET",  # flake8-return
+  "SLF",  # flake8-self
+  "SLOT",  # flake8-slots
+  "SIM",  # flake8-simplify
+  "TID",  # flake8-tidy-imports
+  "TCH",  # flake8-type-checking
+  "INT",  # flake8-gettext
+  "ARG",  # flake8-unused-arguments
+  "PTH",  # flake8-use-pathlib
+  # "TD",  # flake-todos
+  # "FIX",  # flake-fixme
+  # "ERA",  # eradicate
+  "PD",  # pandas-vet
+  "PGH",  # pygrep-hooks
+  "PLC",  # pylint convention
+  "PLE",  # pylint error
+  "PLR",  # pylint refactor
+  "PLW",  # pylint warning
+  # "TRY",  # tryceratops
+  "FLY",  # flynt
+  "NPY",  # numpy-specific
+  "AIR",  # airflow-specific
+  "PERF",  # performance
+  "RUF",  # ruff-specific
+]
+ignore = [
+  "E501",  # line-too-long -- disabled as black takes care of this
+  "COM812",  # missing-trailing-comma -- conflicts with black?
+  "N802",  # invalid-function-name -- too late!
+  "N818",  # error-suffix-on-exception-name -- too late!
+  "D415",  # ends-in-punctuation -- too aggressive
+  "EM101",  # raw-string-in-exception -- no thanks
+  "EM102",  # f-string-in-exception -- no thanks
+  "PLR0913",  # too-many-arguments -- no thanks
+  "D105",  # undocumented-magic-method -- no thanks
+  "ANN101",  # missing-type-self -- unnecessary
+  "ANN102",  # missing-type-cls -- unnecessary
+]
+
+[tool.ruff.pydocstyle]
+convention = "google"
diff --git a/requirements.txt b/requirements.txt
deleted file mode 100644
index c8df5a5..0000000
--- a/requirements.txt
+++ /dev/null
@@ -1,4 +0,0 @@
-gpsoauth>=1.0.2
-future>=0.16.0
-mock>=3.0.5
-enum34>=1.1.6; python_version < '3.4'
diff --git a/setup.cfg b/setup.cfg
deleted file mode 100644
index 3c6e79c..0000000
--- a/setup.cfg
+++ /dev/null
@@ -1,2 +0,0 @@
-[bdist_wheel]
-universal=1
diff --git a/setup.py b/setup.py
deleted file mode 100644
index ac234b3..0000000
--- a/setup.py
+++ /dev/null
@@ -1,58 +0,0 @@
-# Always prefer setuptools over distutils
-from setuptools import setup, find_packages
-
-# To use a consistent encoding
-from codecs import open
-from os import path
-
-import gkeepapi
-
-here = path.abspath(path.dirname(__file__))
-
-setup(
-    name="gkeepapi",
-    # Versions should comply with PEP440.  For a discussion on single-sourcing
-    # the version across setup.py and the project code, see
-    # https://packaging.python.org/en/latest/single_source_version.html
-    version=gkeepapi.__version__,
-    description="An unofficial Google Keep API client",
-    # The project's main homepage.
-    url="https://github.com/kiwiz/gkeepapi",
-    # Author details
-    author="Kai",
-    author_email="z@kwi.li",
-    # Choose your license
-    license="MIT",
-    # See https://pypi.python.org/pypi?%3Aaction=list_classifiers
-    classifiers=[
-        # How mature is this project? Common values are
-        #   3 - Alpha
-        #   4 - Beta
-        #   5 - Production/Stable
-        "Development Status :: 4 - Beta",
-        # Indicate who your project is intended for
-        "Intended Audience :: Developers",
-        "Operating System :: OS Independent",
-        "Topic :: Internet :: WWW/HTTP",
-        "Topic :: Software Development :: Libraries :: Python Modules",
-        # Pick your license as you wish (should match "license" above)
-        "License :: OSI Approved :: MIT License",
-        # Specify the Python versions you support here. In particular, ensure
-        # that you indicate whether you support Python 2, Python 3 or both.
-        "Programming Language :: Python :: 3",
-    ],
-    # What does your project relate to?
-    keywords="google keep api",
-    # You can just specify the packages manually here if your project is
-    # simple. Or you can use find_packages().
-    packages=find_packages(exclude=["docs", "tests"]),
-    # List run-time dependencies here.  These will be installed by pip when
-    # your project is installed. For an analysis of "install_requires" vs pip's
-    # requirements files see:
-    # https://packaging.python.org/en/latest/requirements.html
-    install_requires=[
-        "gpsoauth >= 1.0.2",
-        "future >= 0.16.0",
-        "enum34 >= 1.1.6; python_version < '3.4'",
-    ],
-)
diff --git a/gkeepapi/__init__.py b/src/gkeepapi/__init__.py
similarity index 75%
rename from gkeepapi/__init__.py
rename to src/gkeepapi/__init__.py
index f2f500c..8629c16 100644
--- a/gkeepapi/__init__.py
+++ b/src/gkeepapi/__init__.py
@@ -1,48 +1,44 @@
-# -*- coding: utf-8 -*-
-"""
-.. moduleauthor:: Kai <z@kwi.li>
-"""
+""".. moduleauthor:: Kai <z@kwi.li>"""
 
-__version__ = "0.14.2"
+__version__ = "0.15.0"
 
+import datetime
+import http
 import logging
+import random
 import re
 import time
-import random
-
+from collections.abc import Callable, Iterator
+from typing import Any
 from uuid import getnode as get_mac
 
 import gpsoauth
 import requests
 
-from . import node as _node
 from . import exception
+from . import node as _node
 
 logger = logging.getLogger(__name__)
 
-try:
-    Pattern = re._pattern_type  # pylint: disable=protected-access
-except AttributeError:
-    Pattern = re.Pattern  # pylint: disable=no-member
-
 
-class APIAuth(object):
+class APIAuth:
     """Authentication token manager"""
 
-    def __init__(self, scopes):
+    def __init__(self, scopes: str) -> None:
+        """Construct API authentication manager"""
         self._master_token = None
         self._auth_token = None
         self._email = None
         self._device_id = None
         self._scopes = scopes
 
-    def login(self, email, password, device_id):
+    def login(self, email: str, password: str, device_id: str) -> None:
         """Authenticate to Google with the provided credentials.
 
         Args:
-            email (str): The account to use.
-            password (str): The account password.
-            device_id (str): An identifier for this client.
+            email: The account to use.
+            password: The account password.
+            device_id: An identifier for this client.
 
         Raises:
             LoginException: If there was a problem logging in.
@@ -65,15 +61,14 @@ def login(self, email, password, device_id):
 
         # Obtain an OAuth token.
         self.refresh()
-        return True
 
-    def load(self, email, master_token, device_id):
+    def load(self, email: str, master_token: str, device_id: str) -> bool:
         """Authenticate to Google with the provided master token.
 
         Args:
-            email (str): The account to use.
-            master_token (str): The master token.
-            device_id (str): An identifier for this client.
+            email: The account to use.
+            master_token: The master token.
+            device_id: An identifier for this client.
 
         Raises:
             LoginException: If there was a problem logging in.
@@ -86,69 +81,67 @@ def load(self, email, master_token, device_id):
         self.refresh()
         return True
 
-    def getMasterToken(self):
+    def getMasterToken(self) -> str:
         """Gets the master token.
 
         Returns:
-            str: The account master token.
+            The account master token.
         """
         return self._master_token
 
-    def setMasterToken(self, master_token):
-        """Sets the master token. This is useful if you'd like to authenticate
-        with the API without providing your username & password.
-        Do note that the master token has full access to your account.
+    def setMasterToken(self, master_token: str) -> None:
+        """Sets the master token. This is useful if you'd like to authenticate with the API without providing your username & password. Do note that the master token has full access to your account.
 
         Args:
-            master_token (str): The account master token.
+            master_token: The account master token.
         """
         self._master_token = master_token
 
-    def getEmail(self):
+    def getEmail(self) -> str:
         """Gets the account email.
 
         Returns:
-            str: The account email.
+            The account email.
         """
         return self._email
 
-    def setEmail(self, email):
+    def setEmail(self, email: str) -> None:
         """Sets the account email.
 
         Args:
-            email (str): The account email.
+            email: The account email.
         """
         self._email = email
 
-    def getDeviceId(self):
+    def getDeviceId(self) -> str:
         """Gets the device id.
 
         Returns:
-            str: The device id.
+            The device id.
         """
         return self._device_id
 
-    def setDeviceId(self, device_id):
+    def setDeviceId(self, device_id: str) -> None:
         """Sets the device id.
 
         Args:
-            device_id (str): The device id.
+            device_id: The device id.
         """
         self._device_id = device_id
 
-    def getAuthToken(self):
+    def getAuthToken(self) -> str | None:
         """Gets the auth token.
 
         Returns:
-            Union[str, None]: The auth token.
+            The auth token.
         """
         return self._auth_token
 
-    def refresh(self):
+    def refresh(self) -> str:
         """Refresh the OAuth token.
 
         Returns:
-            string: The auth token.
+            The auth token.
 
         Raises:
             LoginException: If there was a problem refreshing the OAuth token.
@@ -164,14 +157,13 @@ def refresh(self):
             client_sig="38918a453d07199354f8b19af05ec6562ced5788",
         )
         # Bail if no token was returned.
-        if "Auth" not in res:
-            if "Token" not in res:
-                raise exception.LoginException(res.get("Error"))
+        if "Auth" not in res and "Token" not in res:
+            raise exception.LoginException(res.get("Error"))
 
         self._auth_token = res["Auth"]
         return self._auth_token
 
-    def logout(self):
+    def logout(self) -> None:
         """Log out of the account."""
         self._master_token = None
         self._auth_token = None
@@ -179,12 +171,13 @@ def logout(self):
         self._device_id = None
 
 
-class API(object):
+class API:
     """Base API wrapper"""
 
     RETRY_CNT = 2
 
-    def __init__(self, base_url, auth=None):
+    def __init__(self, base_url: str, auth: APIAuth | None = None) -> None:
+        """Construct a low-level API client"""
         self._session = requests.Session()
         self._auth = auth
         self._base_url = base_url
@@ -195,31 +188,30 @@ def __init__(self, base_url, auth=None):
             }
         )
 
-    def getAuth(self):
+    def getAuth(self) -> APIAuth:
         """Get authentication details for this API.
 
         Return:
-            auth (APIAuth): The auth object
+            auth: The auth object
         """
         return self._auth
 
-    def setAuth(self, auth):
+    def setAuth(self, auth: APIAuth) -> None:
         """Set authentication details for this API.
 
         Args:
-            auth (APIAuth): The auth object
+            auth: The auth object
         """
         self._auth = auth
 
-    def send(self, **req_kwargs):
-        """Send an authenticated request to a Google API.
-        Automatically retries if the access token has expired.
+    def send(self, **req_kwargs: dict) -> dict:
+        """Send an authenticated request to a Google API. Automatically retries if the access token has expired.
 
         Args:
             **req_kwargs: Arbitrary keyword arguments to pass to Requests.
 
         Return:
-            dict: The parsed JSON response.
+            The parsed JSON response.
 
         Raises:
             APIException: If the server returns an error.
@@ -237,7 +229,7 @@ def send(self, **req_kwargs):
             # Otherwise, check if it was a non-401 response code. These aren't
             # handled, so bail.
             error = response["error"]
-            if error["code"] != 401:
+            if error["code"] != http.HTTPStatus.UNAUTHORIZED:
                 raise exception.APIException(error["code"], error)
 
             # If we've exceeded the retry limit, also bail.
@@ -251,14 +243,14 @@ def send(self, **req_kwargs):
 
         return response
 
-    def _send(self, **req_kwargs):
+    def _send(self, **req_kwargs: dict) -> requests.Response:
         """Send an authenticated request to a Google API.
 
         Args:
             **req_kwargs: Arbitrary keyword arguments to pass to Requests.
 
         Return:
-            requests.Response: The raw response.
+            The raw response.
 
         Raises:
             LoginException: If :py:meth:`login` has not been called.
@@ -282,26 +274,35 @@ class KeepAPI(API):
 
     API_URL = "https://www.googleapis.com/notes/v1/"
 
-    def __init__(self, auth=None):
-        super(KeepAPI, self).__init__(self.API_URL, auth)
+    def __init__(self, auth: APIAuth | None = None) -> None:
+        """Construct a low-level Google Keep API client"""
+        super().__init__(self.API_URL, auth)
 
         create_time = time.time()
         self._session_id = self._generateId(create_time)
 
     @classmethod
-    def _generateId(cls, tz):
-        return "s--%d--%d" % (int(tz * 1000), random.randint(1000000000, 9999999999))
+    def _generateId(cls, tz: int) -> str:
+        return "s--%d--%d" % (
+            int(tz * 1000),
+            random.randint(1000000000, 9999999999),  # noqa: S311
+        )
 
-    def changes(self, target_version=None, nodes=None, labels=None):
+    def changes(
+        self,
+        target_version: str | None = None,
+        nodes: list[dict] | None = None,
+        labels: list[dict] | None = None,
+    ) -> dict:
         """Sync up (and down) all changes.
 
         Args:
-            target_version (str): The local change version.
-            nodes (List[dict]): A list of nodes to sync up to the server.
-            labels (List[dict]): A list of labels to sync up to the server.
+            target_version: The local change version.
+            nodes: A list of nodes to sync up to the server.
+            labels: A list of labels to sync up to the server.
 
         Return:
-            dict: Description of all changes.
+            Description of all changes.
 
         Raises:
             APIException: If the server returns an error.
@@ -369,21 +370,22 @@ class MediaAPI(API):
 
     API_URL = "https://keep.google.com/media/v2/"
 
-    def __init__(self, auth=None):
-        super(MediaAPI, self).__init__(self.API_URL, auth)
+    def __init__(self, auth: APIAuth | None = None) -> None:
+        """Construct a low-level Google Media API client"""
+        super().__init__(self.API_URL, auth)
 
-    def get(self, blob):
+    def get(self, blob: _node.Blob) -> str:
         """Get the canonical link to a media blob.
 
         Args:
-            blob (gkeepapi.node.Blob): The blob.
+            blob: The blob.
 
         Returns:
-            str: A link to the media.
+            A link to the media.
         """
         url = self._base_url + blob.parent.server_id + "/" + blob.server_id
         if blob.blob.type == _node.BlobType.Drawing:
-            url += "/" + blob.blob._drawing_info.drawing_id
+            url += "/" + blob.blob._drawing_info.drawing_id  # noqa: SLF001
         return self._send(url=url, method="GET", allow_redirects=False).headers[
             "location"
         ]
@@ -397,8 +399,9 @@ class RemindersAPI(API):
 
     API_URL = "https://www.googleapis.com/reminders/v1internal/reminders/"
 
-    def __init__(self, auth=None):
-        super(RemindersAPI, self).__init__(self.API_URL, auth)
+    def __init__(self, auth: APIAuth | None = None) -> None:
+        """Construct a low-level Google Reminders API client"""
+        super().__init__(self.API_URL, auth)
         self.static_params = {
             "taskList": [
                 {"systemListId": "MEMENTO"},
@@ -415,20 +418,21 @@ def __init__(self, auth=None):
             },
         }
 
-    def create(self, node_id, node_server_id, dtime):
+    def create(
+        self, node_id: str, node_server_id: str, dtime: datetime.datetime
+    ) -> Any:  # noqa: ANN401
         """Create a new reminder.
 
         Args:
-            node_id (str): The note ID.
-            node_server_id (str): The note server ID.
-            dtime (datetime.datetime): The due date of this reminder.
+            node_id: The note ID.
+            node_server_id: The note server ID.
+            dtime: The due date of this reminder.
 
         Return: ???
 
         Raises:
             APIException: If the server returns an error.
         """
-
         params = {}
         params.update(self.static_params)
 
@@ -462,13 +466,15 @@ def create(self, node_id, node_server_id, dtime):
 
         return self.send(url=self._base_url + "create", method="POST", json=params)
 
-    def update(self, node_id, node_server_id, dtime):
+    def update_internal(
+        self, node_id: str, node_server_id: str, dtime: datetime.datetime
+    ) -> Any:  # noqa: ANN401
         """Update an existing reminder.
 
         Args:
-            node_id (str): The note ID.
-            node_server_id (str): The note server ID.
-            dtime (datetime.datetime): The due date of this reminder.
+            node_id: The note ID.
+            node_server_id: The note server ID.
+            dtime: The due date of this reminder.
 
         Return: ???
 
@@ -517,18 +523,17 @@ def update(self, node_id, node_server_id, dtime):
 
         return self.send(url=self._base_url + "update", method="POST", json=params)
 
-    def delete(self, node_server_id):
+    def delete(self, node_server_id: str) -> Any:  # noqa: ANN401
         """Delete an existing reminder.
 
         Args:
-            node_server_id (str): The note server ID.
+            node_server_id: The note server ID.
 
         Return: ???
 
         Raises:
             APIException: If the server returns an error.
         """
-
         params = {}
         params.update(self.static_params)
 
@@ -548,11 +553,11 @@ def delete(self, node_server_id):
 
         return self.send(url=self._base_url + "batchmutate", method="POST", json=params)
 
-    def list(self, master=True):
+    def list(self, master: bool = True) -> Any:  # noqa: ANN401, A003
         """List current reminders.
 
         Args:
-            master (bool): ???
+            master: ???
 
         Return:
             ???
@@ -595,11 +600,11 @@ def list(self, master=True):
 
         return self.send(url=self._base_url + "list", method="POST", json=params)
 
-    def history(self, storage_version):
+    def history(self, storage_version: str) -> Any:  # noqa: ANN401
         """Get reminder changes.
 
         Args:
-            storage_version (str): The local storage version.
+            storage_version: The local storage version.
 
         Returns:
             ???
@@ -615,13 +620,13 @@ def history(self, storage_version):
 
         return self.send(url=self._base_url + "history", method="POST", json=params)
 
-    def update(self):
+    def update(self) -> Any:  # noqa: ANN401
         """Sync up changes to reminders."""
         params = {}
         return self.send(url=self._base_url + "update", method="POST", json=params)
 
 
-class Keep(object):
+class Keep:
     """High level Google Keep client.
 
     Stores a local copy of the Keep node tree. To start, first login::
@@ -648,7 +653,8 @@ class Keep(object):
 
     OAUTH_SCOPES = "oauth2:https://www.googleapis.com/auth/memento https://www.googleapis.com/auth/reminders"
 
-    def __init__(self):
+    def __init__(self) -> None:
+        """Construct a Google Keep client"""
         self._keep_api = KeepAPI()
         self._reminders_api = RemindersAPI()
         self._media_api = MediaAPI()
@@ -660,7 +666,7 @@ def __init__(self):
 
         self._clear()
 
-    def _clear(self):
+    def _clear(self) -> None:
         self._keep_version = None
         self._reminder_version = None
         self._labels = {}
@@ -672,70 +678,73 @@ def _clear(self):
 
     def login(
         self,
-        email,
-        password,
-        state=None,
-        sync=True,
-        device_id=None,
-    ):
+        email: str,
+        password: str,
+        state: dict | None = None,
+        sync: bool = True,
+        device_id: str | None = None,
+    ) -> None:
         """Authenticate to Google with the provided credentials & sync.
 
         Args:
-            email (str): The account to use.
-            password (str): The account password.
-            state (dict): Serialized state to load.
-            sync (bool): Whether to sync after login.
-            device_id (str): Override for device ID
+            email: The account to use.
+            password: The account password.
+            state: Serialized state to load.
+            sync: Whether to sync data.
+            device_id: Device id.
 
         Raises:
             LoginException: If there was a problem logging in.
         """
         auth = APIAuth(self.OAUTH_SCOPES)
         if device_id is None:
-            device_id = get_mac()
+            device_id = f"{get_mac():x}"
 
-        ret = auth.login(email, password, device_id)
-        if ret:
-            self.load(auth, state, sync)
+        auth.login(email, password, device_id)
+        self.load(auth, state, sync)
 
-        return ret
-
-    def resume(self, email, master_token, state=None, sync=True, device_id=None):
+    def resume(
+        self,
+        email: str,
+        master_token: str,
+        state: dict | None = None,
+        sync: bool = True,
+        device_id: str | None = None,
+    ) -> None:
         """Authenticate to Google with the provided master token & sync.
 
         Args:
-            email (str): The account to use.
-            master_token (str): The master token.
-            state (dict): Serialized state to load.
-            sync (bool): Whether to sync after login.
-            device_id (str): Override for device ID
+            email: The account to use.
+            master_token: The master token.
+            state: Serialized state to load.
+            sync: Whether to sync data.
+            device_id: Device id.
 
         Raises:
             LoginException: If there was a problem logging in.
         """
         auth = APIAuth(self.OAUTH_SCOPES)
         if device_id is None:
-            device_id = get_mac()
-
-        ret = auth.load(email, master_token, device_id)
-        if ret:
-            self.load(auth, state, sync)
+            device_id = f"{get_mac():x}"
 
-        return ret
+        auth.load(email, master_token, device_id)
+        self.load(auth, state, sync)
 
-    def getMasterToken(self):
+    def getMasterToken(self) -> str:
         """Get master token for resuming.
 
         Returns:
-            str: The master token.
+            The master token.
         """
         return self._keep_api.getAuth().getMasterToken()
 
-    def load(self, auth, state=None, sync=True):
+    def load(self, auth: APIAuth, state: dict | None = None, sync: bool = True) -> None:
         """Authenticate to Google with a prepared authentication object & sync.
+
         Args:
-            auth (APIAuth): Authentication object.
-            state (dict): Serialized state to load.
+            auth: Authentication object.
+            state: Serialized state to load.
+            sync: Whether to sync data.
 
         Raises:
             LoginException: If there was a problem logging in.
@@ -748,59 +757,56 @@ def load(self, auth, state=None, sync=True):
         if sync:
             self.sync(True)
 
-    def dump(self):
+    def dump(self) -> dict:
         """Serialize note data.
 
-        Args:
-            state (dict): Serialized state to load.
+        Returns:
+            Serialized state.
         """
         # Find all nodes manually, as the Keep object isn't aware of new
         # ListItems until they've been synced to the server.
         nodes = []
         for node in self.all():
             nodes.append(node)
-            for child in node.children:
-                nodes.append(child)
+            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],
         }
 
-    def restore(self, state):
+    def restore(self, state: dict) -> None:
         """Unserialize saved note data.
 
         Args:
-            state (dict): Serialized state to load.
+            state: Serialized state to load.
         """
         self._clear()
         self._parseUserInfo({"labels": state["labels"]})
         self._parseNodes(state["nodes"])
         self._keep_version = state["keep_version"]
 
-    def get(self, node_id):
+    def get(self, node_id: str) -> _node.TopLevelNode:
         """Get a note with the given ID.
 
         Args:
-            node_id (str): The note ID.
+            node_id: The note ID.
 
         Returns:
-            gkeepapi.node.TopLevelNode: The Note or None if not found.
+            The Note or None if not found.
         """
         return self._nodes[_node.Root.ID].get(node_id) or self._nodes[
             _node.Root.ID
         ].get(self._sid_map.get(node_id))
 
-    def add(self, node):
-        """Register a top level node (and its children) for syncing up to the server. There's no need to call this for nodes created by
-        :py:meth:`createNote` or :py:meth:`createList` as they are automatically added.
+    def add(self, node: _node.Node) -> None:
+        """Register a top level node (and its children) for syncing up to the server. There's no need to call this for nodes created by :py:meth:`createNote` or :py:meth:`createList` as they are automatically added.
 
-            LoginException: If :py:meth:`login` has not been called.
         Args:
-            node (gkeepapi.node.Node): The node to sync.
+            node: The node to sync.
 
         Raises:
-            Invalid: If the parent node is not found.
+            InvalidException: If the parent node is not found.
         """
         if node.parent_id != _node.Root.ID:
             raise exception.InvalidException("Not a top level node")
@@ -810,27 +816,27 @@ def add(self, node):
 
     def find(
         self,
-        query=None,
-        func=None,
-        labels=None,
-        colors=None,
-        pinned=None,
-        archived=None,
-        trashed=False,
-    ):  # pylint: disable=too-many-arguments
+        query: re.Pattern | str | None = None,
+        func: Callable | None = None,
+        labels: list[str] | None = None,
+        colors: list[str] | None = None,
+        pinned: bool | None = None,
+        archived: bool | None = None,
+        trashed: bool = False,
+    ) -> Iterator[_node.TopLevelNode]:
         """Find Notes based on the specified criteria.
 
         Args:
-            query (Union[_sre.SRE_Pattern, str, None]): A str or regular expression to match against the title and text.
-            func (Union[callable, None]): A filter function.
-            labels (Union[List[str], None]): A list of label ids or objects to match. An empty list matches notes with no labels.
-            colors (Union[List[str], None]): A list of colors to match.
-            pinned (Union[bool, None]): Whether to match pinned notes.
-            archived (Union[bool, None]): Whether to match archived notes.
-            trashed (Union[bool, None]): Whether to match trashed notes.
+            query: A str or regular expression to match against the title and text.
+            func: A filter function.
+            labels: A list of label ids or objects to match. An empty list matches notes with no labels.
+            colors: A list of colors to match.
+            pinned: Whether to match pinned notes.
+            archived: Whether to match archived notes.
+            trashed: Whether to match trashed notes.
 
         Return:
-            Generator[gkeepapi.node.TopLevelNode]: Results.
+            Search results.
         """
         if labels is not None:
             labels = [i.id if isinstance(i, _node.Label) else i for i in labels]
@@ -848,7 +854,7 @@ def find(
                         and (query in node.title or query in node.text)
                     )
                     or (
-                        isinstance(query, Pattern)
+                        isinstance(query, re.Pattern)
                         and (query.search(node.title) or query.search(node.text))
                     )
                 )
@@ -859,7 +865,7 @@ def find(
             and (  # Process the labels.
                 labels is None
                 or (not labels and not node.labels.all())
-                or (any((node.labels.get(i) is not None for i in labels)))
+                or (any(node.labels.get(i) is not None for i in labels))
             )
             and (colors is None or node.color in colors)  # Process the colors.
             and (pinned is None or node.pinned == pinned)  # Process the pinned state.
@@ -869,15 +875,17 @@ def find(
             and (trashed is None or node.trashed == trashed)  # Process the trash state.
         )
 
-    def createNote(self, title=None, text=None):
+    def createNote(
+        self, title: str | None = None, text: str | None = None
+    ) -> _node.Node:
         """Create a new managed note. Any changes to the note will be uploaded when :py:meth:`sync` is called.
 
         Args:
-            title (str): The title of the note.
-            text (str): The text of the note.
+            title: The title of the note.
+            text: The text of the note.
 
         Returns:
-            gkeepapi.node.List: The new note.
+            The new note.
         """
         node = _node.Note()
         if title is not None:
@@ -887,15 +895,19 @@ def createNote(self, title=None, text=None):
         self.add(node)
         return node
 
-    def createList(self, title=None, items=None):
+    def createList(
+        self,
+        title: str | None = None,
+        items: list[tuple[str, bool]] | None = None,
+    ) -> _node.List:
         """Create a new list and populate it. Any changes to the note will be uploaded when :py:meth:`sync` is called.
 
         Args:
-            title (str): The title of the list.
-            items (List[(str, bool)]): A list of tuples. Each tuple represents the text and checked status of the listitem.
+            title: The title of the list.
+            items: A list of tuples. Each tuple represents the text and checked status of the listitem.
 
         Returns:
-            gkeepapi.node.List: The new list.
+            The new list.
         """
         if items is None:
             items = []
@@ -904,21 +916,21 @@ def createList(self, title=None, items=None):
         if title is not None:
             node.title = title
 
-        sort = random.randint(1000000000, 9999999999)
+        sort = random.randint(1000000000, 9999999999)  # noqa: S311
         for text, checked in items:
             node.add(text, checked, sort)
             sort -= _node.List.SORT_DELTA
         self.add(node)
         return node
 
-    def createLabel(self, name):
+    def createLabel(self, name: str) -> _node.Label:
         """Create a new label.
 
         Args:
-            name (str): Label name.
+            name: Label name.
 
         Returns:
-            gkeepapi.node.Label: The new label.
+            The new label.
 
         Raises:
             LabelException: If the label exists.
@@ -927,18 +939,20 @@ def createLabel(self, name):
             raise exception.LabelException("Label exists")
         node = _node.Label()
         node.name = name
-        self._labels[node.id] = node  # pylint: disable=protected-access
+        self._labels[node.id] = node
         return node
 
-    def findLabel(self, query, create=False):
+    def findLabel(
+        self, query: re.Pattern | str, create: bool = False
+    ) -> _node.Label | None:
         """Find a label with the given name.
 
         Args:
-            name (Union[_sre.SRE_Pattern, str]): A str or regular expression to match against the name.
-            create (bool): Whether to create the label if it doesn't exist (only if name is a str).
+            query: A str or regular expression to match against the name.
+            create: Whether to create the label if it doesn't exist (only if name is a str).
 
         Returns:
-            Union[gkeepapi.node.Label, None]: The label.
+            The label.
         """
         is_str = isinstance(query, str)
         name = None
@@ -949,28 +963,28 @@ def findLabel(self, query, create=False):
         for label in self._labels.values():
             # Match the label against query, which may be a str or Pattern.
             if (is_str and query == label.name.lower()) or (
-                isinstance(query, Pattern) and query.search(label.name)
+                isinstance(query, re.Pattern) and query.search(label.name)
             ):
                 return label
 
         return self.createLabel(name) if create and is_str else None
 
-    def getLabel(self, label_id):
+    def getLabel(self, label_id: str) -> _node.Label | None:
         """Get an existing label.
 
         Args:
-            label_id (str): Label id.
+            label_id: Label id.
 
         Returns:
-            Union[gkeepapi.node.Label, None]: The label.
+            The label.
         """
         return self._labels.get(label_id)
 
-    def deleteLabel(self, label_id):
+    def deleteLabel(self, label_id: str) -> None:
         """Deletes a label.
 
         Args:
-            label_id (str): Label id.
+            label_id: Label id.
         """
         if label_id not in self._labels:
             return
@@ -980,38 +994,38 @@ def deleteLabel(self, label_id):
         for node in self.all():
             node.labels.remove(label)
 
-    def labels(self):
+    def labels(self) -> list[_node.Label]:
         """Get all labels.
 
         Returns:
-            List[gkeepapi.node.Label]: Labels
+            Labels
         """
-        return self._labels.values()
+        return list(self._labels.values())
 
-    def getMediaLink(self, blob):
+    def getMediaLink(self, blob: _node.Blob) -> str:
         """Get the canonical link to media.
 
         Args:
-            blob (gkeepapi.node.Blob): The media resource.
+            blob: The media resource.
 
         Returns:
-            str: A link to the media.
+            A link to the media.
         """
         return self._media_api.get(blob)
 
-    def all(self):
+    def all(self) -> list[_node.TopLevelNode]:  # noqa: A003
         """Get all Notes.
 
         Returns:
-            List[gkeepapi.node.TopLevelNode]: Notes
+            All notes.
         """
         return self._nodes[_node.Root.ID].children
 
-    def sync(self, resync=False):
+    def sync(self, resync: bool = False) -> None:
         """Sync the local Keep tree with the server. If resyncing, local changes will be destroyed. Otherwise, local changes to notes, labels and reminders will be detected and synced up.
 
         Args:
-            resync (bool): Whether to resync data.
+            resync: Whether to resync data.
 
         Raises:
             SyncException: If there is a consistency issue.
@@ -1020,13 +1034,13 @@ def sync(self, resync=False):
         if resync:
             self._clear()
 
-        # self._sync_reminders(resync)
-        self._sync_notes(resync)
+        # self._sync_reminders()
+        self._sync_notes()
 
         if _node.DEBUG:
             self._clean()
 
-    def _sync_reminders(self, resync=False):
+    def _sync_reminders(self) -> None:
         # Fetch updates until we reach the newest version.
         while True:
             logger.debug("Starting reminder sync: %s", self._reminder_version)
@@ -1044,13 +1058,13 @@ def _sync_reminders(self, resync=False):
             if self._reminder_version == history["highestStorageVersion"]:
                 break
 
-    def _sync_notes(self, resync=False):
+    def _sync_notes(self) -> None:
         # Fetch updates until we reach the newest version.
         while True:
             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()))
+            labels_updated = any(i.dirty for i in self._labels.values())
             changes = self._keep_api.changes(
                 target_version=self._keep_version,
                 nodes=[i.save() for i in self._findDirtyNodes()],
@@ -1080,10 +1094,10 @@ def _sync_notes(self, resync=False):
             if not changes["truncated"]:
                 break
 
-    def _parseTasks(self, raw):
+    def _parseTasks(self, raw: dict) -> None:
         pass
 
-    def _parseNodes(self, raw):  # pylint: disable=too-many-branches
+    def _parseNodes(self, raw: dict) -> None:  # noqa: C901, PLR0912
         created_nodes = []
         deleted_nodes = []
         listitem_nodes = []
@@ -1155,12 +1169,12 @@ def _parseNodes(self, raw):  # pylint: disable=too-many-branches
 
         # Hydrate label references in notes.
         for node in self.all():
-            for label_id in node.labels._labels:  # pylint: disable=protected-access
-                node.labels._labels[label_id] = self._labels.get(
+            for label_id in node.labels._labels:  # noqa: SLF001
+                node.labels._labels[label_id] = self._labels.get(  # noqa: SLF001
                     label_id
-                )  # pylint: disable=protected-access
+                )
 
-    def _parseUserInfo(self, raw):
+    def _parseUserInfo(self, raw: dict) -> None:
         labels = {}
         if "labels" in raw:
             for label in raw["labels"]:
@@ -1183,30 +1197,25 @@ def _parseUserInfo(self, raw):
 
         self._labels = labels
 
-    def _findDirtyNodes(self):
+    def _findDirtyNodes(self) -> list[_node.Node]:
         # Find nodes that aren't in our internal nodes list and insert them.
         for node in list(self._nodes.values()):
             for child in node.children:
-                if not child.id in self._nodes:
+                if child.id not in self._nodes:
                     self._nodes[child.id] = child
 
-        nodes = []
         # Collect all dirty nodes (any nodes from above will be caught too).
-        for node in self._nodes.values():
-            if node.dirty:
-                nodes.append(node)
-
-        return nodes
+        return [node for node in self._nodes.values() if node.dirty]
 
-    def _clean(self):
+    def _clean(self) -> None:
         """Recursively check that all nodes are reachable."""
-        found_ids = {}
+        found_ids = set()
         nodes = [self._nodes[_node.Root.ID]]
 
         # Enumerate all nodes from the root node
         while nodes:
             node = nodes.pop()
-            found_ids[node.id] = None
+            found_ids.add(node.id)
             nodes = nodes + node.children
 
         # Find nodes that can't be reached from the root
diff --git a/gkeepapi/exception.py b/src/gkeepapi/exception.py
similarity index 68%
rename from gkeepapi/exception.py
rename to src/gkeepapi/exception.py
index da73bf4..2c421d5 100644
--- a/gkeepapi/exception.py
+++ b/src/gkeepapi/exception.py
@@ -1,53 +1,59 @@
-# -*- coding: utf-8 -*-
-"""
-.. moduleauthor:: Kai <z@kwi.li>
-"""
+""".. moduleauthor:: Kai <z@kwi.li>"""
+
 
 class APIException(Exception):
     """The API server returned an error."""
-    def __init__(self, code, msg):
-        super(APIException, self).__init__(msg)
+
+    def __init__(self, code: int, msg: str) -> None:
+        """Construct an exception object"""
+        super().__init__(msg)
         self.code = code
 
+
 class KeepException(Exception):
     """Generic Keep error."""
-    pass
+
 
 class LoginException(KeepException):
     """Login exception."""
-    pass
+
 
 class BrowserLoginRequiredException(LoginException):
     """Browser login required error."""
-    def __init__(self, url):
+
+    def __init__(self, url: str) -> None:
+        """Construct a browser login exception object"""
         self.url = url
 
+
 class LabelException(KeepException):
     """Keep label error."""
-    pass
+
 
 class SyncException(KeepException):
     """Keep consistency error."""
-    pass
+
 
 class ResyncRequiredException(SyncException):
     """Full resync required error."""
-    pass
+
 
 class UpgradeRecommendedException(SyncException):
     """Upgrade recommended error."""
-    pass
+
 
 class MergeException(KeepException):
     """Node consistency error."""
-    pass
+
 
 class InvalidException(KeepException):
     """Constraint error."""
-    pass
+
 
 class ParseException(KeepException):
     """Parse error."""
-    def __init__(self, msg, raw):
-        super(ParseException, self).__init__(msg)
+
+    def __init__(self, msg: str, raw: dict) -> None:
+        """Construct a parse exception object"""
+        super().__init__(msg)
         self.raw = raw
diff --git a/src/gkeepapi/node.py b/src/gkeepapi/node.py
new file mode 100644
index 0000000..eb52b05
--- /dev/null
+++ b/src/gkeepapi/node.py
@@ -0,0 +1,2179 @@
+""".. automodule:: gkeepapi
+
+   :members:
+   :inherited-members:
+
+.. moduleauthor:: Kai <z@kwi.li>
+"""
+
+import datetime
+import enum
+import itertools
+import logging
+import random
+import time
+from collections.abc import Callable
+from operator import attrgetter
+
+from . import exception
+
+DEBUG = False
+
+logger = logging.getLogger(__name__)
+
+
+class NodeType(enum.Enum):
+    """Valid note types."""
+
+    Note = "NOTE"
+    """A Note"""
+
+    List = "LIST"
+    """A List"""
+
+    ListItem = "LIST_ITEM"
+    """A List item"""
+
+    Blob = "BLOB"
+    """A blob (attachment)"""
+
+
+class BlobType(enum.Enum):
+    """Valid blob types."""
+
+    Audio = "AUDIO"
+    """Audio"""
+
+    Image = "IMAGE"
+    """Image"""
+
+    Drawing = "DRAWING"
+    """Drawing"""
+
+
+class ColorValue(enum.Enum):
+    """Valid note colors."""
+
+    White = "DEFAULT"
+    """White"""
+
+    Red = "RED"
+    """Red"""
+
+    Orange = "ORANGE"
+    """Orange"""
+
+    Yellow = "YELLOW"
+    """Yellow"""
+
+    Green = "GREEN"
+    """Green"""
+
+    Teal = "TEAL"
+    """Teal"""
+
+    Blue = "BLUE"
+    """Blue"""
+
+    DarkBlue = "CERULEAN"
+    """Dark blue"""
+
+    Purple = "PURPLE"
+    """Purple"""
+
+    Pink = "PINK"
+    """Pink"""
+
+    Brown = "BROWN"
+    """Brown"""
+
+    Gray = "GRAY"
+    """Gray"""
+
+
+class CategoryValue(enum.Enum):
+    """Valid note categories."""
+
+    Books = "BOOKS"
+    """Books"""
+
+    Food = "FOOD"
+    """Food"""
+
+    Movies = "MOVIES"
+    """Movies"""
+
+    Music = "MUSIC"
+    """Music"""
+
+    Places = "PLACES"
+    """Places"""
+
+    Quotes = "QUOTES"
+    """Quotes"""
+
+    Travel = "TRAVEL"
+    """Travel"""
+
+    TV = "TV"
+    """TV"""
+
+
+class SuggestValue(enum.Enum):
+    """Valid task suggestion categories."""
+
+    GroceryItem = "GROCERY_ITEM"
+    """Grocery item"""
+
+
+class NewListItemPlacementValue(enum.Enum):
+    """Target location to put new list items."""
+
+    Top = "TOP"
+    """Top"""
+
+    Bottom = "BOTTOM"
+    """Bottom"""
+
+
+class GraveyardStateValue(enum.Enum):
+    """Visibility setting for the graveyard."""
+
+    Expanded = "EXPANDED"
+    """Expanded"""
+
+    Collapsed = "COLLAPSED"
+    """Collapsed"""
+
+
+class CheckedListItemsPolicyValue(enum.Enum):
+    """Movement setting for checked list items."""
+
+    Default = "DEFAULT"
+    """Default"""
+
+    Graveyard = "GRAVEYARD"
+    """Graveyard"""
+
+
+class ShareRequestValue(enum.Enum):
+    """Collaborator change type."""
+
+    Add = "WR"
+    """Grant access."""
+
+    Remove = "RM"
+    """Remove access."""
+
+
+class RoleValue(enum.Enum):
+    """Collaborator role type."""
+
+    Owner = "O"
+    """Note owner."""
+
+    User = "W"
+    """Note collaborator."""
+
+
+class Element:
+    """Interface for elements that can be serialized and deserialized."""
+
+    def __init__(self) -> None:
+        """Construct an element object"""
+        self._dirty = False
+
+    def _find_discrepancies(self, raw: dict | list) -> None:  # pragma: no cover
+        s_raw = self.save(False)
+        if isinstance(raw, dict):
+            for key, val in raw.items():
+                if key in ["parentServerId", "lastSavedSessionId"]:
+                    continue
+                if key not in s_raw:
+                    logger.info("Missing key for %s key %s", type(self), key)
+                    continue
+
+                if isinstance(val, list | dict):
+                    continue
+
+                val_a = raw[key]
+                val_b = s_raw[key]
+                # Python strftime's 'z' format specifier includes microseconds, but the response from GKeep
+                # only has milliseconds. This causes a string mismatch, so we construct datetime objects
+                # to properly compare
+                if isinstance(val_a, str) and isinstance(val_b, str):
+                    try:
+                        tval_a = NodeTimestamps.str_to_dt(val_a)
+                        tval_b = NodeTimestamps.str_to_dt(val_b)
+                        val_a, val_b = tval_a, tval_b
+                    except (KeyError, ValueError):
+                        pass
+                if val_a != val_b:
+                    logger.info(
+                        "Different value for %s key %s: %s != %s",
+                        type(self),
+                        key,
+                        raw[key],
+                        s_raw[key],
+                    )
+        elif isinstance(raw, list) and len(raw) != len(s_raw):
+            logger.info(
+                "Different length for %s: %d != %d",
+                type(self),
+                len(raw),
+                len(s_raw),
+            )
+
+    def load(self, raw: dict) -> None:
+        """Unserialize from raw representation. (Wrapper)
+
+        Args:
+            raw: Raw.
+
+
+        Raises:
+            ParseException: If there was an error parsing data.
+        """
+        try:
+            self._load(raw)
+        except (KeyError, ValueError) as e:
+            raise exception.ParseException(f"Parse error in {type(self)}", raw) from e
+
+    def _load(self, raw: dict) -> None:
+        """Unserialize from raw representation. (Implementation logic)
+
+        Args:
+            raw: Raw.
+        """
+        self._dirty = raw.get("_dirty", False)
+
+    def save(self, clean: bool = True) -> dict:
+        """Serialize into raw representation. Clears the dirty bit by default.
+
+        Args:
+            clean: Whether to clear the dirty bit.
+
+        Returns:
+            Raw.
+        """
+        ret = {}
+        if clean:
+            self._dirty = False
+        else:
+            ret["_dirty"] = self._dirty
+        return ret
+
+    @property
+    def dirty(self) -> bool:
+        """Get dirty state.
+
+        Returns:
+            Whether this element is dirty.
+        """
+        return self._dirty
+
+
+class Annotation(Element):
+    """Note annotations base class."""
+
+    def __init__(self) -> None:
+        """Construct a note annotation"""
+        super().__init__()
+        self.id = self._generateAnnotationId()
+
+    def _load(self, raw: dict) -> None:
+        super()._load(raw)
+        self.id = raw.get("id")
+
+    def save(self, clean: bool = True) -> dict:
+        """Save the annotation"""
+        ret = {}
+        if self.id is not None:
+            ret = super().save(clean)
+        if self.id is not None:
+            ret["id"] = self.id
+        return ret
+
+    @classmethod
+    def _generateAnnotationId(cls) -> str:
+        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
+            random.randint(0x0000, 0xFFFF),  # noqa: S311
+            random.randint(0x000000000000, 0xFFFFFFFFFFFF),  # noqa: S311
+        )
+
+
+class WebLink(Annotation):
+    """Represents a link annotation on a :class:`TopLevelNode`."""
+
+    def __init__(self) -> None:
+        """Construct a weblink"""
+        super().__init__()
+        self._title = ""
+        self._url = ""
+        self._image_url = None
+        self._provenance_url = ""
+        self._description = ""
+
+    def _load(self, raw: dict) -> None:
+        super()._load(raw)
+        self._title = raw["webLink"]["title"]
+        self._url = raw["webLink"]["url"]
+        self._image_url = (
+            raw["webLink"]["imageUrl"]
+            if "imageUrl" in raw["webLink"]
+            else self.image_url
+        )
+        self._provenance_url = raw["webLink"]["provenanceUrl"]
+        self._description = raw["webLink"]["description"]
+
+    def save(self, clean: bool = True) -> dict:
+        """Save the weblink"""
+        ret = super().save(clean)
+        ret["webLink"] = {
+            "title": self._title,
+            "url": self._url,
+            "imageUrl": self._image_url,
+            "provenanceUrl": self._provenance_url,
+            "description": self._description,
+        }
+        return ret
+
+    @property
+    def title(self) -> str:
+        """Get the link title.
+
+        Returns:
+            The link title.
+        """
+        return self._title
+
+    @title.setter
+    def title(self, value: str) -> None:
+        self._title = value
+        self._dirty = True
+
+    @property
+    def url(self) -> str:
+        """Get the link url.
+
+        Returns:
+            The link url.
+        """
+        return self._url
+
+    @url.setter
+    def url(self, value: str) -> None:
+        self._url = value
+        self._dirty = True
+
+    @property
+    def image_url(self) -> str | None:
+        """Get the link image url.
+
+        Returns:
+            The image url or None.
+        """
+        return self._image_url
+
+    @image_url.setter
+    def image_url(self, value: str) -> None:
+        self._image_url = value
+        self._dirty = True
+
+    @property
+    def provenance_url(self) -> str:
+        """Get the provenance url.
+
+        Returns:
+            The provenance url.
+        """
+        return self._provenance_url
+
+    @provenance_url.setter
+    def provenance_url(self, value: str) -> None:
+        self._provenance_url = value
+        self._dirty = True
+
+    @property
+    def description(self) -> str:
+        """Get the link description.
+
+        Returns:
+            The link description.
+        """
+        return self._description
+
+    @description.setter
+    def description(self, value: str) -> None:
+        self._description = value
+        self._dirty = True
+
+
+class Category(Annotation):
+    """Represents a category annotation on a :class:`TopLevelNode`."""
+
+    def __init__(self) -> None:
+        """Construct a category annotation"""
+        super().__init__()
+        self._category = None
+
+    def _load(self, raw: dict) -> None:
+        super()._load(raw)
+        self._category = CategoryValue(raw["topicCategory"]["category"])
+
+    def save(self, clean: bool = True) -> dict:
+        """Save the category annotation"""
+        ret = super().save(clean)
+        ret["topicCategory"] = {"category": self._category.value}
+        return ret
+
+    @property
+    def category(self) -> CategoryValue:
+        """Get the category.
+
+        Returns:
+            The category.
+        """
+        return self._category
+
+    @category.setter
+    def category(self, value: CategoryValue) -> None:
+        self._category = value
+        self._dirty = True
+
+
+class TaskAssist(Annotation):
+    """Unknown."""
+
+    def __init__(self) -> None:
+        """Construct a taskassist annotation"""
+        super().__init__()
+        self._suggest = None
+
+    def _load(self, raw: dict) -> None:
+        super()._load(raw)
+        self._suggest = raw["taskAssist"]["suggestType"]
+
+    def save(self, clean: bool = True) -> dict:
+        """Save the taskassist annotation"""
+        ret = super().save(clean)
+        ret["taskAssist"] = {"suggestType": self._suggest}
+        return ret
+
+    @property
+    def suggest(self) -> str:
+        """Get the suggestion.
+
+        Returns:
+            The suggestion.
+        """
+        return self._suggest
+
+    @suggest.setter
+    def suggest(self, value: str) -> None:
+        self._suggest = value
+        self._dirty = True
+
+
+class Context(Annotation):
+    """Represents a context annotation, which may contain other annotations."""
+
+    def __init__(self) -> None:
+        """Construct a context annotation"""
+        super().__init__()
+        self._entries = {}
+
+    def _load(self, raw: dict) -> None:
+        super()._load(raw)
+        self._entries = {}
+        for key, entry in raw.get("context", {}).items():
+            self._entries[key] = NodeAnnotations.from_json({key: entry})
+
+    def save(self, clean: bool = True) -> dict:
+        """Save the context annotation"""
+        ret = super().save(clean)
+        context = {}
+        for entry in self._entries.values():
+            context.update(entry.save(clean))
+        ret["context"] = context
+        return ret
+
+    def all(self) -> list[Annotation]:  # noqa: A003
+        """Get all sub annotations.
+
+        Returns:
+            Sub Annotations.
+        """
+        return list(self._entries.values())
+
+    @property
+    def dirty(self) -> bool:  # noqa: D102
+        return super().dirty or any(
+            annotation.dirty for annotation in self._entries.values()
+        )
+
+
+class NodeAnnotations(Element):
+    """Represents the annotation container on a :class:`TopLevelNode`."""
+
+    def __init__(self) -> None:
+        """Construct an annotations container"""
+        super().__init__()
+        self._annotations = {}
+
+    def __len__(self) -> int:
+        return len(self._annotations)
+
+    @classmethod
+    def from_json(cls, raw: dict) -> Annotation | None:
+        """Helper to construct an annotation from a dict.
+
+        Args:
+            raw: Raw annotation representation.
+
+        Returns:
+            An Annotation object or None.
+        """
+        bcls = None
+        if "webLink" in raw:
+            bcls = WebLink
+        elif "topicCategory" in raw:
+            bcls = Category
+        elif "taskAssist" in raw:
+            bcls = TaskAssist
+        elif "context" in raw:
+            bcls = Context
+
+        if bcls is None:
+            logger.warning("Unknown annotation type: %s", raw.keys())
+            return None
+        annotation = bcls()
+        annotation.load(raw)
+
+        return annotation
+
+    def all(self) -> list[Annotation]:  # noqa: A003
+        """Get all annotations.
+
+        Returns:
+            Annotations.
+        """
+        return list(self._annotations.values())
+
+    def _load(self, raw: dict) -> None:
+        super()._load(raw)
+        self._annotations = {}
+        if "annotations" not in raw:
+            return
+
+        for raw_annotation in raw["annotations"]:
+            annotation = self.from_json(raw_annotation)
+            self._annotations[annotation.id] = annotation
+
+    def save(self, clean: bool = True) -> dict:
+        """Save the annotations container"""
+        ret = super().save(clean)
+        ret["kind"] = "notes#annotationsGroup"
+        if self._annotations:
+            ret["annotations"] = [
+                annotation.save(clean) for annotation in self._annotations.values()
+            ]
+        return ret
+
+    def _get_category_node(self) -> Category | None:
+        for annotation in self._annotations.values():
+            if isinstance(annotation, Category):
+                return annotation
+        return None
+
+    @property
+    def category(self) -> CategoryValue | None:
+        """Get the category.
+
+        Returns:
+            The category.
+        """
+        node = self._get_category_node()
+
+        return node.category if node is not None else None
+
+    @category.setter
+    def category(self, value: CategoryValue) -> None:
+        node = self._get_category_node()
+        if value is None:
+            if node is not None:
+                del self._annotations[node.id]
+        else:
+            if node is None:
+                node = Category()
+                self._annotations[node.id] = node
+
+            node.category = value
+        self._dirty = True
+
+    @property
+    def links(self) -> list[WebLink]:
+        """Get all links.
+
+        Returns:
+            A list of links.
+        """
+        return [
+            annotation
+            for annotation in self._annotations.values()
+            if isinstance(annotation, WebLink)
+        ]
+
+    def append(self, annotation: Annotation) -> Annotation:
+        """Add an annotation.
+
+        Args:
+            annotation: An Annotation object.
+
+        Returns:
+            The Annotation.
+        """
+        self._annotations[annotation.id] = annotation
+        self._dirty = True
+        return annotation
+
+    def remove(self, annotation: Annotation) -> None:
+        """Removes an annotation.
+
+        Args:
+            annotation: An Annotation object.
+        """
+        if annotation.id in self._annotations:
+            del self._annotations[annotation.id]
+        self._dirty = True
+
+    @property
+    def dirty(self) -> bool:  # noqa: D102
+        return super().dirty or any(
+            annotation.dirty for annotation in self._annotations.values()
+        )
+
+
+class NodeTimestamps(Element):
+    """Represents the timestamps associated with a :class:`TopLevelNode`."""
+
+    TZ_FMT = "%Y-%m-%dT%H:%M:%S.%fZ"
+
+    def __init__(self, create_time: float | None = None) -> None:
+        """Construct a timestamps container"""
+        super().__init__()
+        if create_time is None:
+            create_time = time.time()
+
+        self._created = self.int_to_dt(create_time)
+        self._deleted = None
+        self._trashed = None
+        self._updated = self.int_to_dt(create_time)
+        self._edited = self.int_to_dt(create_time)
+
+    def _load(self, raw: dict) -> None:
+        super()._load(raw)
+        if "created" in raw:
+            self._created = self.str_to_dt(raw["created"])
+        self._deleted = self.str_to_dt(raw["deleted"]) if "deleted" in raw else None
+        self._trashed = self.str_to_dt(raw["trashed"]) if "trashed" in raw else None
+        self._updated = self.str_to_dt(raw["updated"])
+        self._edited = (
+            self.str_to_dt(raw["userEdited"]) if "userEdited" in raw else None
+        )
+
+    def save(self, clean: bool = True) -> dict:
+        """Save the timestamps container"""
+        ret = super().save(clean)
+        ret["kind"] = "notes#timestamps"
+        ret["created"] = self.dt_to_str(self._created)
+        if self._deleted is not None:
+            ret["deleted"] = self.dt_to_str(self._deleted)
+        if self._trashed is not None:
+            ret["trashed"] = self.dt_to_str(self._trashed)
+        ret["updated"] = self.dt_to_str(self._updated)
+        if self._edited is not None:
+            ret["userEdited"] = self.dt_to_str(self._edited)
+        return ret
+
+    @classmethod
+    def str_to_dt(cls, tzs: str) -> datetime.datetime:
+        """Convert a datetime string into an object.
+
+        Params:
+            tsz: Datetime string.
+
+        Returns:
+            Datetime.
+        """
+        return datetime.datetime.strptime(tzs, cls.TZ_FMT).replace(
+            tzinfo=datetime.timezone.utc
+        )
+
+    @classmethod
+    def int_to_dt(cls, tz: float) -> datetime.datetime:
+        """Convert a unix timestamp into an object.
+
+        Params:
+            ts: Unix timestamp.
+
+        Returns:
+            Datetime.
+        """
+        return datetime.datetime.fromtimestamp(tz, tz=datetime.timezone.utc)
+
+    @classmethod
+    def dt_to_str(cls, dt: datetime.datetime) -> str:
+        """Convert a datetime to a str.
+
+        Params:
+            dt: Datetime.
+
+        Returns:
+            Datetime string.
+        """
+        return dt.strftime(cls.TZ_FMT)
+
+    @classmethod
+    def int_to_str(cls, tz: int) -> str:
+        """Convert a unix timestamp to a str.
+
+        Returns:
+            Datetime string.
+        """
+        return cls.dt_to_str(cls.int_to_dt(tz))
+
+    @property
+    def created(self) -> datetime.datetime:
+        """Get the creation datetime.
+
+        Returns:
+            Datetime.
+        """
+        return self._created
+
+    @created.setter
+    def created(self, value: datetime.datetime) -> None:
+        self._created = value
+        self._dirty = True
+
+    @property
+    def deleted(self) -> datetime.datetime | None:
+        """Get the deletion datetime.
+
+        Returns:
+            Datetime.
+        """
+        return self._deleted
+
+    @deleted.setter
+    def deleted(self, value: datetime.datetime) -> None:
+        self._deleted = value
+        self._dirty = True
+
+    @property
+    def trashed(self) -> datetime.datetime | None:
+        """Get the move-to-trash datetime.
+
+        Returns:
+            Datetime.
+        """
+        return self._trashed
+
+    @trashed.setter
+    def trashed(self, value: datetime.datetime) -> None:
+        self._trashed = value
+        self._dirty = True
+
+    @property
+    def updated(self) -> datetime.datetime:
+        """Get the updated datetime.
+
+        Returns:
+            Datetime.
+        """
+        return self._updated
+
+    @updated.setter
+    def updated(self, value: datetime.datetime) -> None:
+        self._updated = value
+        self._dirty = True
+
+    @property
+    def edited(self) -> datetime.datetime:
+        """Get the user edited datetime.
+
+        Returns:
+            Datetime.
+        """
+        return self._edited
+
+    @edited.setter
+    def edited(self, value: datetime.datetime) -> None:
+        self._edited = value
+        self._dirty = True
+
+
+class NodeSettings(Element):
+    """Represents the settings associated with a :class:`TopLevelNode`."""
+
+    def __init__(self) -> None:
+        """Construct a settings container"""
+        super().__init__()
+        self._new_listitem_placement = NewListItemPlacementValue.Bottom
+        self._graveyard_state = GraveyardStateValue.Collapsed
+        self._checked_listitems_policy = CheckedListItemsPolicyValue.Graveyard
+
+    def _load(self, raw: dict) -> None:
+        super()._load(raw)
+        self._new_listitem_placement = NewListItemPlacementValue(
+            raw["newListItemPlacement"]
+        )
+        self._graveyard_state = GraveyardStateValue(raw["graveyardState"])
+        self._checked_listitems_policy = CheckedListItemsPolicyValue(
+            raw["checkedListItemsPolicy"]
+        )
+
+    def save(self, clean: bool = True) -> dict:
+        """Save the settings container"""
+        ret = super().save(clean)
+        ret["newListItemPlacement"] = self._new_listitem_placement.value
+        ret["graveyardState"] = self._graveyard_state.value
+        ret["checkedListItemsPolicy"] = self._checked_listitems_policy.value
+        return ret
+
+    @property
+    def new_listitem_placement(self) -> NewListItemPlacementValue:
+        """Get the default location to insert new listitems.
+
+        Returns:
+            Placement.
+        """
+        return self._new_listitem_placement
+
+    @new_listitem_placement.setter
+    def new_listitem_placement(self, value: NewListItemPlacementValue) -> None:
+        self._new_listitem_placement = value
+        self._dirty = True
+
+    @property
+    def graveyard_state(self) -> GraveyardStateValue:
+        """Get the visibility state for the list graveyard.
+
+        Returns:
+            Visibility.
+        """
+        return self._graveyard_state
+
+    @graveyard_state.setter
+    def graveyard_state(self, value: GraveyardStateValue) -> None:
+        self._graveyard_state = value
+        self._dirty = True
+
+    @property
+    def checked_listitems_policy(self) -> CheckedListItemsPolicyValue:
+        """Get the policy for checked listitems.
+
+        Returns:
+            Policy.
+        """
+        return self._checked_listitems_policy
+
+    @checked_listitems_policy.setter
+    def checked_listitems_policy(self, value: CheckedListItemsPolicyValue) -> None:
+        self._checked_listitems_policy = value
+        self._dirty = True
+
+
+class NodeCollaborators(Element):
+    """Represents the collaborators on a :class:`TopLevelNode`."""
+
+    def __init__(self) -> None:
+        """Construct a collaborators container"""
+        super().__init__()
+        self._collaborators = {}
+
+    def __len__(self) -> int:
+        return len(self._collaborators)
+
+    def load(self, collaborators_raw: list, requests_raw: list) -> None:  # noqa: D102
+        # Parent method not called.
+        if requests_raw and isinstance(requests_raw[-1], bool):
+            self._dirty = requests_raw.pop()
+        else:
+            self._dirty = False
+        self._collaborators = {}
+        for collaborator in collaborators_raw:
+            self._collaborators[collaborator["email"]] = RoleValue(collaborator["role"])
+        for collaborator in requests_raw:
+            self._collaborators[collaborator["email"]] = ShareRequestValue(
+                collaborator["type"]
+            )
+
+    def save(self, clean: bool = True) -> tuple[list, list]:
+        """Save the collaborators container"""
+        # Parent method not called.
+        collaborators = []
+        requests = []
+        for email, action in self._collaborators.items():
+            if isinstance(action, ShareRequestValue):
+                requests.append({"email": email, "type": action.value})
+            else:
+                collaborators.append(
+                    {"email": email, "role": action.value, "auxiliary_type": "None"}
+                )
+        if not clean:
+            requests.append(self._dirty)
+        else:
+            self._dirty = False
+        return (collaborators, requests)
+
+    def add(self, email: str) -> None:
+        """Add a collaborator.
+
+        Args:
+            email: Collaborator email address.
+        """
+        if email not in self._collaborators:
+            self._collaborators[email] = ShareRequestValue.Add
+        self._dirty = True
+
+    def remove(self, email: str) -> None:
+        """Remove a Collaborator.
+
+        Args:
+            email: Collaborator email address.
+        """
+        if email in self._collaborators:
+            if self._collaborators[email] == ShareRequestValue.Add:
+                del self._collaborators[email]
+            else:
+                self._collaborators[email] = ShareRequestValue.Remove
+        self._dirty = True
+
+    def all(self) -> list[str]:  # noqa: A003
+        """Get all collaborators.
+
+        Returns:
+            Collaborators.
+        """
+        return [
+            email
+            for email, action in self._collaborators.items()
+            if action in [RoleValue.Owner, RoleValue.User, ShareRequestValue.Add]
+        ]
+
+
+class TimestampsMixin:
+    """A mixin to add methods for updating timestamps."""
+
+    def __init__(self) -> None:
+        """Instantiate mixin"""
+        self.timestamps: NodeTimestamps
+
+    def touch(self, edited: bool = False) -> None:
+        """Mark the node as dirty.
+
+        Args:
+            edited: Whether to set the edited time.
+        """
+        self._dirty = True
+        dt = datetime.datetime.now(tz=datetime.timezone.utc)
+        self.timestamps.updated = dt
+        if edited:
+            self.timestamps.edited = dt
+
+    @property
+    def trashed(self) -> bool:
+        """Get the trashed state.
+
+        Returns:
+            Whether this item is trashed.
+        """
+        return (
+            self.timestamps.trashed is not None
+            and self.timestamps.trashed > NodeTimestamps.int_to_dt(0)
+        )
+
+    def trash(self) -> None:
+        """Mark the item as trashed."""
+        self.timestamps.trashed = datetime.datetime.now(tz=datetime.timezone.utc)
+
+    def untrash(self) -> None:
+        """Mark the item as untrashed."""
+        self.timestamps.trashed = self.timestamps.int_to_dt(0)
+
+    @property
+    def deleted(self) -> bool:
+        """Get the deleted state.
+
+        Returns:
+            Whether this item is deleted.
+        """
+        return (
+            self.timestamps.deleted is not None
+            and self.timestamps.deleted > NodeTimestamps.int_to_dt(0)
+        )
+
+    def delete(self) -> None:
+        """Mark the item as deleted."""
+        self.timestamps.deleted = datetime.datetime.now(tz=datetime.timezone.utc)
+
+    def undelete(self) -> None:
+        """Mark the item as undeleted."""
+        self.timestamps.deleted = None
+
+
+class Label(Element, TimestampsMixin):
+    """Represents a label."""
+
+    def __init__(self) -> None:
+        """Construct a label"""
+        super().__init__()
+
+        create_time = time.time()
+
+        self.id = self._generateId(create_time)
+        self._name = ""
+        self.timestamps = NodeTimestamps(create_time)
+        self._merged = NodeTimestamps.int_to_dt(0)
+
+    @classmethod
+    def _generateId(cls, tz: float) -> str:
+        return "tag.{}.{:x}".format(
+            "".join(
+                [
+                    random.choice("abcdefghijklmnopqrstuvwxyz0123456789")  # noqa: S311
+                    for _ in range(12)
+                ]
+            ),
+            int(tz * 1000),
+        )
+
+    def _load(self, raw: dict) -> None:
+        super()._load(raw)
+        self.id = raw["mainId"]
+        self._name = raw["name"]
+        self.timestamps.load(raw["timestamps"])
+        self._merged = (
+            NodeTimestamps.str_to_dt(raw["lastMerged"])
+            if "lastMerged" in raw
+            else NodeTimestamps.int_to_dt(0)
+        )
+
+    def save(self, clean: bool = True) -> dict:
+        """Save the label"""
+        ret = super().save(clean)
+        ret["mainId"] = self.id
+        ret["name"] = self._name
+        ret["timestamps"] = self.timestamps.save(clean)
+        ret["lastMerged"] = NodeTimestamps.dt_to_str(self._merged)
+        return ret
+
+    @property
+    def name(self) -> str:
+        """Get the label name.
+
+        Returns:
+            Label name.
+        """
+        return self._name
+
+    @name.setter
+    def name(self, value: str) -> None:
+        self._name = value
+        self.touch(True)
+
+    @property
+    def merged(self) -> datetime.datetime:
+        """Get last merge datetime.
+
+        Returns:
+            Datetime.
+        """
+        return self._merged
+
+    @merged.setter
+    def merged(self, value: datetime.datetime) -> None:
+        self._merged = value
+        self.touch()
+
+    @property
+    def dirty(self) -> bool:  # noqa: D102
+        return super().dirty or self.timestamps.dirty
+
+    def __str__(self) -> str:
+        return self.name
+
+
+class NodeLabels(Element):
+    """Represents the labels on a :class:`TopLevelNode`."""
+
+    def __init__(self) -> None:
+        """Construct a labels container"""
+        super().__init__()
+        self._labels = {}
+
+    def __len__(self) -> int:
+        return len(self._labels)
+
+    def _load(self, raw: list) -> None:
+        # Parent method not called.
+        if raw and isinstance(raw[-1], bool):
+            self._dirty = raw.pop()
+        else:
+            self._dirty = False
+        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
+        # Parent method not called.
+        ret = [
+            {
+                "labelId": label_id,
+                "deleted": NodeTimestamps.dt_to_str(
+                    datetime.datetime.now(tz=datetime.timezone.utc)
+                )
+                if label is None
+                else NodeTimestamps.int_to_str(0),
+            }
+            for label_id, label in self._labels.items()
+        ]
+        if not clean:
+            ret.append(self._dirty)
+        else:
+            self._dirty = False
+        return ret
+
+    def add(self, label: Label) -> None:
+        """Add a label.
+
+        Args:
+            label: The Label object.
+        """
+        self._labels[label.id] = label
+        self._dirty = True
+
+    def remove(self, label: Label) -> None:
+        """Remove a label.
+
+        Args:
+            label: The Label object.
+        """
+        if label.id in self._labels:
+            self._labels[label.id] = None
+        self._dirty = True
+
+    def get(self, label_id: str) -> str:
+        """Get a label by ID.
+
+        Args:
+            label_id: The label ID.
+        """
+        return self._labels.get(label_id)
+
+    def all(self) -> list[Label]:  # noqa: A003
+        """Get all labels.
+
+        Returns:
+            Labels.
+        """
+        return [label for _, label in self._labels.items() if label is not None]
+
+
+class Node(Element, TimestampsMixin):
+    """Node base class."""
+
+    def __init__(
+        self,
+        id_: str | None = None,
+        type_: str | None = None,
+        parent_id: str | None = None,
+    ) -> None:
+        """Construct a node"""
+        super().__init__()
+
+        create_time = time.time()
+
+        self.parent = None
+        self.id = self._generateId(create_time) if id_ is None else id_
+        self.server_id = None
+        self.parent_id = parent_id
+        self.type = type_
+        self._sort = random.randint(1000000000, 9999999999)  # noqa: S311
+        self._version = None
+        self._text = ""
+        self._children = {}
+        self.timestamps = NodeTimestamps(create_time)
+        self.settings = NodeSettings()
+        self.annotations = NodeAnnotations()
+
+        # Set if there is no baseVersion in the raw data
+        self._moved = False
+
+    @classmethod
+    def _generateId(cls, tz: float) -> str:
+        return "{:x}.{:016x}".format(
+            int(tz * 1000),
+            random.randint(0x0000000000000000, 0xFFFFFFFFFFFFFFFF),  # noqa: S311
+        )
+
+    def _load(self, raw: dict) -> None:
+        super()._load(raw)
+        # Verify this is a valid type
+        NodeType(raw["type"])
+        if raw["kind"] not in ["notes#node"]:
+            logger.warning("Unknown node kind: %s", raw["kind"])
+
+        if "mergeConflict" in raw:
+            raise exception.MergeException(raw)
+
+        self.id = raw["id"]
+        self.server_id = raw["serverId"] if "serverId" in raw else self.server_id
+        self.parent_id = raw["parentId"]
+        self._sort = raw["sortValue"] if "sortValue" in raw else self.sort
+        self._version = raw["baseVersion"] if "baseVersion" in raw else self._version
+        self._text = raw["text"] if "text" in raw else self._text
+        self.timestamps.load(raw["timestamps"])
+        self.settings.load(raw["nodeSettings"])
+        self.annotations.load(raw["annotationsGroup"])
+
+    def save(self, clean: bool = True) -> dict:  # noqa: D102
+        ret = super().save(clean)
+        ret["id"] = self.id
+        ret["kind"] = "notes#node"
+        ret["type"] = self.type.value
+        ret["parentId"] = self.parent_id
+        ret["sortValue"] = self._sort
+        if not self._moved and self._version is not None:
+            ret["baseVersion"] = self._version
+        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)
+        return ret
+
+    @property
+    def sort(self) -> int:
+        """Get the sort id.
+
+        Returns:
+            Sort id.
+        """
+        return int(self._sort)
+
+    @sort.setter
+    def sort(self, value: int) -> None:
+        self._sort = value
+        self.touch()
+
+    @property
+    def version(self) -> int:
+        """Get the node version.
+
+        Returns:
+            Version.
+        """
+        return self._version
+
+    @property
+    def text(self) -> str:
+        """Get the text value.
+
+        Returns:
+            Text value.
+        """
+        return self._text
+
+    @text.setter
+    def text(self, value: str) -> None:
+        """Set the text value.
+
+        Args:
+            value: Text value.
+        """
+        self._text = value
+        self.timestamps.edited = datetime.datetime.now(tz=datetime.timezone.utc)
+        self.touch(True)
+
+    @property
+    def children(self) -> list["Node"]:
+        """Get all children.
+
+        Returns:
+            Children nodes.
+        """
+        return list(self._children.values())
+
+    def get(self, node_id: str) -> "Node | None":
+        """Get child node with the given ID.
+
+        Args:
+            node_id: The node ID.
+
+        Returns:
+            Child node.
+        """
+        return self._children.get(node_id)
+
+    def append(self, node: "Node", dirty: bool = True) -> "Node":
+        """Add a new child node.
+
+        Args:
+            node: Node to add.
+            dirty: Whether this node should be marked dirty.
+        """
+        self._children[node.id] = node
+        node.parent = self
+        if dirty:
+            self.touch()
+
+        return node
+
+    def remove(self, node: "Node", dirty: bool = True) -> None:
+        """Remove the given child node.
+
+        Args:
+            node: Node to remove.
+            dirty: Whether this node should be marked dirty.
+        """
+        if node.id in self._children:
+            self._children[node.id].parent = None
+            del self._children[node.id]
+        if dirty:
+            self.touch()
+
+    @property
+    def new(self) -> bool:
+        """Get whether this node has been persisted to the server.
+
+        Returns:
+            True if node is new.
+        """
+        return self.server_id is None
+
+    @property
+    def dirty(self) -> bool:  # noqa: D102
+        return (
+            super().dirty
+            or self.timestamps.dirty
+            or self.annotations.dirty
+            or self.settings.dirty
+            or any(node.dirty for node in self.children)
+        )
+
+
+class Root(Node):
+    """Internal root node."""
+
+    ID = "root"
+
+    def __init__(self) -> None:
+        """Construct a root node"""
+        super().__init__(id_=self.ID)
+
+    @property
+    def dirty(self) -> bool:  # noqa: D102
+        return False
+
+
+class TopLevelNode(Node):
+    """Top level node base class."""
+
+    _TYPE = None
+
+    def __init__(self, **kwargs: dict) -> None:
+        """Construct a top level node"""
+        super().__init__(parent_id=Root.ID, **kwargs)
+        self._color = ColorValue.White
+        self._archived = False
+        self._pinned = False
+        self._title = ""
+        self.labels = NodeLabels()
+        self.collaborators = NodeCollaborators()
+
+    def _load(self, raw: dict) -> None:
+        super()._load(raw)
+        self._color = ColorValue(raw["color"]) if "color" in raw else ColorValue.White
+        self._archived = raw["isArchived"] if "isArchived" in raw else False
+        self._pinned = raw["isPinned"] if "isPinned" in raw else False
+        self._title = raw["title"] if "title" in raw else ""
+        self.labels.load(raw["labelIds"] if "labelIds" in raw else [])
+
+        self.collaborators.load(
+            raw["roleInfo"] if "roleInfo" in raw else [],
+            raw["shareRequests"] if "shareRequests" in raw else [],
+        )
+        self._moved = "moved" in raw
+
+    def save(self, clean: bool = True) -> dict:  # noqa: D102
+        ret = super().save(clean)
+        ret["color"] = self._color.value
+        ret["isArchived"] = self._archived
+        ret["isPinned"] = self._pinned
+        ret["title"] = self._title
+        labels = self.labels.save(clean)
+
+        collaborators, requests = self.collaborators.save(clean)
+        if labels:
+            ret["labelIds"] = labels
+        ret["collaborators"] = collaborators
+        if requests:
+            ret["shareRequests"] = requests
+        return ret
+
+    @property
+    def color(self) -> ColorValue:
+        """Get the node color.
+
+        Returns:
+            Color.
+        """
+        return self._color
+
+    @color.setter
+    def color(self, value: ColorValue) -> None:
+        self._color = value
+        self.touch(True)
+
+    @property
+    def archived(self) -> bool:
+        """Get the archive state.
+
+        Returns:
+            Whether this node is archived.
+        """
+        return self._archived
+
+    @archived.setter
+    def archived(self, value: bool) -> None:
+        self._archived = value
+        self.touch(True)
+
+    @property
+    def pinned(self) -> bool:
+        """Get the pin state.
+
+        Returns:
+            Whether this node is pinned.
+        """
+        return self._pinned
+
+    @pinned.setter
+    def pinned(self, value: bool) -> None:
+        self._pinned = value
+        self.touch(True)
+
+    @property
+    def title(self) -> str:
+        """Get the title.
+
+        Returns:
+            Title.
+        """
+        return self._title
+
+    @title.setter
+    def title(self, value: str) -> None:
+        self._title = value
+        self.touch(True)
+
+    @property
+    def url(self) -> str:
+        """Get the url for this node.
+
+        Returns:
+            Google Keep url.
+        """
+        return "https://keep.google.com/u/0/#" + self._TYPE.value + "/" + self.id
+
+    @property
+    def dirty(self) -> bool:  # noqa: D102
+        return super().dirty or self.labels.dirty or self.collaborators.dirty
+
+    @property
+    def blobs(self) -> list["Blob"]:
+        """Get all media blobs.
+
+        Returns:
+            Media blobs.
+        """
+        return [node for node in self.children if isinstance(node, Blob)]
+
+    @property
+    def images(self) -> list["NodeImage"]:
+        """Get all image blobs"""
+        return [blob for blob in self.blobs if isinstance(blob.blob, NodeImage)]
+
+    @property
+    def drawings(self) -> list["NodeDrawing"]:
+        """Get all drawing blobs"""
+        return [blob for blob in self.blobs if isinstance(blob.blob, NodeDrawing)]
+
+    @property
+    def audio(self) -> list["NodeAudio"]:
+        """Get all audio blobs"""
+        return [blob for blob in self.blobs if isinstance(blob.blob, NodeAudio)]
+
+
+class ListItem(Node):
+    """Represents a Google Keep listitem.
+
+    Interestingly enough, :class:`Note`s store their content in a single
+    child :class:`ListItem`.
+    """
+
+    def __init__(
+        self,
+        parent_id: str | None = None,
+        parent_server_id: str | None = None,
+        super_list_item_id: str | None = None,
+        **kwargs: dict,
+    ) -> None:
+        """Construct a list item node"""
+        super().__init__(type_=NodeType.ListItem, parent_id=parent_id, **kwargs)
+        self.parent_item = None
+        self.parent_server_id = parent_server_id
+        self.super_list_item_id = super_list_item_id
+        self.prev_super_list_item_id = None
+        self._subitems = {}
+        self._checked = False
+
+    def _load(self, raw: dict) -> None:
+        super()._load(raw)
+        self.prev_super_list_item_id = self.super_list_item_id
+        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)
+        ret["parentServerId"] = self.parent_server_id
+        ret["superListItemId"] = self.super_list_item_id
+        ret["checked"] = self._checked
+        return ret
+
+    def add(
+        self,
+        text: str,
+        checked: bool = False,
+        sort: NewListItemPlacementValue | int | None = None,
+    ) -> "ListItem":
+        """Add a new sub item to the list. This item must already be attached to a list.
+
+        Args:
+            text: The text.
+            checked: Whether this item is checked.
+            sort: Item id for sorting.
+        """
+        if self.parent is None:
+            raise exception.InvalidException("Item has no parent")
+        node = self.parent.add(text, checked, sort)
+        self.indent(node)
+        return node
+
+    def indent(self, node: "ListItem", dirty: bool = True) -> None:
+        """Indent an item. Does nothing if the target has subitems.
+
+        Args:
+            node: Item to indent.
+            dirty: Whether this node should be marked dirty.
+        """
+        if node.subitems:
+            return
+
+        self._subitems[node.id] = node
+        node.super_list_item_id = self.id
+        node.parent_item = self
+        if dirty:
+            node.touch(True)
+
+    def dedent(self, node: "ListItem", dirty: bool = True) -> None:
+        """Dedent an item. Does nothing if the target is not indented under this item.
+
+        Args:
+            node: Item to dedent.
+            dirty : Whether this node should be marked dirty.
+        """
+        if node.id not in self._subitems:
+            return
+
+        del self._subitems[node.id]
+        node.super_list_item_id = ""
+        node.parent_item = None
+        if dirty:
+            node.touch(True)
+
+    @property
+    def subitems(self) -> list["ListItem"]:
+        """Get subitems for this item.
+
+        Returns:
+            Subitems.
+        """
+        return List.sorted_items(self._subitems.values())
+
+    @property
+    def indented(self) -> bool:
+        """Get indentation state.
+
+        Returns:
+            Whether this item is indented.
+        """
+        return self.parent_item is not None
+
+    @property
+    def checked(self) -> bool:
+        """Get the checked state.
+
+        Returns:
+            Whether this item is checked.
+        """
+        return self._checked
+
+    @checked.setter
+    def checked(self, value: bool) -> None:
+        self._checked = value
+        self.touch(True)
+
+    def __str__(self) -> str:
+        return "{}{} {}".format(
+            "  " if self.indented else "",
+            "☑" if self.checked else "☐",
+            self.text,
+        )
+
+
+class Note(TopLevelNode):
+    """Represents a Google Keep note."""
+
+    _TYPE = NodeType.Note
+
+    def __init__(self, **kwargs: dict) -> None:
+        """Construct a note node"""
+        super().__init__(type_=self._TYPE, **kwargs)
+
+    def _get_text_node(self) -> ListItem | None:
+        node = None
+        for child_node in self.children:
+            if isinstance(child_node, ListItem):
+                node = child_node
+                break
+
+        return node
+
+    @property
+    def text(self) -> str:  # noqa: D102
+        node = self._get_text_node()
+
+        if node is None:
+            return self._text
+        return node.text
+
+    @text.setter
+    def text(self, value: str) -> None:
+        node = self._get_text_node()
+        if node is None:
+            node = ListItem(parent_id=self.id)
+            self.append(node, True)
+        node.text = value
+        self.touch(True)
+
+    def __str__(self) -> str:
+        return f"{self.title}\n{self.text}"
+
+
+class List(TopLevelNode):
+    """Represents a Google Keep list."""
+
+    _TYPE = NodeType.List
+    SORT_DELTA = 10000  # Arbitrary constant
+
+    def __init__(self, **kwargs: dict) -> None:
+        """Construct a list node"""
+        super().__init__(type_=self._TYPE, **kwargs)
+
+    def add(
+        self,
+        text: str,
+        checked: bool = False,
+        sort: NewListItemPlacementValue | int | None = None,
+    ) -> ListItem:
+        """Add a new item to the list.
+
+        Args:
+            text: The text.
+            checked: Whether this item is checked.
+            sort: Item id for sorting or a placement policy.
+        """
+        node = ListItem(parent_id=self.id, parent_server_id=self.server_id)
+        node.checked = checked
+        node.text = text
+
+        items = list(self.items)
+        if isinstance(sort, int):
+            node.sort = sort
+        elif isinstance(sort, NewListItemPlacementValue) and len(items):
+            func = max
+            delta = self.SORT_DELTA
+            if sort == NewListItemPlacementValue.Bottom:
+                func = min
+                delta *= -1
+
+            node.sort = func(int(item.sort) for item in items) + delta
+
+        self.append(node, True)
+        self.touch(True)
+        return node
+
+    @property
+    def text(self) -> str:  # noqa: D102
+        return "\n".join(str(node) for node in self.items)
+
+    @classmethod
+    def sorted_items(cls, items: list[ListItem]) -> list[ListItem]:  # noqa: C901
+        """Generate a list of sorted list items, taking into account parent items.
+
+        Args:
+            items: Items to sort.
+
+
+        Returns:
+            Sorted items.
+        """
+
+        class T(tuple):
+            """Tuple with element-based sorting"""
+
+            __slots__ = ()
+
+            def __cmp__(self, other: "T") -> int:
+                for a, b in itertools.zip_longest(self, other):
+                    if a != b:
+                        if a is None:
+                            return 1
+                        if b is None:
+                            return -1
+                        return a - b
+                return 0
+
+            def __lt__(self, other: "T") -> bool:  # pragma: no cover
+                return self.__cmp__(other) < 0
+
+            def __gt_(self, other: "T") -> bool:  # pragma: no cover
+                return self.__cmp__(other) > 0
+
+            def __le__(self, other: "T") -> bool:  # pragma: no cover
+                return self.__cmp__(other) <= 0
+
+            def __ge_(self, other: "T") -> bool:  # pragma: no cover
+                return self.__cmp__(other) >= 0
+
+            def __eq__(self, other: "T") -> bool:  # pragma: no cover
+                return self.__cmp__(other) == 0
+
+            def __ne__(self, other: "T") -> bool:  # pragma: no cover
+                return self.__cmp__(other) != 0
+
+        def key_func(x: ListItem) -> T:
+            if x.indented:
+                return T((int(x.parent_item.sort), int(x.sort)))
+            return T((int(x.sort),))
+
+        return sorted(items, key=key_func, reverse=True)
+
+    def _items(self, checked: bool | None = None) -> list[ListItem]:
+        return [
+            node
+            for node in self.children
+            if isinstance(node, ListItem)
+            and not node.deleted
+            and (checked is None or node.checked == checked)
+        ]
+
+    def sort_items(
+        self, key: Callable = attrgetter("text"), reverse: bool = False
+    ) -> None:
+        """Sort list items in place. By default, the items are alphabetized, but a custom function can be specified.
+
+        Args:
+            key: A filter function.
+            reverse: Whether to reverse the output.
+        """
+        sorted_children = sorted(self._items(), key=key, reverse=reverse)
+        sort_value = random.randint(1000000000, 9999999999)  # noqa: S311
+
+        for node in sorted_children:
+            node.sort = sort_value
+            sort_value -= self.SORT_DELTA
+
+    def __str__(self) -> str:
+        return "\n".join([self.title] + [str(node) for node in self.items])
+
+    @property
+    def items(self) -> list[ListItem]:
+        """Get all listitems.
+
+        Returns:
+            List items.
+        """
+        return self.sorted_items(self._items())
+
+    @property
+    def checked(self) -> list[ListItem]:
+        """Get all checked listitems.
+
+        Returns:
+            List items.
+        """
+        return self.sorted_items(self._items(True))
+
+    @property
+    def unchecked(self) -> list[ListItem]:
+        """Get all unchecked listitems.
+
+        Returns:
+            List items.
+        """
+        return self.sorted_items(self._items(False))
+
+
+class NodeBlob(Element):
+    """Represents a blob descriptor."""
+
+    _TYPE = None
+
+    def __init__(self, type_: str | None = None) -> None:
+        """Construct a node blob"""
+        super().__init__()
+        self.blob_id = None
+        self.type = type_
+        self._media_id = None
+        self._mimetype = ""
+        self._is_uploaded = False
+
+    def _load(self, raw: dict) -> None:
+        super()._load(raw)
+        # Verify this is a valid type
+        BlobType(raw["type"])
+        self.blob_id = raw.get("blob_id")
+        self._media_id = raw.get("media_id")
+        self._mimetype = raw.get("mimetype")
+
+    def save(self, clean: bool = True) -> dict:
+        """Save the node blob"""
+        ret = super().save(clean)
+        ret["kind"] = "notes#blob"
+        ret["type"] = self.type.value
+        if self.blob_id is not None:
+            ret["blob_id"] = self.blob_id
+        if self._media_id is not None:
+            ret["media_id"] = self._media_id
+        ret["mimetype"] = self._mimetype
+        return ret
+
+
+class NodeAudio(NodeBlob):
+    """Represents an audio blob."""
+
+    _TYPE = BlobType.Audio
+
+    def __init__(self) -> None:
+        """Construct a node audio blob"""
+        super().__init__(type_=self._TYPE)
+        self._length = None
+
+    def _load(self, raw: dict) -> None:
+        super()._load(raw)
+        self._length = raw.get("length")
+
+    def save(self, clean: bool = True) -> dict:
+        """Save the node audio blob"""
+        ret = super().save(clean)
+        if self._length is not None:
+            ret["length"] = self._length
+        return ret
+
+    @property
+    def length(self) -> int:
+        """Get length of the audio clip.
+
+        Returns:
+            Audio length.
+        """
+        return self._length
+
+
+class NodeImage(NodeBlob):
+    """Represents an image blob."""
+
+    _TYPE = BlobType.Image
+
+    def __init__(self) -> None:
+        """Construct a node image blob"""
+        super().__init__(type_=self._TYPE)
+        self._is_uploaded = False
+        self._width = 0
+        self._height = 0
+        self._byte_size = 0
+        self._extracted_text = ""
+        self._extraction_status = ""
+
+    def _load(self, raw: dict) -> None:
+        super()._load(raw)
+        self._is_uploaded = raw.get("is_uploaded") or False
+        self._width = raw.get("width")
+        self._height = raw.get("height")
+        self._byte_size = raw.get("byte_size")
+        self._extracted_text = raw.get("extracted_text")
+        self._extraction_status = raw.get("extraction_status")
+
+    def save(self, clean: bool = True) -> dict:
+        """Save the node image blob"""
+        ret = super().save(clean)
+        ret["width"] = self._width
+        ret["height"] = self._height
+        ret["byte_size"] = self._byte_size
+        ret["extracted_text"] = self._extracted_text
+        ret["extraction_status"] = self._extraction_status
+        return ret
+
+    @property
+    def width(self) -> int:
+        """Get width of image.
+
+        Returns:
+            Image width.
+        """
+        return self._width
+
+    @property
+    def height(self) -> int:
+        """Get height of image.
+
+        Returns:
+            Image height.
+        """
+        return self._height
+
+    @property
+    def byte_size(self) -> int:
+        """Get size of image in bytes.
+
+        Returns:
+            Image byte size.
+        """
+        return self._byte_size
+
+    @property
+    def extracted_text(self) -> str:
+        """Get text extracted from image
+
+        Returns:
+            Extracted text.
+        """
+        return self._extracted_text
+
+    @property
+    def url(self) -> str:
+        """Get a url to the image.
+
+        Returns:
+            Image url.
+        """
+        raise NotImplementedError
+
+
+class NodeDrawing(NodeBlob):
+    """Represents a drawing blob."""
+
+    _TYPE = BlobType.Drawing
+
+    def __init__(self) -> None:
+        """Construct a node drawing blob"""
+        super().__init__(type_=self._TYPE)
+        self._extracted_text = ""
+        self._extraction_status = ""
+        self._drawing_info = None
+
+    def _load(self, raw: dict) -> None:
+        super()._load(raw)
+        self._extracted_text = raw.get("extracted_text")
+        self._extraction_status = raw.get("extraction_status")
+        drawing_info = None
+        if "drawingInfo" in raw:
+            drawing_info = NodeDrawingInfo()
+            drawing_info.load(raw["drawingInfo"])
+        self._drawing_info = drawing_info
+
+    def save(self, clean: bool = True) -> dict:
+        """Save the node drawing blob"""
+        ret = super().save(clean)
+        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)
+        return ret
+
+    @property
+    def extracted_text(self) -> str:
+        """Get text extracted from image
+
+        Returns:
+            Extracted text.
+        """
+        return (
+            self._drawing_info.snapshot.extracted_text
+            if self._drawing_info is not None
+            else ""
+        )
+
+
+class NodeDrawingInfo(Element):
+    """Represents information about a drawing blob."""
+
+    def __init__(self) -> None:
+        """Construct a drawing info container"""
+        super().__init__()
+        self.drawing_id = ""
+        self.snapshot = NodeImage()
+        self._snapshot_fingerprint = ""
+        self._thumbnail_generated_time = NodeTimestamps.int_to_dt(0)
+        self._ink_hash = ""
+        self._snapshot_proto_fprint = ""
+
+    def _load(self, raw: dict) -> None:
+        super()._load(raw)
+        self.drawing_id = raw["drawingId"]
+        self.snapshot.load(raw["snapshotData"])
+        self._snapshot_fingerprint = (
+            raw["snapshotFingerprint"]
+            if "snapshotFingerprint" in raw
+            else self._snapshot_fingerprint
+        )
+        self._thumbnail_generated_time = (
+            NodeTimestamps.str_to_dt(raw["thumbnailGeneratedTime"])
+            if "thumbnailGeneratedTime" in raw
+            else NodeTimestamps.int_to_dt(0)
+        )
+        self._ink_hash = raw["inkHash"] if "inkHash" in raw else ""
+        self._snapshot_proto_fprint = (
+            raw["snapshotProtoFprint"]
+            if "snapshotProtoFprint" in raw
+            else self._snapshot_proto_fprint
+        )
+
+    def save(self, clean: bool = True) -> dict:  # noqa: D102
+        ret = super().save(clean)
+        ret["drawingId"] = self.drawing_id
+        ret["snapshotData"] = self.snapshot.save(clean)
+        ret["snapshotFingerprint"] = self._snapshot_fingerprint
+        ret["thumbnailGeneratedTime"] = NodeTimestamps.dt_to_str(
+            self._thumbnail_generated_time
+        )
+        ret["inkHash"] = self._ink_hash
+        ret["snapshotProtoFprint"] = self._snapshot_proto_fprint
+        return ret
+
+
+class Blob(Node):
+    """Represents a Google Keep blob."""
+
+    _blob_type_map = {  # noqa: RUF012
+        BlobType.Audio: NodeAudio,
+        BlobType.Image: NodeImage,
+        BlobType.Drawing: NodeDrawing,
+    }
+
+    def __init__(self, parent_id: str | None = None, **kwargs: dict) -> None:
+        """Construct a blob"""
+        super().__init__(type_=NodeType.Blob, parent_id=parent_id, **kwargs)
+        self.blob = None
+
+    @classmethod
+    def from_json(cls: type, raw: dict) -> NodeBlob | None:
+        """Helper to construct a blob from a dict.
+
+        Args:
+            raw: Raw blob representation.
+
+        Returns:
+            A NodeBlob object or None.
+        """
+        if raw is None:
+            return None
+
+        _type = raw.get("type")
+        if _type is None:
+            return None
+
+        bcls = None
+        try:
+            bcls = cls._blob_type_map[BlobType(_type)]
+        except (KeyError, ValueError) as e:
+            logger.warning("Unknown blob type: %s", _type)
+            if DEBUG:  # pragma: no cover
+                raise exception.ParseException(f"Parse error for {_type}", raw) from e
+            return None
+        blob = bcls()
+        blob.load(raw)
+
+        return blob
+
+    def _load(self, raw: dict) -> None:
+        super()._load(raw)
+        self.blob = self.from_json(raw.get("blob"))
+
+    def save(self, clean: bool = True) -> dict:
+        """Save the blob"""
+        ret = super().save(clean)
+        if self.blob is not None:
+            ret["blob"] = self.blob.save(clean)
+        return ret
+
+
+_type_map = {
+    NodeType.Note: Note,
+    NodeType.List: List,
+    NodeType.ListItem: ListItem,
+    NodeType.Blob: Blob,
+}
+
+
+def from_json(raw: dict) -> Node | None:
+    """Helper to construct a node from a dict.
+
+    Args:
+        raw: Raw node representation.
+
+    Returns:
+        A Node object or None.
+    """
+    ncls = None
+    _type = raw.get("type")
+    try:
+        ncls = _type_map[NodeType(_type)]
+    except (KeyError, ValueError) as e:
+        logger.warning("Unknown node type: %s", _type)
+        if DEBUG:  # pragma: no cover
+            raise exception.ParseException(f"Parse error for {_type}", raw) from e
+        return None
+    node = ncls()
+    node.load(raw)
+
+    return node
+
+
+if DEBUG:  # pragma: no cover
+    Node.__load = Node._load  # noqa: SLF001
+
+    def _load(self, raw):  # noqa: ANN001, ANN202
+        self.__load(raw)
+        self._find_discrepancies(raw)
+
+    Node._load = _load  # noqa: SLF001


Test patch:
diff --git a/test/test_client.py b/test/test_client.py
index a10fb45..ffbe08d 100644
--- a/test/test_client.py
+++ b/test/test_client.py
@@ -3,12 +3,7 @@
 import logging
 import gpsoauth
 import json
-import six
-
-if six.PY2:
-    import mock
-else:
-    from unittest import mock
+from unittest import mock
 
 from gkeepapi import Keep, node
 
diff --git a/test/test_nodes.py b/test/test_nodes.py
index 3c3334c..f0edbd7 100644
--- a/test/test_nodes.py
+++ b/test/test_nodes.py
@@ -1,10 +1,4 @@
 # -*- coding: utf-8 -*-
-import six
-if six.PY2:
-    import sys
-    reload(sys)
-    sys.setdefaultencoding('utf-8')
-
 import unittest
 import logging