├── .codeclimate.yml ├── .github └── workflows │ ├── lint.yml │ └── semgrep.yml ├── .gitignore ├── .readthedocs.yaml ├── ISSUE_TEMPLATE.md ├── LICENSE.txt ├── Makefile ├── README.md ├── docs ├── Makefile ├── conf.py ├── gkeepapi.rst └── index.rst ├── examples └── resume.py ├── pyproject.toml ├── src └── gkeepapi │ ├── __init__.py │ ├── exception.py │ └── node.py └── test ├── __init__.py ├── data ├── keep-00 ├── keep-01 ├── keep-02 ├── reminder-00 ├── reminder-01 ├── reminder-02 ├── reminder-03 ├── reminder-04 └── reminder-05 ├── test_client.py └── test_nodes.py /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | checks: 3 | method-complexity: 4 | enabled: false 5 | plugins: 6 | duplication: 7 | enabled: false 8 | 9 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches-ignore: 4 | - main 5 | jobs: 6 | build: 7 | name: Lint & Test 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | python-version: ["3.10"] 12 | steps: 13 | - uses: actions/checkout@v3 14 | - name: Set up Python ${{ matrix.python-version }} 15 | uses: actions/setup-python@v3 16 | with: 17 | python-version: ${{ matrix.python-version }} 18 | - name: Install tools 19 | run: | 20 | python -m pip install . '.[dev]' 21 | - name: Lint with ruff 22 | run: | 23 | ruff src 24 | - name: Check format with black 25 | run: | 26 | black --check src 27 | - name: Run tests 28 | run: | 29 | python -m unittest discover 30 | -------------------------------------------------------------------------------- /.github/workflows/semgrep.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches-ignore: 4 | - main 5 | name: Semgrep 6 | jobs: 7 | semgrep: 8 | name: Scan 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | - uses: returntocorp/semgrep-action@v1 13 | with: 14 | auditOn: push 15 | publishUrl: https://dev.semgrep.dev 16 | publishToken: ${{ secrets.SEMGREP_APP_TOKEN }} 17 | publishDeployment: 306 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | docs/_build/ 2 | build/ 3 | htmlcov/ 4 | dist/ 5 | *.swp 6 | *.pyc 7 | *.egg-info/ 8 | .coverage 9 | .ruff_cache/ 10 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | build: 3 | os: ubuntu-22.04 4 | tools: 5 | python: "3.11" 6 | sphinx: 7 | configuration: docs/conf.py 8 | python: 9 | install: 10 | - method: pip 11 | path: . 12 | extra_requirements: 13 | - dev 14 | -------------------------------------------------------------------------------- /ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Please make sure you've done the following before submitting your issue: 2 | 3 | - [ ] Check that you're running the newest version of the library. 4 | - [ ] If you're providing a stack trace, make sure it doesn't contain your password. 5 | - [ ] If you're getting a KeyError or ParseException, please follow the instructions [here](https://gkeepapi.readthedocs.io/en/latest/#reporting-errors) to dump the raw data. 6 | - [ ] If you're including any output, wrap them in triple quotes. 7 | 8 | Additionally, please provide the following information: 9 | 10 | - [ ] Operating system 11 | - [ ] Python version 12 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright 2017 Kai Zhong 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: lint test coverage build clean upload all 2 | 3 | lint: 4 | -ruff --fix src 5 | 6 | test: 7 | python3 -m unittest discover 8 | 9 | coverage: 10 | coverage run --source src -m unittest discover 11 | coverage report 12 | coverage html 13 | 14 | build: src/gkeepapi/*.py 15 | python3 -m build 16 | 17 | clean: 18 | rm -rf build dist 19 | 20 | upload: 21 | twine upload dist/*.whl 22 | 23 | all: lint test build upload 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | gkeepapi 2 | ======== 3 | 4 | [![Documentation Status](https://readthedocs.org/projects/gkeepapi/badge/?version=latest)](http://gkeepapi.readthedocs.io/en/latest/?badge=latest) 5 | [![Gitter chat](https://badges.gitter.im/gkeepapi/Lobby.png)](https://gitter.im/gkeepapi/Lobby) 6 | [![Test Coverage](https://api.codeclimate.com/v1/badges/4386792a941a156a14f0/test_coverage)](https://codeclimate.com/github/kiwiz/gkeepapi/test_coverage) 7 | 8 | ## NOTICE: Google offers an official [API](https://developers.google.com/keep/api) which might be an option if you have an Enterprise account. 🎉 9 | 10 | An unofficial client for the [Google Keep](https://keep.google.com) API. 11 | 12 | ```python 13 | import gkeepapi 14 | 15 | # Obtain a master token for your account (see docs) 16 | master_token = '...' 17 | 18 | keep = gkeepapi.Keep() 19 | success = keep.authenticate('user@gmail.com', master_token) 20 | 21 | note = keep.createNote('Todo', 'Eat breakfast') 22 | note.pinned = True 23 | note.color = gkeepapi.node.ColorValue.Red 24 | keep.sync() 25 | ``` 26 | 27 | *gkeepapi is not supported nor endorsed by Google.* 28 | 29 | The code is pretty stable at this point, but you should always make backups. The project is under development, so feel free to open an issue if you have questions, see any bugs or have a feature request. PRs are welcome too! 30 | 31 | ## Installation 32 | 33 | ``` 34 | pip install gkeepapi 35 | ``` 36 | 37 | ## Documentation 38 | 39 | The docs are available on [Read the Docs](https://gkeepapi.readthedocs.io/en/latest/). 40 | 41 | ## Todo (Open an issue if you'd like to help!) 42 | 43 | - Reminders 44 | - `reminders` 45 | - Figure out all possible values for `TaskAssist._suggest` (Same as CategoryValue?) 46 | - Figure out all possible values for `NodeImage._extraction_status` (integer) 47 | - Blobs (Drawings/Images/Recordings) 48 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = python3 -msphinx 7 | SPHINXPROJ = gkeepapi 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M html "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # gkeepapi documentation build configuration file, created by 4 | # sphinx-quickstart on Sat Oct 14 10:43:15 2017. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | # If extensions (or modules to document with autodoc) are in another directory, 16 | # add these directories to sys.path here. If the directory is relative to the 17 | # documentation root, use os.path.abspath to make it absolute, like shown here. 18 | # 19 | import os 20 | import sys 21 | 22 | sys.path.insert(0, os.path.abspath("../src")) 23 | import gkeepapi 24 | 25 | 26 | # -- General configuration ------------------------------------------------ 27 | 28 | # If your documentation needs a minimal Sphinx version, state it here. 29 | # 30 | # needs_sphinx = '1.0' 31 | 32 | # Add any Sphinx extension module names here, as strings. They can be 33 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 34 | # ones. 35 | extensions = [ 36 | "sphinx.ext.napoleon", 37 | "sphinx.ext.coverage", 38 | # "sphinx.ext.autodoc", 39 | "sphinx.ext.viewcode", 40 | "sphinx.ext.githubpages", 41 | ] 42 | 43 | # Add any paths that contain templates here, relative to this directory. 44 | templates_path = ["_templates"] 45 | 46 | # The suffix(es) of source filenames. 47 | # You can specify multiple suffix as a list of string: 48 | # 49 | # source_suffix = ['.rst', '.md'] 50 | source_suffix = ".rst" 51 | 52 | # The master toctree document. 53 | master_doc = "index" 54 | 55 | # General information about the project. 56 | project = "gkeepapi" 57 | copyright = "2017, Kai" 58 | author = "Kai" 59 | 60 | # The version info for the project you're documenting, acts as replacement for 61 | # |version| and |release|, also used in various other places throughout the 62 | # built documents. 63 | # 64 | # The short X.Y version. 65 | version = gkeepapi.__version__ 66 | # The full version, including alpha/beta/rc tags. 67 | release = gkeepapi.__version__ 68 | 69 | # The language for content autogenerated by Sphinx. Refer to documentation 70 | # for a list of supported languages. 71 | # 72 | # This is also used if you do content translation via gettext catalogs. 73 | # Usually you set "language" from the command line for these cases. 74 | language = "en" 75 | 76 | # List of patterns, relative to source directory, that match files and 77 | # directories to ignore when looking for source files. 78 | # This patterns also effect to html_static_path and html_extra_path 79 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 80 | 81 | # The name of the Pygments (syntax highlighting) style to use. 82 | pygments_style = "sphinx" 83 | 84 | # If true, `todo` and `todoList` produce output, else they produce nothing. 85 | todo_include_todos = False 86 | 87 | 88 | # -- Options for HTML output ---------------------------------------------- 89 | 90 | # The theme to use for HTML and HTML Help pages. See the documentation for 91 | # a list of builtin themes. 92 | # 93 | html_theme = "alabaster" 94 | 95 | # Theme options are theme-specific and customize the look and feel of a theme 96 | # further. For a list of options available for each theme, see the 97 | # documentation. 98 | # 99 | # html_theme_options = {} 100 | 101 | # Add any paths that contain custom static files (such as style sheets) here, 102 | # relative to this directory. They are copied after the builtin static files, 103 | # so a file named "default.css" will overwrite the builtin "default.css". 104 | html_static_path = [] 105 | 106 | # Custom sidebar templates, must be a dictionary that maps document names 107 | # to template names. 108 | # 109 | # This is required for the alabaster theme 110 | # refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars 111 | html_sidebars = { 112 | "**": [ 113 | "about.html", 114 | "navigation.html", 115 | "relations.html", # needs 'show_related': True theme option to display 116 | "searchbox.html", 117 | "donate.html", 118 | ] 119 | } 120 | 121 | 122 | # -- Options for HTMLHelp output ------------------------------------------ 123 | 124 | # Output file base name for HTML help builder. 125 | htmlhelp_basename = "gkeepapidoc" 126 | 127 | 128 | # -- Options for LaTeX output --------------------------------------------- 129 | 130 | latex_elements = { 131 | # The paper size ('letterpaper' or 'a4paper'). 132 | # 133 | # 'papersize': 'letterpaper', 134 | # The font size ('10pt', '11pt' or '12pt'). 135 | # 136 | # 'pointsize': '10pt', 137 | # Additional stuff for the LaTeX preamble. 138 | # 139 | # 'preamble': '', 140 | # Latex figure (float) alignment 141 | # 142 | # 'figure_align': 'htbp', 143 | } 144 | 145 | # Grouping the document tree into LaTeX files. List of tuples 146 | # (source start file, target name, title, 147 | # author, documentclass [howto, manual, or own class]). 148 | latex_documents = [ 149 | (master_doc, "gkeepapi.tex", "gkeepapi Documentation", "Kai", "manual"), 150 | ] 151 | 152 | 153 | # -- Options for manual page output --------------------------------------- 154 | 155 | # One entry per manual page. List of tuples 156 | # (source start file, name, description, authors, manual section). 157 | man_pages = [(master_doc, "gkeepapi", "gkeepapi Documentation", [author], 1)] 158 | 159 | 160 | # -- Options for Texinfo output ------------------------------------------- 161 | 162 | # Grouping the document tree into Texinfo files. List of tuples 163 | # (source start file, target name, title, author, 164 | # dir menu entry, description, category) 165 | texinfo_documents = [ 166 | ( 167 | master_doc, 168 | "gkeepapi", 169 | "gkeepapi Documentation", 170 | author, 171 | "gkeepapi", 172 | "One line description of project.", 173 | "Miscellaneous", 174 | ), 175 | ] 176 | -------------------------------------------------------------------------------- /docs/gkeepapi.rst: -------------------------------------------------------------------------------- 1 | gkeepapi package 2 | ================ 3 | 4 | Submodules 5 | ---------- 6 | 7 | gkeepapi\.node module 8 | --------------------- 9 | 10 | .. automodule:: gkeepapi.node 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | 16 | Module contents 17 | --------------- 18 | 19 | .. automodule:: gkeepapi 20 | :members: 21 | :undoc-members: 22 | :show-inheritance: 23 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. gkeepapi documentation master file, created by 2 | sphinx-quickstart on Sat Oct 14 10:43:15 2017. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | .. py:currentmodule:: gkeepapi 6 | 7 | Welcome to gkeepapi's documentation! 8 | ==================================== 9 | 10 | .. contents:: 11 | 12 | **gkeepapi** is an unofficial client for programmatically interacting with Google Keep:: 13 | 14 | import gkeepapi 15 | 16 | # Obtain a master token for your account 17 | master_token = '...' 18 | 19 | keep = gkeepapi.Keep() 20 | keep.authenticate('user@gmail.com', master_token) 21 | 22 | note = keep.createNote('Todo', 'Eat breakfast') 23 | note.pinned = True 24 | note.color = gkeepapi.node.ColorValue.Red 25 | 26 | keep.sync() 27 | 28 | print(note.title) 29 | print(note.text) 30 | 31 | The client is mostly complete and ready for use, but there are some hairy spots. In particular, the interface for manipulating labels and blobs is subject to change. 32 | 33 | Client Usage 34 | ============ 35 | 36 | All interaction with Google Keep is done through a :py:class:`Keep` object, which is responsible for authenticating, syncing changes and tracking modifications. 37 | 38 | Authenticating 39 | -------------- 40 | 41 | The client uses the (private) mobile Google Keep API. A valid OAuth token is generated via :py:mod:`gpsoauth`, which requires a master token for the account. These tokens are so called because they have full access to your account. Protect them like you would a password:: 42 | 43 | keep = gkeepapi.Keep() 44 | keep.authenticate('user@gmail.com', master_token) 45 | 46 | Rather than storing the token in the script, consider using your platform secrets store:: 47 | 48 | import keyring 49 | 50 | # To save the token 51 | # ... 52 | # keyring.set_password('google-keep-token', 'user@gmail.com', master_token) 53 | 54 | master_token = keyring.get_password("google-keep-token", "user@gmail.com") 55 | 56 | There is also a deprecated :py:meth:`Keep.login` method which accepts a username and password. This is discouraged (and unlikely to work), due to increased security requirements on logins:: 57 | 58 | keep.login('user@gmail.com', 'password') 59 | 60 | Obtaining a Master Token 61 | ------------------------ 62 | 63 | Instructions can be found in the gpsoauth `documentation `__. If you have Docker installed, the following one-liner prompts for the necessary information and outputs the token:: 64 | 65 | docker run --rm -it --entrypoint /bin/sh python:3 -c 'pip install gpsoauth; python3 -c '\''print(__import__("gpsoauth").exchange_token(input("Email: "), input("OAuth Token: "), input("Android ID: ")))'\' 66 | 67 | 68 | Syncing 69 | ------- 70 | 71 | gkeepapi automatically pulls down all notes after authenticating. It takes care of refreshing API tokens, so there's no need to call :py:meth:`Keep.authenticate` again. After making any local modifications to notes, make sure to call :py:meth:`Keep.sync` to update them on the server!:: 72 | 73 | keep.sync() 74 | 75 | Caching notes 76 | ------------- 77 | 78 | The initial sync can take a while, especially if you have a lot of notes. To mitigate this, you can serialize note data to a file. The next time your program runs, it can resume from this state. This is handled via :py:meth:`Keep.dump` and :py:meth:`Keep.restore`:: 79 | 80 | # Store cache 81 | state = keep.dump() 82 | fh = open('state', 'w') 83 | json.dump(state, fh) 84 | 85 | # Load cache 86 | fh = open('state', 'r') 87 | state = json.load(fh) 88 | keep.restore(state) 89 | 90 | You can also pass the state directly to the :py:meth:`Keep.authenticate` and the (deprecated) :py:meth:`Keep.login` methods:: 91 | 92 | keep.authenticate(username, master_token, state=state) 93 | keep.login(username, password, state=state) 94 | 95 | Notes and Lists 96 | =============== 97 | 98 | Notes and Lists are the primary types of notes visible to a Google Keep user. gkeepapi exposes these two notes via the :py:class:`node.Note` and :py:class:`node.List` classes. For Lists, there's also the :py:class:`node.ListItem` class. 99 | 100 | Creating Notes 101 | -------------- 102 | 103 | New notes are created with the :py:meth:`Keep.createNote` and :py:meth:`Keep.createList` methods. The :py:class:`Keep` object keeps track of these objects and, upon :py:meth:`Keep.sync`, will sync them if modifications have been made:: 104 | 105 | gnote = keep.createNote('Title', 'Text') 106 | 107 | glist = keep.createList('Title', [ 108 | ('Item 1', False), # Not checked 109 | ('Item 2', True) # Checked 110 | ]) 111 | 112 | # Sync up changes 113 | keep.sync() 114 | 115 | Getting Notes 116 | ------------- 117 | 118 | Notes can be retrieved via :py:meth:`Keep.get` by their ID (visible in the URL when selecting a Note in the webapp):: 119 | 120 | gnote = keep.get('...') 121 | 122 | To fetch all notes, use :py:meth:`Keep.all`:: 123 | 124 | gnotes = keep.all() 125 | 126 | Searching for Notes 127 | ------------------- 128 | 129 | Notes can be searched for via :py:meth:`Keep.find`:: 130 | 131 | # Find by string 132 | gnotes = keep.find(query='Title') 133 | 134 | # Find by filter function 135 | gnotes = keep.find(func=lambda x: x.deleted and x.title == 'Title') 136 | 137 | # Find by labels 138 | gnotes = keep.find(labels=[keep.findLabel('todo')]) 139 | 140 | # Find by colors 141 | gnotes = keep.find(colors=[gkeepapi.node.ColorValue.White]) 142 | 143 | # Find by pinned/archived/trashed state 144 | gnotes = keep.find(pinned=True, archived=False, trashed=False) 145 | 146 | Manipulating Notes 147 | ------------------ 148 | 149 | Note objects have many attributes that can be directly get and set. Here is a non-comprehensive list of the more interesting ones. 150 | 151 | Notes and Lists: 152 | 153 | * :py:attr:`node.TopLevelNode.id` (Read only) 154 | * :py:attr:`node.TopLevelNode.parent` (Read only) 155 | * :py:attr:`node.TopLevelNode.title` 156 | * :py:attr:`node.TopLevelNode.text` 157 | * :py:attr:`node.TopLevelNode.color` 158 | * :py:attr:`node.TopLevelNode.archived` 159 | * :py:attr:`node.TopLevelNode.pinned` 160 | * :py:attr:`node.TopLevelNode.labels` 161 | * :py:attr:`node.TopLevelNode.annotations` 162 | * :py:attr:`node.TopLevelNode.timestamps` (Read only) 163 | * :py:attr:`node.TopLevelNode.collaborators` 164 | * :py:attr:`node.TopLevelNode.blobs` (Read only) 165 | * :py:attr:`node.TopLevelNode.drawings` (Read only) 166 | * :py:attr:`node.TopLevelNode.images` (Read only) 167 | * :py:attr:`node.TopLevelNode.audio` (Read only) 168 | 169 | ListItems: 170 | 171 | * :py:attr:`node.TopLevelNode.id` (Read only) 172 | * :py:attr:`node.TopLevelNode.parent` (Read only) 173 | * :py:attr:`node.TopLevelNode.parent_item` (Read only) 174 | * :py:attr:`node.TopLevelNode.indented` (Read only) 175 | * :py:attr:`node.TopLevelNode.text` 176 | * :py:attr:`node.TopLevelNode.checked` 177 | 178 | Getting Note content 179 | ^^^^^^^^^^^^^^^^^^^^ 180 | 181 | Example usage:: 182 | 183 | print gnote.title 184 | print gnote.text 185 | 186 | Getting List content 187 | ^^^^^^^^^^^^^^^^^^^^ 188 | 189 | Retrieving the content of a list is slightly more nuanced as they contain multiple entries. To get a serialized version of the contents, simply access :py:attr:`node.List.text` as usual. To get the individual :py:class:`node.ListItem` objects, access :py:attr:`node.List.items`:: 190 | 191 | # Serialized content 192 | print glist.text 193 | 194 | # ListItem objects 195 | glistitems = glist.items 196 | 197 | # Checked ListItems 198 | cglistitems = glist.checked 199 | 200 | # Unchecked ListItems 201 | uglistitems = glist.unchecked 202 | 203 | Setting Note content 204 | ^^^^^^^^^^^^^^^^^^^^ 205 | 206 | Example usage:: 207 | 208 | gnote.title = 'Title 2' 209 | gnote.text = 'Text 2' 210 | gnote.color = gkeepapi.node.ColorValue.White 211 | gnote.archived = True 212 | gnote.pinned = False 213 | 214 | Setting List content 215 | ^^^^^^^^^^^^^^^^^^^^ 216 | 217 | New items can be added via :py:meth:`node.List.add`:: 218 | 219 | # Create a checked item 220 | glist.add('Item 2', True) 221 | 222 | # Create an item at the top of the list 223 | glist.add('Item 1', True, gkeepapi.node.NewListItemPlacementValue.Top) 224 | 225 | # Create an item at the bottom of the list 226 | glist.add('Item 3', True, gkeepapi.node.NewListItemPlacementValue.Bottom) 227 | 228 | Existing items can be retrieved and modified directly:: 229 | 230 | glistitem = glist.items[0] 231 | glistitem.text = 'Item 4' 232 | glistitem.checked = True 233 | 234 | Or deleted via :py:meth:`node.ListItem.delete`:: 235 | 236 | glistitem.delete() 237 | 238 | Setting List item position 239 | ^^^^^^^^^^^^^^^^^^^^^^^^^^ 240 | 241 | To reposition an item (larger is closer to the top):: 242 | 243 | # Set a specific sort id 244 | glistitem1.sort = 42 245 | 246 | # Swap the position of two items 247 | val = glistitem2.sort 248 | glistitem2.sort = glistitem3.sort 249 | glistitem3.sort = val 250 | 251 | Sorting a List 252 | ^^^^^^^^^^^^^^ 253 | 254 | Lists can be sorted via :py:meth:`node.List.sort_items`:: 255 | 256 | # Sorts items alphabetically by default 257 | glist.sort_items() 258 | 259 | Indent/dedent List items 260 | ^^^^^^^^^^^^^^^^^^^^^^^^ 261 | 262 | To indent a list item:: 263 | 264 | gparentlistitem.indent(gchildlistitem) 265 | 266 | To dedent:: 267 | 268 | gparentlistitem.dedent(gchildlistitem) 269 | 270 | Deleting Notes 271 | -------------- 272 | 273 | The :py:meth:`node.TopLevelNode.delete` method marks the note for deletion (or undo):: 274 | 275 | gnote.delete() 276 | gnote.undelete() 277 | 278 | To send the node to the trash instead (or undo):: 279 | 280 | gnote.trash() 281 | gnote.untrash() 282 | 283 | Media 284 | ===== 285 | 286 | Media blobs are images, drawings and audio clips that are attached to notes. 287 | 288 | Accessing media 289 | --------------- 290 | 291 | Drawings: 292 | 293 | * :py:attr:`node.NodeDrawing.extracted_text` (Read only) 294 | 295 | Images: 296 | 297 | * :py:attr:`node.NodeImage.width` (Read only) 298 | * :py:attr:`node.NodeImage.height` (Read only) 299 | * :py:attr:`node.NodeImage.byte_size` (Read only) 300 | * :py:attr:`node.NodeImage.extracted_text` (Read only) 301 | 302 | Audio: 303 | 304 | * :py:attr:`node.NodeAudio.length` (Read only) 305 | 306 | Fetching media 307 | -------------- 308 | 309 | To download media, you can use the :py:meth:`Keep.getMediaLink` method to get a link:: 310 | 311 | blob = gnote.images[0] 312 | keep.getMediaLink(blob) 313 | 314 | Labels 315 | ====== 316 | 317 | Labels are short identifiers that can be assigned to notes. Labels are exposed via the :py:class:`node.Label` class. Management is a bit unwieldy right now and is done via the :py:class:`Keep` object. Like notes, labels are automatically tracked and changes are synced to the server. 318 | 319 | Getting Labels 320 | -------------- 321 | 322 | Labels can be retrieved via :py:meth:`Keep.getLabel` by their ID:: 323 | 324 | label = keep.getLabel('...') 325 | 326 | To fetch all labels, use :py:meth:`Keep.labels`:: 327 | 328 | labels = keep.labels() 329 | 330 | Searching for Labels 331 | -------------------- 332 | 333 | Most of the time, you'll want to find a label by name. For that, use :py:meth:`Keep.findLabel`:: 334 | 335 | label = keep.findLabel('todo') 336 | 337 | Regular expressions are also supported here:: 338 | 339 | label = keep.findLabel(re.compile('^todo$')) 340 | 341 | Creating Labels 342 | --------------- 343 | 344 | New labels can be created with :py:meth:`Keep.createLabel`:: 345 | 346 | label = keep.createLabel('todo') 347 | 348 | Editing Labels 349 | -------------- 350 | 351 | A label's name can be updated directly:: 352 | 353 | label.name = 'later' 354 | 355 | Deleting Labels 356 | --------------- 357 | 358 | A label can be deleted with :py:meth:`Keep.deleteLabel`. This method ensures the label is removed from all notes:: 359 | 360 | keep.deleteLabel(label) 361 | 362 | Manipulating Labels on Notes 363 | ---------------------------- 364 | 365 | When working with labels and notes, the key point to remember is that we're always working with :py:class:`node.Label` objects or IDs. Interaction is done through the :py:class:`node.NodeLabels` class. 366 | 367 | To add a label to a note:: 368 | 369 | gnote.labels.add(label) 370 | 371 | To check if a label is on a note:: 372 | 373 | gnote.labels.get(label.id) != None 374 | 375 | To remove a label from a note:: 376 | 377 | gnote.labels.remove(label) 378 | 379 | Constants 380 | ========= 381 | 382 | - :py:class:`node.ColorValue` enumerates valid colors. 383 | - :py:class:`node.CategoryValue` enumerates valid note categories. 384 | - :py:class:`node.CheckedListItemsPolicyValue` enumerates valid policies for checked list items. 385 | - :py:class:`node.GraveyardStateValue` enumerates valid visibility settings for checked list items. 386 | - :py:class:`node.NewListItemPlacementValue` enumerates valid locations for new list items. 387 | - :py:class:`node.NodeType` enumerates valid node types. 388 | - :py:class:`node.BlobType` enumerates valid blob types. 389 | - :py:class:`node.RoleValue` enumerates valid collaborator permissions. 390 | - :py:class:`node.ShareRequestValue` enumerates vaild collaborator modification requests. 391 | - :py:class:`node.SuggestValue` enumerates valid suggestion types. 392 | 393 | Annotations 394 | =========== 395 | 396 | READ ONLY 397 | TODO 398 | 399 | Settings 400 | ======== 401 | 402 | TODO 403 | 404 | Collaborators 405 | ============= 406 | 407 | Collaborators are users you've shared notes with. Access can be granted or revoked per note. Interaction is done through the :py:class:`node.NodeCollaborators` class. 408 | 409 | To add a collaborator to a note:: 410 | 411 | gnote.collaborators.add(email) 412 | 413 | To check if a collaborator has access to a note:: 414 | 415 | email in gnote.collaborators.all() 416 | 417 | To remove a collaborator from a note:: 418 | 419 | gnote.collaborators.remove(email) 420 | 421 | Timestamps 422 | ========== 423 | 424 | All notes and lists have a :py:class:`node.NodeTimestamps` object with timestamp data:: 425 | 426 | node.timestamps.created 427 | node.timestamps.deleted 428 | node.timestamps.trashed 429 | node.timestamps.updated 430 | node.timestamps.edited 431 | 432 | These timestamps are all read-only. 433 | 434 | FAQ 435 | === 436 | 437 | 1. I get a "NeedsBrowser", "CaptchaRequired" or "BadAuthentication" :py:class:`exception.LoginException` when I try to log in. (Not an issue when using :py:meth:`Keep.authenticate`) 438 | 439 | This usually occurs when Google thinks the login request looks suspicious. Here are some steps you can take to resolve this: 440 | 441 | 1. Make sure you have the newest version of gkeepapi installed. 442 | 2. Instead of logging in every time, cache the authentication token and reuse it on subsequent runs. See `here `__ for an example implementation. 443 | 3. If you have 2-Step Verification turned on, generating an App Password for gkeepapi is highly recommended. 444 | 4. Upgrading to a newer version of Python (3.7+) has worked for some people. See this `issue `__ for more information. 445 | 5. If all else fails, try testing gkeepapi on a separate IP address and/or user to see if you can isolate the problem. 446 | 447 | 2. I get a "DeviceManagementRequiredOrSyncDisabled" :py:class:`exception.LoginException` when I try to log in. (Not an issue when using :py:meth:`Keep.authenticate`) 448 | 449 | 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 `__. 450 | 451 | 3. My notes take a long time to sync 452 | 453 | Follow the instructions in the caching notes section and see if that helps. If you only need to update notes, you can try creating a new Google account. Share the notes to the new account and manage through there. 454 | 455 | Known Issues 456 | ============ 457 | 458 | 1. :py:class:`node.ListItem` consistency 459 | 460 | The :py:class:`Keep` class isn't aware of new :py:class:`node.ListItem` objects till they're synced up to the server. In other words, :py:meth:`Keep.get` calls for their IDs will fail. 461 | 462 | Debug 463 | ===== 464 | 465 | To enable development debug logs:: 466 | 467 | gkeepapi.node.DEBUG = True 468 | 469 | Notes 470 | ===== 471 | 472 | - Many sub-elements are read only. 473 | - :py:class:`node.Node` specific :py:class:`node.NewListItemPlacementValue` settings are not used. 474 | 475 | Reporting errors 476 | ---------------- 477 | 478 | Google occasionally ramps up changes to the Keep data format. When this happens, you'll likely get a :py:class:`exception.ParseException`. Please report this on Github with the raw data, which you can grab like so:: 479 | 480 | try: 481 | # Code that raises the exception 482 | except gkeepapi.exception.ParseException as e: 483 | print(e.raw) 484 | 485 | If you're not getting an :py:class:`exception.ParseException`, just a log line, make sure you've enabled debug mode. 486 | 487 | 488 | Indices and tables 489 | ================== 490 | 491 | * :ref:`genindex` 492 | * :ref:`modindex` 493 | * :ref:`search` 494 | -------------------------------------------------------------------------------- /examples/resume.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import sys 3 | import keyring 4 | import getpass 5 | import logging 6 | import gkeepapi 7 | 8 | USERNAME = "user@gmail.com" 9 | 10 | 11 | # Set up logging 12 | logger = logging.getLogger("gkeepapi") 13 | logger.setLevel(logging.INFO) 14 | ch = logging.StreamHandler(sys.stdout) 15 | formatter = logging.Formatter("[%(levelname)s] %(message)s") 16 | ch.setFormatter(formatter) 17 | logger.addHandler(ch) 18 | 19 | # Initialize the client 20 | keep = gkeepapi.Keep() 21 | 22 | token = keyring.get_password("google-keep-token", USERNAME) 23 | store_token = False 24 | 25 | if not token: 26 | token = getpass.getpass("Master token: ") 27 | store_token = True 28 | 29 | # Authenticate using a master token 30 | logger.info("Authenticating") 31 | try: 32 | keep.authenticate(USERNAME, token, sync=False) 33 | logger.info("Success") 34 | except gkeepapi.exception.LoginException: 35 | logger.error("Failed to authenticate") 36 | sys.exit(1) 37 | 38 | if store_token: 39 | keyring.set_password("google-keep-token", USERNAME, token) 40 | 41 | # Sync state down 42 | keep.sync() 43 | 44 | # Application logic 45 | # ... 46 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "flit_core >=3.2,<4", 4 | ] 5 | build-backend = "flit_core.buildapi" 6 | 7 | [project] 8 | name = "gkeepapi" 9 | version = "0.16.0" 10 | authors = [ 11 | { name="Kai", email="z@kwi.li" }, 12 | ] 13 | description = "An unofficial Google Keep API client" 14 | readme = "README.md" 15 | requires-python = ">=3.10" 16 | classifiers = [ 17 | "Programming Language :: Python :: 3", 18 | "License :: OSI Approved :: MIT License", 19 | "Development Status :: 4 - Beta", 20 | "Operating System :: OS Independent", 21 | "Intended Audience :: Developers", 22 | "Topic :: Internet :: WWW/HTTP", 23 | "Topic :: Software Development :: Libraries :: Python Modules", 24 | ] 25 | dependencies = [ 26 | "gpsoauth >= 1.1.0", 27 | "future >= 0.16.0", 28 | ] 29 | 30 | [project.optional-dependencies] 31 | dev = [ 32 | "ruff>=0.1.14", 33 | "coverage>=7.2.5", 34 | "Sphinx>=7.2.6" 35 | ] 36 | 37 | [project.urls] 38 | "Homepage" = "https://github.com/kiwiz/gkeepapi" 39 | "Bug Tracker" = "https://github.com/kiwiz/gkeepapi/issues" 40 | 41 | [tool.ruff] 42 | target-version = "py310" 43 | select = [ 44 | # https://beta.ruff.rs/docs/rules/ 45 | "F", # pyflakes 46 | "E", # pycodestyle error 47 | "W", # pycodestyle warning 48 | "C90", # mccabe 49 | "I", # isort 50 | "N", # pep8-naming 51 | "D", # pydocstyle 52 | "UP", # pyupgrade 53 | "YTT", # flake8-2020 54 | "ANN", # flake8-annotations 55 | "ASYNC", # flake8-async 56 | "S", # flake8-bandit 57 | "BLE", # flake8-blind-except 58 | # "FBT", # flake8-boolean-trap 59 | "B", # flake8-bugbear 60 | "A", # flake8-builtins 61 | "COM", # flake8-commas 62 | "CPY", # flake8-copyright 63 | "C4", # flake8-comprehensions 64 | "DTZ", # flake8-datetimez 65 | "T10", # flake8-debugger 66 | "DJ", # flake8-django 67 | "EM", # flake8-errmsg 68 | "EXE", # flake8-executable 69 | "FA", # flake8-future-annotations 70 | "ISC", # flake8-implicit-str-concat 71 | "ICN", # flake8-import-conventions 72 | "G", # flake8-logging-format 73 | "INP", # flake8-no-pep420 74 | "PIE", # flake8-pie 75 | "T20", # flake8-print 76 | "PYI", # flake8-pyi 77 | "PT", # flake8-pytest-style 78 | "Q", # flake8-quotes 79 | "RSE", # flake8-raise 80 | "RET", # flake8-return 81 | "SLF", # flake8-self 82 | "SLOT", # flake8-slots 83 | "SIM", # flake8-simplify 84 | "TID", # flake8-tidy-imports 85 | "TCH", # flake8-type-checking 86 | "INT", # flake8-gettext 87 | "ARG", # flake8-unused-arguments 88 | "PTH", # flake8-use-pathlib 89 | # "TD", # flake-todos 90 | # "FIX", # flake-fixme 91 | # "ERA", # eradicate 92 | "PD", # pandas-vet 93 | "PGH", # pygrep-hooks 94 | "PLC", # pylint convention 95 | "PLE", # pylint error 96 | "PLR", # pylint refactor 97 | "PLW", # pylint warning 98 | # "TRY", # tryceratops 99 | "FLY", # flynt 100 | "NPY", # numpy-specific 101 | "AIR", # airflow-specific 102 | "PERF", # performance 103 | "FURB", # refurb 104 | "RUF", # ruff-specific 105 | ] 106 | ignore = [ 107 | "E501", # line-too-long -- disabled as black takes care of this 108 | "COM812", # missing-trailing-comma -- conflicts with black? 109 | "N802", # invalid-function-name -- too late! 110 | "N818", # error-suffix-on-exception-name -- too late! 111 | "D415", # ends-in-punctuation -- too aggressive 112 | "EM101", # raw-string-in-exception -- no thanks 113 | "EM102", # f-string-in-exception -- no thanks 114 | "PLR0913", # too-many-arguments -- no thanks 115 | "D105", # undocumented-magic-method -- no thanks 116 | "ANN101", # missing-type-self -- unnecessary 117 | "ANN102", # missing-type-cls -- unnecessary 118 | ] 119 | 120 | [tool.ruff.pydocstyle] 121 | convention = "google" 122 | -------------------------------------------------------------------------------- /src/gkeepapi/__init__.py: -------------------------------------------------------------------------------- 1 | """.. moduleauthor:: Kai """ 2 | 3 | __version__ = "0.16.0" 4 | 5 | import datetime 6 | import http 7 | import logging 8 | import random 9 | import re 10 | import time 11 | from collections.abc import Callable, Iterator 12 | from typing import IO, Any 13 | from uuid import getnode as get_mac 14 | 15 | import gpsoauth 16 | import requests 17 | 18 | from . import exception 19 | from . import node as _node 20 | 21 | logger = logging.getLogger(__name__) 22 | 23 | 24 | class APIAuth: 25 | """Authentication token manager""" 26 | 27 | def __init__(self, scopes: str) -> None: 28 | """Construct API authentication manager""" 29 | self._master_token = None 30 | self._auth_token = None 31 | self._email = None 32 | self._device_id = None 33 | self._scopes = scopes 34 | 35 | def login(self, email: str, password: str, device_id: str) -> None: 36 | """Authenticate to Google with the provided credentials. 37 | 38 | Args: 39 | email: The account to use. 40 | password: The account password. 41 | device_id: An identifier for this client. 42 | 43 | Raises: 44 | LoginException: If there was a problem logging in. 45 | """ 46 | self._email = email 47 | self._device_id = device_id 48 | 49 | # Obtain a master token. 50 | res = gpsoauth.perform_master_login(self._email, password, self._device_id) 51 | 52 | # Bail if browser login is required. 53 | if res.get("Error") == "NeedsBrowser": 54 | raise exception.BrowserLoginRequiredException(res.get("Url")) 55 | 56 | # Bail if no token was returned. 57 | if "Token" not in res: 58 | raise exception.LoginException(res.get("Error"), res.get("ErrorDetail")) 59 | 60 | self._master_token = res["Token"] 61 | 62 | # Obtain an OAuth token. 63 | self.refresh() 64 | 65 | def load(self, email: str, master_token: str, device_id: str) -> bool: 66 | """Authenticate to Google with the provided master token. 67 | 68 | Args: 69 | email: The account to use. 70 | master_token: The master token. 71 | device_id: An identifier for this client. 72 | 73 | Raises: 74 | LoginException: If there was a problem logging in. 75 | """ 76 | self._email = email 77 | self._device_id = device_id 78 | self._master_token = master_token 79 | 80 | # Obtain an OAuth token. 81 | self.refresh() 82 | return True 83 | 84 | def getMasterToken(self) -> str: 85 | """Gets the master token. 86 | 87 | Returns: 88 | The account master token. 89 | """ 90 | return self._master_token 91 | 92 | def setMasterToken(self, master_token: str) -> None: 93 | """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. 94 | 95 | Args: 96 | master_token: The account master token. 97 | """ 98 | self._master_token = master_token 99 | 100 | def getEmail(self) -> str: 101 | """Gets the account email. 102 | 103 | Returns: 104 | The account email. 105 | """ 106 | return self._email 107 | 108 | def setEmail(self, email: str) -> None: 109 | """Sets the account email. 110 | 111 | Args: 112 | email: The account email. 113 | """ 114 | self._email = email 115 | 116 | def getDeviceId(self) -> str: 117 | """Gets the device id. 118 | 119 | Returns: 120 | The device id. 121 | """ 122 | return self._device_id 123 | 124 | def setDeviceId(self, device_id: str) -> None: 125 | """Sets the device id. 126 | 127 | Args: 128 | device_id: The device id. 129 | """ 130 | self._device_id = device_id 131 | 132 | def getAuthToken(self) -> str | None: 133 | """Gets the auth token. 134 | 135 | Returns: 136 | The auth token. 137 | """ 138 | return self._auth_token 139 | 140 | def refresh(self) -> str: 141 | """Refresh the OAuth token. 142 | 143 | Returns: 144 | The auth token. 145 | 146 | Raises: 147 | LoginException: If there was a problem refreshing the OAuth token. 148 | """ 149 | # Obtain an OAuth token with the necessary scopes by pretending to be 150 | # the keep android client. 151 | res = gpsoauth.perform_oauth( 152 | self._email, 153 | self._master_token, 154 | self._device_id, 155 | service=self._scopes, 156 | app="com.google.android.keep", 157 | client_sig="38918a453d07199354f8b19af05ec6562ced5788", 158 | ) 159 | # Bail if no token was returned. 160 | if "Auth" not in res and "Token" not in res: 161 | raise exception.LoginException(res.get("Error")) 162 | 163 | self._auth_token = res["Auth"] 164 | return self._auth_token 165 | 166 | def logout(self) -> None: 167 | """Log out of the account.""" 168 | self._master_token = None 169 | self._auth_token = None 170 | self._email = None 171 | self._device_id = None 172 | 173 | 174 | class API: 175 | """Base API wrapper""" 176 | 177 | RETRY_CNT = 2 178 | 179 | def __init__(self, base_url: str, auth: APIAuth | None = None) -> None: 180 | """Construct a low-level API client""" 181 | self._session = requests.Session() 182 | self._auth = auth 183 | self._base_url = base_url 184 | self._session.headers.update( 185 | { 186 | "User-Agent": "x-gkeepapi/%s (https://github.com/kiwiz/gkeepapi)" 187 | % __version__ 188 | } 189 | ) 190 | 191 | def getAuth(self) -> APIAuth: 192 | """Get authentication details for this API. 193 | 194 | Return: 195 | auth: The auth object 196 | """ 197 | return self._auth 198 | 199 | def setAuth(self, auth: APIAuth) -> None: 200 | """Set authentication details for this API. 201 | 202 | Args: 203 | auth: The auth object 204 | """ 205 | self._auth = auth 206 | 207 | def send(self, **req_kwargs: dict) -> dict: 208 | """Send an authenticated request to a Google API. Automatically retries if the access token has expired. 209 | 210 | Args: 211 | **req_kwargs: Arbitrary keyword arguments to pass to Requests. 212 | 213 | Return: 214 | The parsed JSON response. 215 | 216 | Raises: 217 | APIException: If the server returns an error. 218 | LoginException: If session is not authenticated. 219 | """ 220 | # Send a request to the API servers, with retry handling. OAuth tokens 221 | # are valid for several hours (as of this comment). 222 | i = 0 223 | while True: 224 | # Send off the request. If there was no error, we're good. 225 | response = self._send(**req_kwargs).json() 226 | if "error" not in response: 227 | break 228 | 229 | # Otherwise, check if it was a non-401 response code. These aren't 230 | # handled, so bail. 231 | error = response["error"] 232 | if error["code"] != http.HTTPStatus.UNAUTHORIZED: 233 | raise exception.APIException(error["code"], error) 234 | 235 | # If we've exceeded the retry limit, also bail. 236 | if i >= self.RETRY_CNT: 237 | raise exception.APIException(error["code"], error) 238 | 239 | # Otherwise, try requesting a new OAuth token. 240 | logger.info("Refreshing access token") 241 | self._auth.refresh() 242 | i += 1 243 | 244 | return response 245 | 246 | def _send(self, **req_kwargs: dict) -> requests.Response: 247 | """Send an authenticated request to a Google API. 248 | 249 | Args: 250 | **req_kwargs: Arbitrary keyword arguments to pass to Requests. 251 | 252 | Return: 253 | The raw response. 254 | 255 | Raises: 256 | LoginException: If session is not authenticated. 257 | """ 258 | # Bail if we don't have an OAuth token. 259 | auth_token = self._auth.getAuthToken() 260 | if auth_token is None: 261 | raise exception.LoginException("Not logged in") 262 | 263 | # Add the token to the request. 264 | req_kwargs.setdefault("headers", {"Authorization": "OAuth " + auth_token}) 265 | 266 | return self._session.request(**req_kwargs) 267 | 268 | 269 | class KeepAPI(API): 270 | """Low level Google Keep API client. Mimics the Android Google Keep app. 271 | 272 | You probably want to use :py:class:`Keep` instead. 273 | """ 274 | 275 | API_URL = "https://www.googleapis.com/notes/v1/" 276 | 277 | def __init__(self, auth: APIAuth | None = None) -> None: 278 | """Construct a low-level Google Keep API client""" 279 | super().__init__(self.API_URL, auth) 280 | 281 | create_time = time.time() 282 | self._session_id = self._generateId(create_time) 283 | 284 | @classmethod 285 | def _generateId(cls, tz: int) -> str: 286 | return "s--%d--%d" % ( 287 | int(tz * 1000), 288 | random.randint(1000000000, 9999999999), # noqa: S311 289 | ) 290 | 291 | def changes( 292 | self, 293 | target_version: str | None = None, 294 | nodes: list[dict] | None = None, 295 | labels: list[dict] | None = None, 296 | ) -> dict: 297 | """Sync up (and down) all changes. 298 | 299 | Args: 300 | target_version: The local change version. 301 | nodes: A list of nodes to sync up to the server. 302 | labels: A list of labels to sync up to the server. 303 | 304 | Return: 305 | Description of all changes. 306 | 307 | Raises: 308 | APIException: If the server returns an error. 309 | """ 310 | # Handle defaults. 311 | if nodes is None: 312 | nodes = [] 313 | if labels is None: 314 | labels = [] 315 | 316 | current_time = time.time() 317 | 318 | # Initialize request parameters. 319 | params = { 320 | "nodes": nodes, 321 | "clientTimestamp": _node.NodeTimestamps.int_to_str(current_time), 322 | "requestHeader": { 323 | "clientSessionId": self._session_id, 324 | "clientPlatform": "ANDROID", 325 | "clientVersion": { 326 | "major": "9", 327 | "minor": "9", 328 | "build": "9", 329 | "revision": "9", 330 | }, 331 | "capabilities": [ 332 | {"type": "NC"}, # Color support (Send note color) 333 | {"type": "PI"}, # Pinned support (Send note pinned) 334 | {"type": "LB"}, # Labels support (Send note labels) 335 | {"type": "AN"}, # Annotations support (Send annotations) 336 | {"type": "SH"}, # Sharing support 337 | {"type": "DR"}, # Drawing support 338 | {"type": "TR"}, # Trash support (Stop setting the delete timestamp) 339 | {"type": "IN"}, # Indentation support (Send listitem parent) 340 | {"type": "SNB"}, # Allows modification of shared notes? 341 | {"type": "MI"}, # Concise blob info? 342 | {"type": "CO"}, # VSS_SUCCEEDED when off? 343 | # TODO: Figure out what these do: 344 | # {'type': 'EC'}, # ??? 345 | # {'type': 'RB'}, # Rollback? 346 | # {'type': 'EX'}, # ??? 347 | ], 348 | }, 349 | } 350 | 351 | # Add the targetVersion if set. This tells the server what version the 352 | # client is currently at. 353 | if target_version is not None: 354 | params["targetVersion"] = target_version 355 | 356 | # Add any new or updated labels to the request. 357 | if labels: 358 | params["userInfo"] = {"labels": labels} 359 | 360 | logger.debug("Syncing %d labels and %d nodes", len(labels), len(nodes)) 361 | 362 | return self.send(url=self._base_url + "changes", method="POST", json=params) 363 | 364 | 365 | class MediaAPI(API): 366 | """Low level Google Media API client. Mimics the Android Google Keep app. 367 | 368 | You probably want to use :py:class:`Keep` instead. 369 | """ 370 | 371 | API_URL = "https://keep.google.com/media/v2/" 372 | 373 | def __init__(self, auth: APIAuth | None = None) -> None: 374 | """Construct a low-level Google Media API client""" 375 | super().__init__(self.API_URL, auth) 376 | 377 | def get(self, blob: _node.Blob) -> str: 378 | """Get the canonical link to a media blob. 379 | 380 | Args: 381 | blob: The blob. 382 | 383 | Returns: 384 | A link to the media. 385 | """ 386 | url = self._base_url + blob.parent.server_id + "/" + blob.server_id 387 | if blob.blob.type == _node.BlobType.Drawing: 388 | url += "/" + blob.blob._drawing_info.drawing_id # noqa: SLF001 389 | return self._send(url=url, method="GET", allow_redirects=False).headers[ 390 | "location" 391 | ] 392 | 393 | 394 | class RemindersAPI(API): 395 | """Low level Google Reminders API client. Mimics the Android Google Keep app. 396 | 397 | You probably want to use :py:class:`Keep` instead. 398 | """ 399 | 400 | API_URL = "https://www.googleapis.com/reminders/v1internal/reminders/" 401 | 402 | def __init__(self, auth: APIAuth | None = None) -> None: 403 | """Construct a low-level Google Reminders API client""" 404 | super().__init__(self.API_URL, auth) 405 | self.static_params = { 406 | "taskList": [ 407 | {"systemListId": "MEMENTO"}, 408 | ], 409 | "requestParameters": { 410 | "userAgentStructured": { 411 | "clientApplication": "KEEP", 412 | "clientApplicationVersion": { 413 | "major": "9", 414 | "minor": "9.9.9.9", 415 | }, 416 | "clientPlatform": "ANDROID", 417 | }, 418 | }, 419 | } 420 | 421 | def create( 422 | self, node_id: str, node_server_id: str, dtime: datetime.datetime 423 | ) -> Any: # noqa: ANN401 424 | """Create a new reminder. 425 | 426 | Args: 427 | node_id: The note ID. 428 | node_server_id: The note server ID. 429 | dtime: The due date of this reminder. 430 | 431 | Return: ??? 432 | 433 | Raises: 434 | APIException: If the server returns an error. 435 | """ 436 | params = {} 437 | params.update(self.static_params) 438 | 439 | params.update( 440 | { 441 | "task": { 442 | "dueDate": { 443 | "year": dtime.year, 444 | "month": dtime.month, 445 | "day": dtime.day, 446 | "time": { 447 | "hour": dtime.hour, 448 | "minute": dtime.minute, 449 | "second": dtime.second, 450 | }, 451 | }, 452 | "snoozed": True, 453 | "extensions": { 454 | "keepExtension": { 455 | "reminderVersion": "V2", 456 | "clientNoteId": node_id, 457 | "serverNoteId": node_server_id, 458 | }, 459 | }, 460 | }, 461 | "taskId": { 462 | "clientAssignedId": "KEEP/v2/" + node_server_id, 463 | }, 464 | } 465 | ) 466 | 467 | return self.send(url=self._base_url + "create", method="POST", json=params) 468 | 469 | def update_internal( 470 | self, node_id: str, node_server_id: str, dtime: datetime.datetime 471 | ) -> Any: # noqa: ANN401 472 | """Update an existing reminder. 473 | 474 | Args: 475 | node_id: The note ID. 476 | node_server_id: The note server ID. 477 | dtime: The due date of this reminder. 478 | 479 | Return: ??? 480 | 481 | Raises: 482 | APIException: If the server returns an error. 483 | """ 484 | params = {} 485 | params.update(self.static_params) 486 | 487 | params.update( 488 | { 489 | "newTask": { 490 | "dueDate": { 491 | "year": dtime.year, 492 | "month": dtime.month, 493 | "day": dtime.day, 494 | "time": { 495 | "hour": dtime.hour, 496 | "minute": dtime.minute, 497 | "second": dtime.second, 498 | }, 499 | }, 500 | "snoozed": True, 501 | "extensions": { 502 | "keepExtension": { 503 | "reminderVersion": "V2", 504 | "clientNoteId": node_id, 505 | "serverNoteId": node_server_id, 506 | }, 507 | }, 508 | }, 509 | "taskId": { 510 | "clientAssignedId": "KEEP/v2/" + node_server_id, 511 | }, 512 | "updateMask": { 513 | "updateField": [ 514 | "ARCHIVED", 515 | "DUE_DATE", 516 | "EXTENSIONS", 517 | "LOCATION", 518 | "TITLE", 519 | ] 520 | }, 521 | } 522 | ) 523 | 524 | return self.send(url=self._base_url + "update", method="POST", json=params) 525 | 526 | def delete(self, node_server_id: str) -> Any: # noqa: ANN401 527 | """Delete an existing reminder. 528 | 529 | Args: 530 | node_server_id: The note server ID. 531 | 532 | Return: ??? 533 | 534 | Raises: 535 | APIException: If the server returns an error. 536 | """ 537 | params = {} 538 | params.update(self.static_params) 539 | 540 | params.update( 541 | { 542 | "batchedRequest": [ 543 | { 544 | "deleteTask": { 545 | "taskId": [ 546 | {"clientAssignedId": "KEEP/v2/" + node_server_id} 547 | ] 548 | } 549 | } 550 | ] 551 | } 552 | ) 553 | 554 | return self.send(url=self._base_url + "batchmutate", method="POST", json=params) 555 | 556 | def list(self, master: bool = True) -> Any: # noqa: ANN401 557 | """List current reminders. 558 | 559 | Args: 560 | master: ??? 561 | 562 | Return: 563 | ??? 564 | 565 | Raises: 566 | APIException: If the server returns an error. 567 | """ 568 | params = {} 569 | params.update(self.static_params) 570 | 571 | if master: 572 | params.update( 573 | { 574 | "recurrenceOptions": { 575 | "collapseMode": "MASTER_ONLY", 576 | }, 577 | "includeArchived": True, 578 | "includeDeleted": False, 579 | } 580 | ) 581 | else: 582 | current_time = time.time() 583 | start_time = int((current_time - (365 * 24 * 60 * 60)) * 1000) 584 | end_time = int((current_time + (24 * 60 * 60)) * 1000) 585 | 586 | params.update( 587 | { 588 | "recurrenceOptions": { 589 | "collapseMode": "INSTANCES_ONLY", 590 | "recurrencesOnly": True, 591 | }, 592 | "includeArchived": False, 593 | "includeCompleted": False, 594 | "includeDeleted": False, 595 | "dueAfterMs": start_time, 596 | "dueBeforeMs": end_time, 597 | "recurrenceId": [], 598 | } 599 | ) 600 | 601 | return self.send(url=self._base_url + "list", method="POST", json=params) 602 | 603 | def history(self, storage_version: str) -> Any: # noqa: ANN401 604 | """Get reminder changes. 605 | 606 | Args: 607 | storage_version: The local storage version. 608 | 609 | Returns: 610 | ??? 611 | 612 | Raises: 613 | APIException: If the server returns an error. 614 | """ 615 | params = { 616 | "storageVersion": storage_version, 617 | "includeSnoozePresetUpdates": True, 618 | } 619 | params.update(self.static_params) 620 | 621 | return self.send(url=self._base_url + "history", method="POST", json=params) 622 | 623 | def update(self) -> Any: # noqa: ANN401 624 | """Sync up changes to reminders.""" 625 | params = {} 626 | return self.send(url=self._base_url + "update", method="POST", json=params) 627 | 628 | 629 | class Keep: 630 | """High level Google Keep client. 631 | 632 | Manipulates a local copy of the Keep node tree. First, obtain a master token for your account. 633 | 634 | To start, first authenticate:: 635 | 636 | keep.authenticate('...', '...') 637 | 638 | Individual Notes can be retrieved by id:: 639 | 640 | some_note = keep.get('some_id') 641 | 642 | New Notes can be created:: 643 | 644 | new_note = keep.createNote() 645 | 646 | These Notes can then be modified:: 647 | 648 | some_note.text = 'Test' 649 | new_note.text = 'Text' 650 | 651 | These changes are automatically detected and synced up with:: 652 | 653 | keep.sync() 654 | """ 655 | 656 | OAUTH_SCOPES = "oauth2:https://www.googleapis.com/auth/memento https://www.googleapis.com/auth/reminders" 657 | 658 | def __init__(self) -> None: 659 | """Construct a Google Keep client""" 660 | self._keep_api = KeepAPI() 661 | self._reminders_api = RemindersAPI() 662 | self._media_api = MediaAPI() 663 | self._keep_version = None 664 | self._reminder_version = None 665 | self._labels = {} 666 | self._nodes = {} 667 | self._sid_map = {} 668 | 669 | self._clear() 670 | 671 | def _clear(self) -> None: 672 | self._keep_version = None 673 | self._reminder_version = None 674 | self._labels = {} 675 | self._nodes = {} 676 | self._sid_map = {} 677 | 678 | root_node = _node.Root() 679 | self._nodes[_node.Root.ID] = root_node 680 | 681 | def login( 682 | self, 683 | email: str, 684 | password: str, 685 | state: dict | None = None, 686 | sync: bool = True, 687 | device_id: str | None = None, 688 | ) -> None: 689 | """Authenticate to Google with the provided credentials & sync. 690 | 691 | This flow is discouraged. 692 | 693 | Args: 694 | email: The account to use. 695 | password: The account password. 696 | state: Serialized state to load. 697 | sync: Whether to sync data. 698 | device_id: Device id. 699 | 700 | Raises: 701 | LoginException: If there was a problem logging in. 702 | """ 703 | logger.warning("'Keep.login' is deprecated. Please use 'Keep.authenticate' instead") 704 | auth = APIAuth(self.OAUTH_SCOPES) 705 | if device_id is None: 706 | device_id = f"{get_mac():x}" 707 | 708 | auth.login(email, password, device_id) 709 | self.load(auth, state, sync) 710 | 711 | def resume( 712 | self, 713 | email: str, 714 | master_token: str, 715 | state: dict | None = None, 716 | sync: bool = True, 717 | device_id: str | None = None, 718 | ) -> None: 719 | logger.warning("'Keep.resume' has been renamed to 'Keep.authenticate'. Please update your code") 720 | self.authenticate(email, master_token, state, sync, device_id) 721 | 722 | def authenticate( 723 | self, 724 | email: str, 725 | master_token: str, 726 | state: dict | None = None, 727 | sync: bool = True, 728 | device_id: str | None = None, 729 | ) -> None: 730 | """Authenticate to Google with the provided master token & sync. 731 | 732 | Args: 733 | email: The account to use. 734 | master_token: The master token. 735 | state: Serialized state to load. 736 | sync: Whether to sync data. 737 | device_id: Device id. 738 | 739 | Raises: 740 | LoginException: If there was a problem logging in. 741 | """ 742 | auth = APIAuth(self.OAUTH_SCOPES) 743 | if device_id is None: 744 | device_id = f"{get_mac():x}" 745 | 746 | auth.load(email, master_token, device_id) 747 | self.load(auth, state, sync) 748 | 749 | def getMasterToken(self) -> str: 750 | """Get master token for resuming. 751 | 752 | Returns: 753 | The master token. 754 | """ 755 | return self._keep_api.getAuth().getMasterToken() 756 | 757 | def load(self, auth: APIAuth, state: dict | None = None, sync: bool = True) -> None: 758 | """Authenticate to Google with a prepared authentication object & sync. 759 | 760 | Args: 761 | auth: Authentication object. 762 | state: Serialized state to load. 763 | sync: Whether to sync data. 764 | 765 | Raises: 766 | LoginException: If there was a problem logging in. 767 | """ 768 | self._keep_api.setAuth(auth) 769 | self._reminders_api.setAuth(auth) 770 | self._media_api.setAuth(auth) 771 | if state is not None: 772 | self.restore(state) 773 | if sync: 774 | self.sync() 775 | 776 | def dump(self) -> dict: 777 | """Serialize note data. 778 | 779 | Returns: 780 | Serialized state. 781 | """ 782 | # Find all nodes manually, as the Keep object isn't aware of new 783 | # ListItems until they've been synced to the server. 784 | nodes = [] 785 | for node in self.all(): 786 | nodes.append(node) 787 | nodes.extend(node.children) 788 | return { 789 | "keep_version": self._keep_version, 790 | "labels": [label.save(False) for label in self.labels()], 791 | "nodes": [node.save(False) for node in nodes], 792 | } 793 | 794 | def restore(self, state: dict) -> None: 795 | """Unserialize saved note data. 796 | 797 | Args: 798 | state: Serialized state to load. 799 | """ 800 | self._clear() 801 | self._parseUserInfo({"labels": state["labels"]}) 802 | self._parseNodes(state["nodes"]) 803 | self._keep_version = state["keep_version"] 804 | 805 | def get(self, node_id: str) -> _node.TopLevelNode: 806 | """Get a note with the given ID. 807 | 808 | Args: 809 | node_id: The note ID. 810 | 811 | Returns: 812 | The Note or None if not found. 813 | """ 814 | return self._nodes[_node.Root.ID].get(node_id) or self._nodes[ 815 | _node.Root.ID 816 | ].get(self._sid_map.get(node_id)) 817 | 818 | def add(self, node: _node.Node) -> None: 819 | """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. 820 | 821 | Args: 822 | node: The node to sync. 823 | 824 | Raises: 825 | InvalidException: If the parent node is not found. 826 | """ 827 | if node.parent_id != _node.Root.ID: 828 | raise exception.InvalidException("Not a top level node") 829 | 830 | self._nodes[node.id] = node 831 | self._nodes[node.parent_id].append(node, False) 832 | 833 | def find( 834 | self, 835 | query: re.Pattern | str | None = None, 836 | func: Callable | None = None, 837 | labels: list[str] | None = None, 838 | colors: list[str] | None = None, 839 | pinned: bool | None = None, 840 | archived: bool | None = None, 841 | trashed: bool = False, 842 | ) -> Iterator[_node.TopLevelNode]: 843 | """Find Notes based on the specified criteria. 844 | 845 | Args: 846 | query: A str or regular expression to match against the title and text. 847 | func: A filter function. 848 | labels: A list of label ids or objects to match. An empty list matches notes with no labels. 849 | colors: A list of colors to match. 850 | pinned: Whether to match pinned notes. 851 | archived: Whether to match archived notes. 852 | trashed: Whether to match trashed notes. 853 | 854 | Return: 855 | Search results. 856 | """ 857 | if labels is not None: 858 | labels = [i.id if isinstance(i, _node.Label) else i for i in labels] 859 | 860 | return ( 861 | node 862 | for node in self.all() 863 | if 864 | # Process the query. 865 | ( 866 | query is None 867 | or ( 868 | ( 869 | isinstance(query, str) 870 | and (query in node.title or query in node.text) 871 | ) 872 | or ( 873 | isinstance(query, re.Pattern) 874 | and (query.search(node.title) or query.search(node.text)) 875 | ) 876 | ) 877 | ) 878 | and 879 | # Process the func. 880 | (func is None or func(node)) 881 | and ( # Process the labels. 882 | labels is None 883 | or (not labels and not node.labels.all()) 884 | or (any(node.labels.get(i) is not None for i in labels)) 885 | ) 886 | and (colors is None or node.color in colors) # Process the colors. 887 | and (pinned is None or node.pinned == pinned) # Process the pinned state. 888 | and ( # Process the archive state. 889 | archived is None or node.archived == archived 890 | ) 891 | and (trashed is None or node.trashed == trashed) # Process the trash state. 892 | ) 893 | 894 | def createNote( 895 | self, title: str | None = None, text: str | None = None 896 | ) -> _node.Node: 897 | """Create a new managed note. Any changes to the note will be uploaded when :py:meth:`sync` is called. 898 | 899 | Args: 900 | title: The title of the note. 901 | text: The text of the note. 902 | 903 | Returns: 904 | The new note. 905 | """ 906 | node = _node.Note() 907 | if title is not None: 908 | node.title = title 909 | if text is not None: 910 | node.text = text 911 | self.add(node) 912 | return node 913 | 914 | def createList( 915 | self, 916 | title: str | None = None, 917 | items: list[tuple[str, bool]] | None = None, 918 | ) -> _node.List: 919 | """Create a new list and populate it. Any changes to the note will be uploaded when :py:meth:`sync` is called. 920 | 921 | Args: 922 | title: The title of the list. 923 | items: A list of tuples. Each tuple represents the text and checked status of the listitem. 924 | 925 | Returns: 926 | The new list. 927 | """ 928 | if items is None: 929 | items = [] 930 | 931 | node = _node.List() 932 | if title is not None: 933 | node.title = title 934 | 935 | sort = random.randint(1000000000, 9999999999) # noqa: S311 936 | for text, checked in items: 937 | node.add(text, checked, sort) 938 | sort -= _node.List.SORT_DELTA 939 | self.add(node) 940 | return node 941 | 942 | def createLabel(self, name: str) -> _node.Label: 943 | """Create a new label. 944 | 945 | Args: 946 | name: Label name. 947 | 948 | Returns: 949 | The new label. 950 | 951 | Raises: 952 | LabelException: If the label exists. 953 | """ 954 | if self.findLabel(name): 955 | raise exception.LabelException("Label exists") 956 | node = _node.Label() 957 | node.name = name 958 | self._labels[node.id] = node 959 | return node 960 | 961 | def findLabel( 962 | self, query: re.Pattern | str, create: bool = False 963 | ) -> _node.Label | None: 964 | """Find a label with the given name. 965 | 966 | Args: 967 | query: A str or regular expression to match against the name. 968 | create: Whether to create the label if it doesn't exist (only if name is a str). 969 | 970 | Returns: 971 | The label. 972 | """ 973 | is_str = isinstance(query, str) 974 | name = None 975 | if is_str: 976 | name = query 977 | query = query.lower() 978 | 979 | for label in self._labels.values(): 980 | # Match the label against query, which may be a str or Pattern. 981 | if (is_str and query == label.name.lower()) or ( 982 | isinstance(query, re.Pattern) and query.search(label.name) 983 | ): 984 | return label 985 | 986 | return self.createLabel(name) if create and is_str else None 987 | 988 | def getLabel(self, label_id: str) -> _node.Label | None: 989 | """Get an existing label. 990 | 991 | Args: 992 | label_id: Label id. 993 | 994 | Returns: 995 | The label. 996 | """ 997 | return self._labels.get(label_id) 998 | 999 | def deleteLabel(self, label_id: str) -> None: 1000 | """Deletes a label. 1001 | 1002 | Args: 1003 | label_id: Label id. 1004 | """ 1005 | if label_id not in self._labels: 1006 | return 1007 | 1008 | label = self._labels[label_id] 1009 | label.delete() 1010 | for node in self.all(): 1011 | node.labels.remove(label) 1012 | 1013 | def labels(self) -> list[_node.Label]: 1014 | """Get all labels. 1015 | 1016 | Returns: 1017 | Labels 1018 | """ 1019 | return list(self._labels.values()) 1020 | 1021 | def __UNSTABLE_API_uploadMedia(self, fh: IO)-> None: 1022 | pass 1023 | 1024 | def getMediaLink(self, blob: _node.Blob) -> str: 1025 | """Get the canonical link to media. 1026 | 1027 | Args: 1028 | blob: The media resource. 1029 | 1030 | Returns: 1031 | A link to the media. 1032 | """ 1033 | return self._media_api.get(blob) 1034 | 1035 | def all(self) -> list[_node.TopLevelNode]: 1036 | """Get all Notes. 1037 | 1038 | Returns: 1039 | All notes. 1040 | """ 1041 | return self._nodes[_node.Root.ID].children 1042 | 1043 | def sync(self, resync: bool = False) -> None: 1044 | """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. 1045 | 1046 | Args: 1047 | resync: Whether to resync data. 1048 | 1049 | Raises: 1050 | SyncException: If there is a consistency issue. 1051 | """ 1052 | # Clear all state if we want to resync. 1053 | if resync: 1054 | self._clear() 1055 | 1056 | # self._sync_reminders() 1057 | self._sync_notes() 1058 | 1059 | if _node.DEBUG: 1060 | self._clean() 1061 | 1062 | def _sync_reminders(self) -> None: 1063 | # Fetch updates until we reach the newest version. 1064 | while True: 1065 | logger.debug("Starting reminder sync: %s", self._reminder_version) 1066 | changes = self._reminders_api.list() 1067 | 1068 | # Hydrate the individual "tasks". 1069 | if "task" in changes: 1070 | self._parseTasks(changes["task"]) 1071 | 1072 | self._reminder_version = changes["storageVersion"] 1073 | logger.debug("Finishing sync: %s", self._reminder_version) 1074 | 1075 | # Check if we've reached the newest version. 1076 | history = self._reminders_api.history(self._reminder_version) 1077 | if self._reminder_version == history["highestStorageVersion"]: 1078 | break 1079 | 1080 | def _sync_notes(self) -> None: 1081 | # Fetch updates until we reach the newest version. 1082 | while True: 1083 | logger.debug("Starting keep sync: %s", self._keep_version) 1084 | 1085 | # Collect any changes and send them up to the server. 1086 | labels_updated = any(i.dirty for i in self._labels.values()) 1087 | changes = self._keep_api.changes( 1088 | target_version=self._keep_version, 1089 | nodes=[i.save() for i in self._findDirtyNodes()], 1090 | labels=[i.save() for i in self._labels.values()] 1091 | if labels_updated 1092 | else None, 1093 | ) 1094 | 1095 | if changes.get("forceFullResync"): 1096 | raise exception.ResyncRequiredException("Full resync required") 1097 | 1098 | if changes.get("upgradeRecommended"): 1099 | raise exception.UpgradeRecommendedException("Upgrade recommended") 1100 | 1101 | # Hydrate labels. 1102 | if "userInfo" in changes: 1103 | self._parseUserInfo(changes["userInfo"]) 1104 | 1105 | # Hydrate notes and any children. 1106 | if "nodes" in changes: 1107 | self._parseNodes(changes["nodes"]) 1108 | 1109 | self._keep_version = changes["toVersion"] 1110 | logger.debug("Finishing sync: %s", self._keep_version) 1111 | 1112 | # Check if there are more changes to retrieve. 1113 | if not changes["truncated"]: 1114 | break 1115 | 1116 | def _parseTasks(self, raw: dict) -> None: 1117 | pass 1118 | 1119 | def _parseNodes(self, raw: dict) -> None: # noqa: C901, PLR0912 1120 | created_nodes = [] 1121 | deleted_nodes = [] 1122 | listitem_nodes = [] 1123 | 1124 | # Loop over each updated node. 1125 | for raw_node in raw: 1126 | # If the id exists, then we already know about it. In other words, 1127 | # update a local node. 1128 | if raw_node["id"] in self._nodes: 1129 | node = self._nodes[raw_node["id"]] 1130 | 1131 | if "parentId" in raw_node: 1132 | # If the parentId field is set, this is an update. Load it 1133 | # into the existing node. 1134 | node.load(raw_node) 1135 | self._sid_map[node.server_id] = node.id 1136 | logger.debug("Updated node: %s", raw_node["id"]) 1137 | else: 1138 | # Otherwise, this node has been deleted. Add it to the list. 1139 | deleted_nodes.append(node) 1140 | 1141 | else: 1142 | # Otherwise, this is a new node. Attempt to hydrate it. 1143 | node = _node.from_json(raw_node) 1144 | if node is None: 1145 | logger.debug("Discarded unknown node") 1146 | else: 1147 | # Append the new node into the node tree. 1148 | self._nodes[raw_node["id"]] = node 1149 | self._sid_map[node.server_id] = node.id 1150 | created_nodes.append(node) 1151 | logger.debug("Created node: %s", raw_node["id"]) 1152 | 1153 | # If the node is a listitem, keep track of it. 1154 | if isinstance(node, _node.ListItem): 1155 | listitem_nodes.append(node) 1156 | 1157 | # Attach each listitem to its parent list. Indented items point to their 1158 | # parent listitem, so we need to traverse up until we reach the list. 1159 | for node in listitem_nodes: 1160 | prev = node.prev_super_list_item_id 1161 | curr = node.super_list_item_id 1162 | if prev == curr: 1163 | continue 1164 | 1165 | # Apply proper indentation. 1166 | if prev is not None and prev in self._nodes: 1167 | self._nodes[prev].dedent(node, False) 1168 | if curr is not None and curr in self._nodes: 1169 | self._nodes[curr].indent(node, False) 1170 | 1171 | # Attach created nodes to the tree. 1172 | for node in created_nodes: 1173 | logger.debug( 1174 | "Attached node: %s to %s", 1175 | node.id if node else None, 1176 | node.parent_id if node else None, 1177 | ) 1178 | parent_node = self._nodes.get(node.parent_id) 1179 | parent_node.append(node, False) 1180 | 1181 | # Detach deleted nodes from the tree. 1182 | for node in deleted_nodes: 1183 | node.parent.remove(node) 1184 | del self._nodes[node.id] 1185 | if node.server_id is not None: 1186 | del self._sid_map[node.server_id] 1187 | logger.debug("Deleted node: %s", node.id) 1188 | 1189 | # Hydrate label references in notes. 1190 | for node in self.all(): 1191 | for label_id in node.labels._labels: # noqa: SLF001 1192 | node.labels._labels[label_id] = self._labels.get( # noqa: SLF001 1193 | label_id 1194 | ) 1195 | 1196 | def _parseUserInfo(self, raw: dict) -> None: 1197 | labels = {} 1198 | if "labels" in raw: 1199 | for label in raw["labels"]: 1200 | # If the mainId field exists, this is an update. 1201 | if label["mainId"] in self._labels: 1202 | node = self._labels[label["mainId"]] 1203 | # Remove this key from our list of labels. 1204 | del self._labels[label["mainId"]] 1205 | logger.debug("Updated label: %s", label["mainId"]) 1206 | else: 1207 | # Otherwise, this is a brand new label. 1208 | node = _node.Label() 1209 | logger.debug("Created label: %s", label["mainId"]) 1210 | node.load(label) 1211 | labels[label["mainId"]] = node 1212 | 1213 | # All remaining labels are deleted. 1214 | for label_id in self._labels: 1215 | logger.debug("Deleted label: %s", label_id) 1216 | 1217 | self._labels = labels 1218 | 1219 | def _findDirtyNodes(self) -> list[_node.Node]: 1220 | # Find nodes that aren't in our internal nodes list and insert them. 1221 | for node in list(self._nodes.values()): 1222 | for child in node.children: 1223 | if child.id not in self._nodes: 1224 | self._nodes[child.id] = child 1225 | 1226 | # Collect all dirty nodes (any nodes from above will be caught too). 1227 | return [node for node in self._nodes.values() if node.dirty] 1228 | 1229 | def _clean(self) -> None: 1230 | """Recursively check that all nodes are reachable.""" 1231 | found_ids = set() 1232 | nodes = [self._nodes[_node.Root.ID]] 1233 | 1234 | # Enumerate all nodes from the root node 1235 | while nodes: 1236 | node = nodes.pop() 1237 | found_ids.add(node.id) 1238 | nodes = nodes + node.children 1239 | 1240 | # Find nodes that can't be reached from the root 1241 | for node_id in self._nodes: 1242 | if node_id in found_ids: 1243 | continue 1244 | logger.error("Dangling node: %s", node_id) 1245 | 1246 | # Find nodes that don't exist in the collection 1247 | for node_id in found_ids: 1248 | if node_id in self._nodes: 1249 | continue 1250 | logger.error("Unregistered node: %s", node_id) 1251 | -------------------------------------------------------------------------------- /src/gkeepapi/exception.py: -------------------------------------------------------------------------------- 1 | """.. moduleauthor:: Kai """ 2 | 3 | 4 | class APIException(Exception): 5 | """The API server returned an error.""" 6 | 7 | def __init__(self, code: int, msg: str) -> None: 8 | """Construct an exception object""" 9 | super().__init__(msg) 10 | self.code = code 11 | 12 | 13 | class KeepException(Exception): 14 | """Generic Keep error.""" 15 | 16 | 17 | class LoginException(KeepException): 18 | """Login exception.""" 19 | 20 | 21 | class BrowserLoginRequiredException(LoginException): 22 | """Browser login required error.""" 23 | 24 | def __init__(self, url: str) -> None: 25 | """Construct a browser login exception object""" 26 | self.url = url 27 | 28 | 29 | class LabelException(KeepException): 30 | """Keep label error.""" 31 | 32 | 33 | class SyncException(KeepException): 34 | """Keep consistency error.""" 35 | 36 | 37 | class ResyncRequiredException(SyncException): 38 | """Full resync required error.""" 39 | 40 | 41 | class UpgradeRecommendedException(SyncException): 42 | """Upgrade recommended error.""" 43 | 44 | 45 | class MergeException(KeepException): 46 | """Node consistency error.""" 47 | 48 | 49 | class InvalidException(KeepException): 50 | """Constraint error.""" 51 | 52 | 53 | class ParseException(KeepException): 54 | """Parse error.""" 55 | 56 | def __init__(self, msg: str, raw: dict) -> None: 57 | """Construct a parse exception object""" 58 | super().__init__(msg) 59 | self.raw = raw 60 | -------------------------------------------------------------------------------- /src/gkeepapi/node.py: -------------------------------------------------------------------------------- 1 | """.. automodule:: gkeepapi 2 | 3 | :members: 4 | :inherited-members: 5 | 6 | .. moduleauthor:: Kai 7 | """ 8 | 9 | import datetime 10 | import enum 11 | import itertools 12 | import logging 13 | import random 14 | import time 15 | from collections.abc import Callable 16 | from operator import attrgetter 17 | 18 | from . import exception 19 | 20 | DEBUG = False 21 | 22 | logger = logging.getLogger(__name__) 23 | 24 | 25 | class NodeType(enum.Enum): 26 | """Valid note types.""" 27 | 28 | Note = "NOTE" 29 | """A Note""" 30 | 31 | List = "LIST" 32 | """A List""" 33 | 34 | ListItem = "LIST_ITEM" 35 | """A List item""" 36 | 37 | Blob = "BLOB" 38 | """A blob (attachment)""" 39 | 40 | 41 | class BlobType(enum.Enum): 42 | """Valid blob types.""" 43 | 44 | Audio = "AUDIO" 45 | """Audio""" 46 | 47 | Image = "IMAGE" 48 | """Image""" 49 | 50 | Drawing = "DRAWING" 51 | """Drawing""" 52 | 53 | 54 | class ColorValue(enum.Enum): 55 | """Valid note colors.""" 56 | 57 | White = "DEFAULT" 58 | """White""" 59 | 60 | Red = "RED" 61 | """Red""" 62 | 63 | Orange = "ORANGE" 64 | """Orange""" 65 | 66 | Yellow = "YELLOW" 67 | """Yellow""" 68 | 69 | Green = "GREEN" 70 | """Green""" 71 | 72 | Teal = "TEAL" 73 | """Teal""" 74 | 75 | Blue = "BLUE" 76 | """Blue""" 77 | 78 | DarkBlue = "CERULEAN" 79 | """Dark blue""" 80 | 81 | Purple = "PURPLE" 82 | """Purple""" 83 | 84 | Pink = "PINK" 85 | """Pink""" 86 | 87 | Brown = "BROWN" 88 | """Brown""" 89 | 90 | Gray = "GRAY" 91 | """Gray""" 92 | 93 | 94 | class CategoryValue(enum.Enum): 95 | """Valid note categories.""" 96 | 97 | Books = "BOOKS" 98 | """Books""" 99 | 100 | Food = "FOOD" 101 | """Food""" 102 | 103 | Movies = "MOVIES" 104 | """Movies""" 105 | 106 | Music = "MUSIC" 107 | """Music""" 108 | 109 | Places = "PLACES" 110 | """Places""" 111 | 112 | Quotes = "QUOTES" 113 | """Quotes""" 114 | 115 | Travel = "TRAVEL" 116 | """Travel""" 117 | 118 | TV = "TV" 119 | """TV""" 120 | 121 | 122 | class SuggestValue(enum.Enum): 123 | """Valid task suggestion categories.""" 124 | 125 | GroceryItem = "GROCERY_ITEM" 126 | """Grocery item""" 127 | 128 | 129 | class NewListItemPlacementValue(enum.Enum): 130 | """Target location to put new list items.""" 131 | 132 | Top = "TOP" 133 | """Top""" 134 | 135 | Bottom = "BOTTOM" 136 | """Bottom""" 137 | 138 | 139 | class GraveyardStateValue(enum.Enum): 140 | """Visibility setting for the graveyard.""" 141 | 142 | Expanded = "EXPANDED" 143 | """Expanded""" 144 | 145 | Collapsed = "COLLAPSED" 146 | """Collapsed""" 147 | 148 | 149 | class CheckedListItemsPolicyValue(enum.Enum): 150 | """Movement setting for checked list items.""" 151 | 152 | Default = "DEFAULT" 153 | """Default""" 154 | 155 | Graveyard = "GRAVEYARD" 156 | """Graveyard""" 157 | 158 | 159 | class ShareRequestValue(enum.Enum): 160 | """Collaborator change type.""" 161 | 162 | Add = "WR" 163 | """Grant access.""" 164 | 165 | Remove = "RM" 166 | """Remove access.""" 167 | 168 | 169 | class RoleValue(enum.Enum): 170 | """Collaborator role type.""" 171 | 172 | Owner = "O" 173 | """Note owner.""" 174 | 175 | User = "W" 176 | """Note collaborator.""" 177 | 178 | 179 | class Element: 180 | """Interface for elements that can be serialized and deserialized.""" 181 | 182 | __slots__ = ("_dirty",) 183 | 184 | def __init__(self) -> None: 185 | """Construct an element object""" 186 | self._dirty = False 187 | 188 | def _find_discrepancies(self, raw: dict | list) -> None: # pragma: no cover 189 | s_raw = self.save(False) 190 | if isinstance(raw, dict): 191 | for key, val in raw.items(): 192 | if key in ["parentServerId", "lastSavedSessionId"]: 193 | continue 194 | if key not in s_raw: 195 | logger.info("Missing key for %s key %s", type(self), key) 196 | continue 197 | 198 | if isinstance(val, list | dict): 199 | continue 200 | 201 | val_a = raw[key] 202 | val_b = s_raw[key] 203 | # Python strftime's 'z' format specifier includes microseconds, but the response from GKeep 204 | # only has milliseconds. This causes a string mismatch, so we construct datetime objects 205 | # to properly compare 206 | if isinstance(val_a, str) and isinstance(val_b, str): 207 | try: 208 | tval_a = NodeTimestamps.str_to_dt(val_a) 209 | tval_b = NodeTimestamps.str_to_dt(val_b) 210 | val_a, val_b = tval_a, tval_b 211 | except (KeyError, ValueError): 212 | pass 213 | if val_a != val_b: 214 | logger.info( 215 | "Different value for %s key %s: %s != %s", 216 | type(self), 217 | key, 218 | raw[key], 219 | s_raw[key], 220 | ) 221 | elif isinstance(raw, list) and len(raw) != len(s_raw): 222 | logger.info( 223 | "Different length for %s: %d != %d", 224 | type(self), 225 | len(raw), 226 | len(s_raw), 227 | ) 228 | 229 | def load(self, raw: dict) -> None: 230 | """Unserialize from raw representation. (Wrapper) 231 | 232 | Args: 233 | raw: Raw. 234 | 235 | 236 | Raises: 237 | ParseException: If there was an error parsing data. 238 | """ 239 | try: 240 | self._load(raw) 241 | except (KeyError, ValueError) as e: 242 | raise exception.ParseException(f"Parse error in {type(self)}", raw) from e 243 | 244 | def _load(self, raw: dict) -> None: 245 | """Unserialize from raw representation. (Implementation logic) 246 | 247 | Args: 248 | raw: Raw. 249 | """ 250 | self._dirty = raw.get("_dirty", False) 251 | 252 | def save(self, clean: bool = True) -> dict: 253 | """Serialize into raw representation. Clears the dirty bit by default. 254 | 255 | Args: 256 | clean: Whether to clear the dirty bit. 257 | 258 | Returns: 259 | Raw. 260 | """ 261 | ret = {} 262 | if clean: 263 | self._dirty = False 264 | else: 265 | ret["_dirty"] = self._dirty 266 | return ret 267 | 268 | @property 269 | def dirty(self) -> bool: 270 | """Get dirty state. 271 | 272 | Returns: 273 | Whether this element is dirty. 274 | """ 275 | return self._dirty 276 | 277 | 278 | class Annotation(Element): 279 | """Note annotations base class.""" 280 | 281 | __slots__ = ("id",) 282 | 283 | def __init__(self) -> None: 284 | """Construct a note annotation""" 285 | super().__init__() 286 | self.id = self._generateAnnotationId() 287 | 288 | def _load(self, raw: dict) -> None: 289 | super()._load(raw) 290 | self.id = raw.get("id") 291 | 292 | def save(self, clean: bool = True) -> dict: 293 | """Save the annotation""" 294 | ret = {} 295 | if self.id is not None: 296 | ret = super().save(clean) 297 | if self.id is not None: 298 | ret["id"] = self.id 299 | return ret 300 | 301 | @classmethod 302 | def _generateAnnotationId(cls) -> str: 303 | return "{:08x}-{:04x}-{:04x}-{:04x}-{:012x}".format( # noqa: UP032 304 | random.randint(0x00000000, 0xFFFFFFFF), # noqa: S311 305 | random.randint(0x0000, 0xFFFF), # noqa: S311 306 | random.randint(0x0000, 0xFFFF), # noqa: S311 307 | random.randint(0x0000, 0xFFFF), # noqa: S311 308 | random.randint(0x000000000000, 0xFFFFFFFFFFFF), # noqa: S311 309 | ) 310 | 311 | 312 | class WebLink(Annotation): 313 | """Represents a link annotation on a :class:`TopLevelNode`.""" 314 | 315 | __slots__ = ("_title", "_url", "_image_url", "_provenance_url", "_description") 316 | 317 | def __init__(self) -> None: 318 | """Construct a weblink""" 319 | super().__init__() 320 | self._title = None 321 | self._url = "" 322 | self._image_url = None 323 | self._provenance_url = "" 324 | self._description = None 325 | 326 | def _load(self, raw: dict) -> None: 327 | super()._load(raw) 328 | self._title = raw["webLink"].get("title", self.title) 329 | self._url = raw["webLink"]["url"] 330 | self._image_url = raw["webLink"].get("imageUrl", self.image_url) 331 | self._provenance_url = raw["webLink"]["provenanceUrl"] 332 | self._description = raw["webLink"].get("description", self.description) 333 | 334 | def save(self, clean: bool = True) -> dict: 335 | """Save the weblink""" 336 | ret = super().save(clean) 337 | ret["webLink"] = { 338 | "title": self._title, 339 | "url": self._url, 340 | "imageUrl": self._image_url, 341 | "provenanceUrl": self._provenance_url, 342 | "description": self._description, 343 | } 344 | return ret 345 | 346 | @property 347 | def title(self) -> str | None: 348 | """Get the link title. 349 | 350 | Returns: 351 | The link title or None. 352 | """ 353 | return self._title 354 | 355 | @title.setter 356 | def title(self, value: str) -> None: 357 | self._title = value 358 | self._dirty = True 359 | 360 | @property 361 | def url(self) -> str: 362 | """Get the link url. 363 | 364 | Returns: 365 | The link url. 366 | """ 367 | return self._url 368 | 369 | @url.setter 370 | def url(self, value: str) -> None: 371 | self._url = value 372 | self._dirty = True 373 | 374 | @property 375 | def image_url(self) -> str | None: 376 | """Get the link image url. 377 | 378 | Returns: 379 | The image url or None. 380 | """ 381 | return self._image_url 382 | 383 | @image_url.setter 384 | def image_url(self, value: str) -> None: 385 | self._image_url = value 386 | self._dirty = True 387 | 388 | @property 389 | def provenance_url(self) -> str: 390 | """Get the provenance url. 391 | 392 | Returns: 393 | The provenance url. 394 | """ 395 | return self._provenance_url 396 | 397 | @provenance_url.setter 398 | def provenance_url(self, value: str) -> None: 399 | self._provenance_url = value 400 | self._dirty = True 401 | 402 | @property 403 | def description(self) -> str | None: 404 | """Get the link description. 405 | 406 | Returns: 407 | The link description or None. 408 | """ 409 | return self._description 410 | 411 | @description.setter 412 | def description(self, value: str) -> None: 413 | self._description = value 414 | self._dirty = True 415 | 416 | 417 | class Category(Annotation): 418 | """Represents a category annotation on a :class:`TopLevelNode`.""" 419 | 420 | __slots__ = ("_category",) 421 | 422 | def __init__(self) -> None: 423 | """Construct a category annotation""" 424 | super().__init__() 425 | self._category = None 426 | 427 | def _load(self, raw: dict) -> None: 428 | super()._load(raw) 429 | self._category = CategoryValue(raw["topicCategory"]["category"]) 430 | 431 | def save(self, clean: bool = True) -> dict: 432 | """Save the category annotation""" 433 | ret = super().save(clean) 434 | ret["topicCategory"] = {"category": self._category.value} 435 | return ret 436 | 437 | @property 438 | def category(self) -> CategoryValue: 439 | """Get the category. 440 | 441 | Returns: 442 | The category. 443 | """ 444 | return self._category 445 | 446 | @category.setter 447 | def category(self, value: CategoryValue) -> None: 448 | self._category = value 449 | self._dirty = True 450 | 451 | 452 | class TaskAssist(Annotation): 453 | """Unknown.""" 454 | 455 | __slots__ = ("_suggest",) 456 | 457 | def __init__(self) -> None: 458 | """Construct a taskassist annotation""" 459 | super().__init__() 460 | self._suggest = None 461 | 462 | def _load(self, raw: dict) -> None: 463 | super()._load(raw) 464 | self._suggest = raw["taskAssist"]["suggestType"] 465 | 466 | def save(self, clean: bool = True) -> dict: 467 | """Save the taskassist annotation""" 468 | ret = super().save(clean) 469 | ret["taskAssist"] = {"suggestType": self._suggest} 470 | return ret 471 | 472 | @property 473 | def suggest(self) -> str: 474 | """Get the suggestion. 475 | 476 | Returns: 477 | The suggestion. 478 | """ 479 | return self._suggest 480 | 481 | @suggest.setter 482 | def suggest(self, value: str) -> None: 483 | self._suggest = value 484 | self._dirty = True 485 | 486 | 487 | class Context(Annotation): 488 | """Represents a context annotation, which may contain other annotations.""" 489 | 490 | __slots__ = ("_entries",) 491 | 492 | def __init__(self) -> None: 493 | """Construct a context annotation""" 494 | super().__init__() 495 | self._entries = {} 496 | 497 | def _load(self, raw: dict) -> None: 498 | super()._load(raw) 499 | self._entries = {} 500 | for key, entry in raw.get("context", {}).items(): 501 | self._entries[key] = NodeAnnotations.from_json({key: entry}) 502 | 503 | def save(self, clean: bool = True) -> dict: 504 | """Save the context annotation""" 505 | ret = super().save(clean) 506 | context = {} 507 | for entry in self._entries.values(): 508 | context.update(entry.save(clean)) 509 | ret["context"] = context 510 | return ret 511 | 512 | def all(self) -> list[Annotation]: 513 | """Get all sub annotations. 514 | 515 | Returns: 516 | Sub Annotations. 517 | """ 518 | return list(self._entries.values()) 519 | 520 | @property 521 | def dirty(self) -> bool: # noqa: D102 522 | return super().dirty or any( 523 | annotation.dirty for annotation in self._entries.values() 524 | ) 525 | 526 | 527 | class NodeAnnotations(Element): 528 | """Represents the annotation container on a :class:`TopLevelNode`.""" 529 | 530 | __slots__ = ("_annotations",) 531 | 532 | def __init__(self) -> None: 533 | """Construct an annotations container""" 534 | super().__init__() 535 | self._annotations = {} 536 | 537 | def __len__(self) -> int: 538 | return len(self._annotations) 539 | 540 | @classmethod 541 | def from_json(cls, raw: dict) -> Annotation | None: 542 | """Helper to construct an annotation from a dict. 543 | 544 | Args: 545 | raw: Raw annotation representation. 546 | 547 | Returns: 548 | An Annotation object or None. 549 | """ 550 | bcls = None 551 | if "webLink" in raw: 552 | bcls = WebLink 553 | elif "topicCategory" in raw: 554 | bcls = Category 555 | elif "taskAssist" in raw: 556 | bcls = TaskAssist 557 | elif "context" in raw: 558 | bcls = Context 559 | 560 | if bcls is None: 561 | logger.warning("Unknown annotation type: %s", raw.keys()) 562 | return None 563 | annotation = bcls() 564 | annotation.load(raw) 565 | 566 | return annotation 567 | 568 | def all(self) -> list[Annotation]: 569 | """Get all annotations. 570 | 571 | Returns: 572 | Annotations. 573 | """ 574 | return list(self._annotations.values()) 575 | 576 | def _load(self, raw: dict) -> None: 577 | super()._load(raw) 578 | self._annotations = {} 579 | if "annotations" not in raw: 580 | return 581 | 582 | for raw_annotation in raw["annotations"]: 583 | annotation = self.from_json(raw_annotation) 584 | self._annotations[annotation.id] = annotation 585 | 586 | def save(self, clean: bool = True) -> dict: 587 | """Save the annotations container""" 588 | ret = super().save(clean) 589 | ret["kind"] = "notes#annotationsGroup" 590 | if self._annotations: 591 | ret["annotations"] = [ 592 | annotation.save(clean) for annotation in self._annotations.values() 593 | ] 594 | return ret 595 | 596 | def _get_category_node(self) -> Category | None: 597 | for annotation in self._annotations.values(): 598 | if isinstance(annotation, Category): 599 | return annotation 600 | return None 601 | 602 | @property 603 | def category(self) -> CategoryValue | None: 604 | """Get the category. 605 | 606 | Returns: 607 | The category. 608 | """ 609 | node = self._get_category_node() 610 | 611 | return node.category if node is not None else None 612 | 613 | @category.setter 614 | def category(self, value: CategoryValue) -> None: 615 | node = self._get_category_node() 616 | if value is None: 617 | if node is not None: 618 | del self._annotations[node.id] 619 | else: 620 | if node is None: 621 | node = Category() 622 | self._annotations[node.id] = node 623 | 624 | node.category = value 625 | self._dirty = True 626 | 627 | @property 628 | def links(self) -> list[WebLink]: 629 | """Get all links. 630 | 631 | Returns: 632 | A list of links. 633 | """ 634 | return [ 635 | annotation 636 | for annotation in self._annotations.values() 637 | if isinstance(annotation, WebLink) 638 | ] 639 | 640 | def append(self, annotation: Annotation) -> Annotation: 641 | """Add an annotation. 642 | 643 | Args: 644 | annotation: An Annotation object. 645 | 646 | Returns: 647 | The Annotation. 648 | """ 649 | self._annotations[annotation.id] = annotation 650 | self._dirty = True 651 | return annotation 652 | 653 | def remove(self, annotation: Annotation) -> None: 654 | """Removes an annotation. 655 | 656 | Args: 657 | annotation: An Annotation object. 658 | """ 659 | if annotation.id in self._annotations: 660 | del self._annotations[annotation.id] 661 | self._dirty = True 662 | 663 | @property 664 | def dirty(self) -> bool: # noqa: D102 665 | return super().dirty or any( 666 | annotation.dirty for annotation in self._annotations.values() 667 | ) 668 | 669 | 670 | class NodeTimestamps(Element): 671 | """Represents the timestamps associated with a :class:`TopLevelNode`.""" 672 | 673 | __slots__ = ("_created", "_deleted", "_trashed", "_updated", "_edited") 674 | 675 | TZ_FMT = "%Y-%m-%dT%H:%M:%S.%fZ" 676 | 677 | def __init__(self, create_time: float | None = None) -> None: 678 | """Construct a timestamps container""" 679 | super().__init__() 680 | if create_time is None: 681 | create_time = time.time() 682 | 683 | self._created = self.int_to_dt(create_time) 684 | self._deleted = None 685 | self._trashed = None 686 | self._updated = self.int_to_dt(create_time) 687 | self._edited = self.int_to_dt(create_time) 688 | 689 | def _load(self, raw: dict) -> None: 690 | super()._load(raw) 691 | if "created" in raw: 692 | self._created = self.str_to_dt(raw["created"]) 693 | self._deleted = self.str_to_dt(raw["deleted"]) if "deleted" in raw else None 694 | self._trashed = self.str_to_dt(raw["trashed"]) if "trashed" in raw else None 695 | self._updated = self.str_to_dt(raw["updated"]) 696 | self._edited = ( 697 | self.str_to_dt(raw["userEdited"]) if "userEdited" in raw else None 698 | ) 699 | 700 | def save(self, clean: bool = True) -> dict: 701 | """Save the timestamps container""" 702 | ret = super().save(clean) 703 | ret["kind"] = "notes#timestamps" 704 | ret["created"] = self.dt_to_str(self._created) 705 | if self._deleted is not None: 706 | ret["deleted"] = self.dt_to_str(self._deleted) 707 | if self._trashed is not None: 708 | ret["trashed"] = self.dt_to_str(self._trashed) 709 | ret["updated"] = self.dt_to_str(self._updated) 710 | if self._edited is not None: 711 | ret["userEdited"] = self.dt_to_str(self._edited) 712 | return ret 713 | 714 | @classmethod 715 | def str_to_dt(cls, tzs: str | None) -> datetime.datetime: 716 | """Convert a datetime string into an object. 717 | 718 | Params: 719 | tsz: Datetime string. 720 | 721 | Returns: 722 | Datetime. 723 | """ 724 | if tzs is None: 725 | return cls.int_to_dt(0) 726 | 727 | return datetime.datetime.strptime(tzs, cls.TZ_FMT).replace( 728 | tzinfo=datetime.timezone.utc 729 | ) 730 | 731 | @classmethod 732 | def int_to_dt(cls, tz: float) -> datetime.datetime: 733 | """Convert a unix timestamp into an object. 734 | 735 | Params: 736 | ts: Unix timestamp. 737 | 738 | Returns: 739 | Datetime. 740 | """ 741 | return datetime.datetime.fromtimestamp(tz, tz=datetime.timezone.utc) 742 | 743 | @classmethod 744 | def dt_to_str(cls, dt: datetime.datetime) -> str: 745 | """Convert a datetime to a str. 746 | 747 | Params: 748 | dt: Datetime. 749 | 750 | Returns: 751 | Datetime string. 752 | """ 753 | return dt.strftime(cls.TZ_FMT) 754 | 755 | @classmethod 756 | def int_to_str(cls, tz: int) -> str: 757 | """Convert a unix timestamp to a str. 758 | 759 | Returns: 760 | Datetime string. 761 | """ 762 | return cls.dt_to_str(cls.int_to_dt(tz)) 763 | 764 | @property 765 | def created(self) -> datetime.datetime: 766 | """Get the creation datetime. 767 | 768 | Returns: 769 | Datetime. 770 | """ 771 | return self._created 772 | 773 | @created.setter 774 | def created(self, value: datetime.datetime) -> None: 775 | self._created = value 776 | self._dirty = True 777 | 778 | @property 779 | def deleted(self) -> datetime.datetime | None: 780 | """Get the deletion datetime. 781 | 782 | Returns: 783 | Datetime. 784 | """ 785 | return self._deleted 786 | 787 | @deleted.setter 788 | def deleted(self, value: datetime.datetime) -> None: 789 | self._deleted = value 790 | self._dirty = True 791 | 792 | @property 793 | def trashed(self) -> datetime.datetime | None: 794 | """Get the move-to-trash datetime. 795 | 796 | Returns: 797 | Datetime. 798 | """ 799 | return self._trashed 800 | 801 | @trashed.setter 802 | def trashed(self, value: datetime.datetime) -> None: 803 | self._trashed = value 804 | self._dirty = True 805 | 806 | @property 807 | def updated(self) -> datetime.datetime: 808 | """Get the updated datetime. 809 | 810 | Returns: 811 | Datetime. 812 | """ 813 | return self._updated 814 | 815 | @updated.setter 816 | def updated(self, value: datetime.datetime) -> None: 817 | self._updated = value 818 | self._dirty = True 819 | 820 | @property 821 | def edited(self) -> datetime.datetime: 822 | """Get the user edited datetime. 823 | 824 | Returns: 825 | Datetime. 826 | """ 827 | return self._edited 828 | 829 | @edited.setter 830 | def edited(self, value: datetime.datetime) -> None: 831 | self._edited = value 832 | self._dirty = True 833 | 834 | 835 | class NodeSettings(Element): 836 | """Represents the settings associated with a :class:`TopLevelNode`.""" 837 | 838 | __slots__ = ( 839 | "_new_listitem_placement", 840 | "_graveyard_state", 841 | "_checked_listitems_policy", 842 | ) 843 | 844 | def __init__(self) -> None: 845 | """Construct a settings container""" 846 | super().__init__() 847 | self._new_listitem_placement = NewListItemPlacementValue.Bottom 848 | self._graveyard_state = GraveyardStateValue.Collapsed 849 | self._checked_listitems_policy = CheckedListItemsPolicyValue.Graveyard 850 | 851 | def _load(self, raw: dict) -> None: 852 | super()._load(raw) 853 | self._new_listitem_placement = NewListItemPlacementValue( 854 | raw["newListItemPlacement"] 855 | ) 856 | self._graveyard_state = GraveyardStateValue(raw["graveyardState"]) 857 | self._checked_listitems_policy = CheckedListItemsPolicyValue( 858 | raw["checkedListItemsPolicy"] 859 | ) 860 | 861 | def save(self, clean: bool = True) -> dict: 862 | """Save the settings container""" 863 | ret = super().save(clean) 864 | ret["newListItemPlacement"] = self._new_listitem_placement.value 865 | ret["graveyardState"] = self._graveyard_state.value 866 | ret["checkedListItemsPolicy"] = self._checked_listitems_policy.value 867 | return ret 868 | 869 | @property 870 | def new_listitem_placement(self) -> NewListItemPlacementValue: 871 | """Get the default location to insert new listitems. 872 | 873 | Returns: 874 | Placement. 875 | """ 876 | return self._new_listitem_placement 877 | 878 | @new_listitem_placement.setter 879 | def new_listitem_placement(self, value: NewListItemPlacementValue) -> None: 880 | self._new_listitem_placement = value 881 | self._dirty = True 882 | 883 | @property 884 | def graveyard_state(self) -> GraveyardStateValue: 885 | """Get the visibility state for the list graveyard. 886 | 887 | Returns: 888 | Visibility. 889 | """ 890 | return self._graveyard_state 891 | 892 | @graveyard_state.setter 893 | def graveyard_state(self, value: GraveyardStateValue) -> None: 894 | self._graveyard_state = value 895 | self._dirty = True 896 | 897 | @property 898 | def checked_listitems_policy(self) -> CheckedListItemsPolicyValue: 899 | """Get the policy for checked listitems. 900 | 901 | Returns: 902 | Policy. 903 | """ 904 | return self._checked_listitems_policy 905 | 906 | @checked_listitems_policy.setter 907 | def checked_listitems_policy(self, value: CheckedListItemsPolicyValue) -> None: 908 | self._checked_listitems_policy = value 909 | self._dirty = True 910 | 911 | 912 | class NodeCollaborators(Element): 913 | """Represents the collaborators on a :class:`TopLevelNode`.""" 914 | 915 | __slots__ = ("_collaborators",) 916 | 917 | def __init__(self) -> None: 918 | """Construct a collaborators container""" 919 | super().__init__() 920 | self._collaborators = {} 921 | 922 | def __len__(self) -> int: 923 | return len(self._collaborators) 924 | 925 | def load(self, collaborators_raw: list, requests_raw: list) -> None: # noqa: D102 926 | # Parent method not called. 927 | if requests_raw and isinstance(requests_raw[-1], bool): 928 | self._dirty = requests_raw.pop() 929 | else: 930 | self._dirty = False 931 | self._collaborators = {} 932 | for collaborator in collaborators_raw: 933 | self._collaborators[collaborator["email"]] = RoleValue(collaborator["role"]) 934 | for collaborator in requests_raw: 935 | self._collaborators[collaborator["email"]] = ShareRequestValue( 936 | collaborator["type"] 937 | ) 938 | 939 | def save(self, clean: bool = True) -> tuple[list, list]: 940 | """Save the collaborators container""" 941 | # Parent method not called. 942 | collaborators = [] 943 | requests = [] 944 | for email, action in self._collaborators.items(): 945 | if isinstance(action, ShareRequestValue): 946 | requests.append({"email": email, "type": action.value}) 947 | else: 948 | collaborators.append( 949 | {"email": email, "role": action.value, "auxiliary_type": "None"} 950 | ) 951 | if not clean: 952 | requests.append(self._dirty) 953 | else: 954 | self._dirty = False 955 | return (collaborators, requests) 956 | 957 | def add(self, email: str) -> None: 958 | """Add a collaborator. 959 | 960 | Args: 961 | email: Collaborator email address. 962 | """ 963 | if email not in self._collaborators: 964 | self._collaborators[email] = ShareRequestValue.Add 965 | self._dirty = True 966 | 967 | def remove(self, email: str) -> None: 968 | """Remove a Collaborator. 969 | 970 | Args: 971 | email: Collaborator email address. 972 | """ 973 | if email in self._collaborators: 974 | if self._collaborators[email] == ShareRequestValue.Add: 975 | del self._collaborators[email] 976 | else: 977 | self._collaborators[email] = ShareRequestValue.Remove 978 | self._dirty = True 979 | 980 | def all(self) -> list[str]: 981 | """Get all collaborators. 982 | 983 | Returns: 984 | Collaborators. 985 | """ 986 | return [ 987 | email 988 | for email, action in self._collaborators.items() 989 | if action in [RoleValue.Owner, RoleValue.User, ShareRequestValue.Add] 990 | ] 991 | 992 | 993 | class TimestampsMixin: 994 | """A mixin to add methods for updating timestamps.""" 995 | 996 | __slots__ = () # empty to resolve multiple inheritance 997 | 998 | def __init__(self) -> None: 999 | """Instantiate mixin""" 1000 | self.timestamps: NodeTimestamps 1001 | 1002 | def touch(self, edited: bool = False) -> None: 1003 | """Mark the node as dirty. 1004 | 1005 | Args: 1006 | edited: Whether to set the edited time. 1007 | """ 1008 | self._dirty = True 1009 | dt = datetime.datetime.now(tz=datetime.timezone.utc) 1010 | self.timestamps.updated = dt 1011 | if edited: 1012 | self.timestamps.edited = dt 1013 | 1014 | @property 1015 | def trashed(self) -> bool: 1016 | """Get the trashed state. 1017 | 1018 | Returns: 1019 | Whether this item is trashed. 1020 | """ 1021 | return ( 1022 | self.timestamps.trashed is not None 1023 | and self.timestamps.trashed > NodeTimestamps.int_to_dt(0) 1024 | ) 1025 | 1026 | def trash(self) -> None: 1027 | """Mark the item as trashed.""" 1028 | self.timestamps.trashed = datetime.datetime.now(tz=datetime.timezone.utc) 1029 | 1030 | def untrash(self) -> None: 1031 | """Mark the item as untrashed.""" 1032 | self.timestamps.trashed = self.timestamps.int_to_dt(0) 1033 | 1034 | @property 1035 | def deleted(self) -> bool: 1036 | """Get the deleted state. 1037 | 1038 | Returns: 1039 | Whether this item is deleted. 1040 | """ 1041 | return ( 1042 | self.timestamps.deleted is not None 1043 | and self.timestamps.deleted > NodeTimestamps.int_to_dt(0) 1044 | ) 1045 | 1046 | def delete(self) -> None: 1047 | """Mark the item as deleted.""" 1048 | self.timestamps.deleted = datetime.datetime.now(tz=datetime.timezone.utc) 1049 | 1050 | def undelete(self) -> None: 1051 | """Mark the item as undeleted.""" 1052 | self.timestamps.deleted = None 1053 | 1054 | 1055 | class Label(Element, TimestampsMixin): 1056 | """Represents a label.""" 1057 | 1058 | __slots__ = ("id", "_name", "timestamps", "_merged") 1059 | 1060 | def __init__(self) -> None: 1061 | """Construct a label""" 1062 | super().__init__() 1063 | 1064 | create_time = time.time() 1065 | 1066 | self.id = self._generateId(create_time) 1067 | self._name = "" 1068 | self.timestamps = NodeTimestamps(create_time) 1069 | self._merged = NodeTimestamps.int_to_dt(0) 1070 | 1071 | @classmethod 1072 | def _generateId(cls, tz: float) -> str: 1073 | return "tag.{}.{:x}".format( 1074 | "".join( 1075 | [ 1076 | random.choice("abcdefghijklmnopqrstuvwxyz0123456789") # noqa: S311 1077 | for _ in range(12) 1078 | ] 1079 | ), 1080 | int(tz * 1000), 1081 | ) 1082 | 1083 | def _load(self, raw: dict) -> None: 1084 | super()._load(raw) 1085 | self.id = raw["mainId"] 1086 | self._name = raw["name"] 1087 | self.timestamps.load(raw["timestamps"]) 1088 | self._merged = NodeTimestamps.str_to_dt(raw.get("lastMerged")) 1089 | 1090 | def save(self, clean: bool = True) -> dict: 1091 | """Save the label""" 1092 | ret = super().save(clean) 1093 | ret["mainId"] = self.id 1094 | ret["name"] = self._name 1095 | ret["timestamps"] = self.timestamps.save(clean) 1096 | ret["lastMerged"] = NodeTimestamps.dt_to_str(self._merged) 1097 | return ret 1098 | 1099 | @property 1100 | def name(self) -> str: 1101 | """Get the label name. 1102 | 1103 | Returns: 1104 | Label name. 1105 | """ 1106 | return self._name 1107 | 1108 | @name.setter 1109 | def name(self, value: str) -> None: 1110 | self._name = value 1111 | self.touch(True) 1112 | 1113 | @property 1114 | def merged(self) -> datetime.datetime: 1115 | """Get last merge datetime. 1116 | 1117 | Returns: 1118 | Datetime. 1119 | """ 1120 | return self._merged 1121 | 1122 | @merged.setter 1123 | def merged(self, value: datetime.datetime) -> None: 1124 | self._merged = value 1125 | self.touch() 1126 | 1127 | @property 1128 | def dirty(self) -> bool: # noqa: D102 1129 | return super().dirty or self.timestamps.dirty 1130 | 1131 | def __str__(self) -> str: 1132 | return self.name 1133 | 1134 | 1135 | class NodeLabels(Element): 1136 | """Represents the labels on a :class:`TopLevelNode`.""" 1137 | 1138 | __slots__ = ("_labels",) 1139 | 1140 | def __init__(self) -> None: 1141 | """Construct a labels container""" 1142 | super().__init__() 1143 | self._labels = {} 1144 | 1145 | def __len__(self) -> int: 1146 | return len(self._labels) 1147 | 1148 | def _load(self, raw: list) -> None: 1149 | # Parent method not called. 1150 | if raw and isinstance(raw[-1], bool): 1151 | self._dirty = raw.pop() 1152 | else: 1153 | self._dirty = False 1154 | self._labels = {} 1155 | for raw_label in raw: 1156 | self._labels[raw_label["labelId"]] = None 1157 | 1158 | def save(self, clean: bool = True) -> tuple[dict] | tuple[dict, bool]: # noqa: D102 1159 | # Parent method not called. 1160 | ret = [ 1161 | { 1162 | "labelId": label_id, 1163 | "deleted": NodeTimestamps.dt_to_str( 1164 | datetime.datetime.now(tz=datetime.timezone.utc) 1165 | ) 1166 | if label is None 1167 | else NodeTimestamps.int_to_str(0), 1168 | } 1169 | for label_id, label in self._labels.items() 1170 | ] 1171 | if not clean: 1172 | ret.append(self._dirty) 1173 | else: 1174 | self._dirty = False 1175 | return ret 1176 | 1177 | def add(self, label: Label) -> None: 1178 | """Add a label. 1179 | 1180 | Args: 1181 | label: The Label object. 1182 | """ 1183 | self._labels[label.id] = label 1184 | self._dirty = True 1185 | 1186 | def remove(self, label: Label) -> None: 1187 | """Remove a label. 1188 | 1189 | Args: 1190 | label: The Label object. 1191 | """ 1192 | if label.id in self._labels: 1193 | self._labels[label.id] = None 1194 | self._dirty = True 1195 | 1196 | def get(self, label_id: str) -> str: 1197 | """Get a label by ID. 1198 | 1199 | Args: 1200 | label_id: The label ID. 1201 | """ 1202 | return self._labels.get(label_id) 1203 | 1204 | def all(self) -> list[Label]: 1205 | """Get all labels. 1206 | 1207 | Returns: 1208 | Labels. 1209 | """ 1210 | return [label for _, label in self._labels.items() if label is not None] 1211 | 1212 | 1213 | class Node(Element, TimestampsMixin): 1214 | """Node base class.""" 1215 | 1216 | __slots__ = ( 1217 | "parent", 1218 | "id", 1219 | "server_id", 1220 | "parent_id", 1221 | "type", 1222 | "_sort", 1223 | "_version", 1224 | "_text", 1225 | "_children", 1226 | "timestamps", 1227 | "settings", 1228 | "annotations", 1229 | "_moved", 1230 | ) 1231 | 1232 | def __init__( 1233 | self, 1234 | id_: str | None = None, 1235 | type_: str | None = None, 1236 | parent_id: str | None = None, 1237 | ) -> None: 1238 | """Construct a node""" 1239 | super().__init__() 1240 | 1241 | create_time = time.time() 1242 | 1243 | self.parent = None 1244 | self.id = self._generateId(create_time) if id_ is None else id_ 1245 | self.server_id = None 1246 | self.parent_id = parent_id 1247 | self.type = type_ 1248 | self._sort = random.randint(1000000000, 9999999999) # noqa: S311 1249 | self._version = None 1250 | self._text = "" 1251 | self._children = {} 1252 | self.timestamps = NodeTimestamps(create_time) 1253 | self.settings = NodeSettings() 1254 | self.annotations = NodeAnnotations() 1255 | 1256 | # Set if there is no baseVersion in the raw data 1257 | self._moved = False 1258 | 1259 | @classmethod 1260 | def _generateId(cls, tz: float) -> str: 1261 | return "{:x}.{:016x}".format( # noqa: UP032 1262 | int(tz * 1000), 1263 | random.randint(0x0000000000000000, 0xFFFFFFFFFFFFFFFF), # noqa: S311 1264 | ) 1265 | 1266 | def _load(self, raw: dict) -> None: 1267 | super()._load(raw) 1268 | # Verify this is a valid type 1269 | NodeType(raw["type"]) 1270 | if raw["kind"] != "notes#node": 1271 | logger.warning("Unknown node kind: %s", raw["kind"]) 1272 | 1273 | if "mergeConflict" in raw: 1274 | raise exception.MergeException(raw) 1275 | 1276 | self.id = raw["id"] 1277 | self.server_id = raw.get("serverId", self.server_id) 1278 | self.parent_id = raw["parentId"] 1279 | self._sort = raw.get("sortValue", self.sort) 1280 | self._version = raw.get("baseVersion", self._version) 1281 | self._text = raw.get("text", self._text) 1282 | self.timestamps.load(raw["timestamps"]) 1283 | self.settings.load(raw["nodeSettings"]) 1284 | self.annotations.load(raw["annotationsGroup"]) 1285 | 1286 | def save(self, clean: bool = True) -> dict: # noqa: D102 1287 | ret = super().save(clean) 1288 | ret["id"] = self.id 1289 | ret["kind"] = "notes#node" 1290 | ret["type"] = self.type.value 1291 | ret["parentId"] = self.parent_id 1292 | ret["sortValue"] = self._sort 1293 | if not self._moved and self._version is not None: 1294 | ret["baseVersion"] = self._version 1295 | ret["text"] = self._text 1296 | if self.server_id is not None: 1297 | ret["serverId"] = self.server_id 1298 | ret["timestamps"] = self.timestamps.save(clean) 1299 | ret["nodeSettings"] = self.settings.save(clean) 1300 | ret["annotationsGroup"] = self.annotations.save(clean) 1301 | return ret 1302 | 1303 | @property 1304 | def sort(self) -> int: 1305 | """Get the sort id. 1306 | 1307 | Returns: 1308 | Sort id. 1309 | """ 1310 | return int(self._sort) 1311 | 1312 | @sort.setter 1313 | def sort(self, value: int) -> None: 1314 | self._sort = value 1315 | self.touch() 1316 | 1317 | @property 1318 | def version(self) -> int: 1319 | """Get the node version. 1320 | 1321 | Returns: 1322 | Version. 1323 | """ 1324 | return self._version 1325 | 1326 | @property 1327 | def text(self) -> str: 1328 | """Get the text value. 1329 | 1330 | Returns: 1331 | Text value. 1332 | """ 1333 | return self._text 1334 | 1335 | @text.setter 1336 | def text(self, value: str) -> None: 1337 | """Set the text value. 1338 | 1339 | Args: 1340 | value: Text value. 1341 | """ 1342 | self._text = value 1343 | self.timestamps.edited = datetime.datetime.now(tz=datetime.timezone.utc) 1344 | self.touch(True) 1345 | 1346 | @property 1347 | def children(self) -> list["Node"]: 1348 | """Get all children. 1349 | 1350 | Returns: 1351 | Children nodes. 1352 | """ 1353 | return list(self._children.values()) 1354 | 1355 | def get(self, node_id: str) -> "Node | None": 1356 | """Get child node with the given ID. 1357 | 1358 | Args: 1359 | node_id: The node ID. 1360 | 1361 | Returns: 1362 | Child node. 1363 | """ 1364 | return self._children.get(node_id) 1365 | 1366 | def append(self, node: "Node", dirty: bool = True) -> "Node": 1367 | """Add a new child node. 1368 | 1369 | Args: 1370 | node: Node to add. 1371 | dirty: Whether this node should be marked dirty. 1372 | """ 1373 | self._children[node.id] = node 1374 | node.parent = self 1375 | if dirty: 1376 | self.touch() 1377 | 1378 | return node 1379 | 1380 | def remove(self, node: "Node", dirty: bool = True) -> None: 1381 | """Remove the given child node. 1382 | 1383 | Args: 1384 | node: Node to remove. 1385 | dirty: Whether this node should be marked dirty. 1386 | """ 1387 | if node.id in self._children: 1388 | self._children[node.id].parent = None 1389 | del self._children[node.id] 1390 | if dirty: 1391 | self.touch() 1392 | 1393 | @property 1394 | def new(self) -> bool: 1395 | """Get whether this node has been persisted to the server. 1396 | 1397 | Returns: 1398 | True if node is new. 1399 | """ 1400 | return self.server_id is None 1401 | 1402 | @property 1403 | def dirty(self) -> bool: # noqa: D102 1404 | return ( 1405 | super().dirty 1406 | or self.timestamps.dirty 1407 | or self.annotations.dirty 1408 | or self.settings.dirty 1409 | or any(node.dirty for node in self.children) 1410 | ) 1411 | 1412 | 1413 | class Root(Node): 1414 | """Internal root node.""" 1415 | 1416 | __slots__ = () 1417 | 1418 | ID = "root" 1419 | 1420 | def __init__(self) -> None: 1421 | """Construct a root node""" 1422 | super().__init__(id_=self.ID) 1423 | 1424 | @property 1425 | def dirty(self) -> bool: # noqa: D102 1426 | return False 1427 | 1428 | 1429 | class TopLevelNode(Node): 1430 | """Top level node base class.""" 1431 | 1432 | __slots__ = ("_color", "_archived", "_pinned", "_title", "labels", "collaborators") 1433 | 1434 | _TYPE = None 1435 | 1436 | def __init__(self, **kwargs: dict) -> None: 1437 | """Construct a top level node""" 1438 | super().__init__(parent_id=Root.ID, **kwargs) 1439 | self._color = ColorValue.White 1440 | self._archived = False 1441 | self._pinned = False 1442 | self._title = "" 1443 | self.labels = NodeLabels() 1444 | self.collaborators = NodeCollaborators() 1445 | 1446 | def _load(self, raw: dict) -> None: 1447 | super()._load(raw) 1448 | self._color = ColorValue(raw["color"]) if "color" in raw else ColorValue.White 1449 | self._archived = raw.get("isArchived", False) 1450 | self._pinned = raw.get("isPinned", False) 1451 | self._title = raw.get("title", "") 1452 | self.labels.load(raw.get("labelIds", [])) 1453 | 1454 | self.collaborators.load( 1455 | raw.get("roleInfo", []), 1456 | raw.get("shareRequests", []), 1457 | ) 1458 | self._moved = "moved" in raw 1459 | 1460 | def save(self, clean: bool = True) -> dict: # noqa: D102 1461 | ret = super().save(clean) 1462 | ret["color"] = self._color.value 1463 | ret["isArchived"] = self._archived 1464 | ret["isPinned"] = self._pinned 1465 | ret["title"] = self._title 1466 | labels = self.labels.save(clean) 1467 | 1468 | collaborators, requests = self.collaborators.save(clean) 1469 | if labels: 1470 | ret["labelIds"] = labels 1471 | ret["collaborators"] = collaborators 1472 | if requests: 1473 | ret["shareRequests"] = requests 1474 | return ret 1475 | 1476 | @property 1477 | def color(self) -> ColorValue: 1478 | """Get the node color. 1479 | 1480 | Returns: 1481 | Color. 1482 | """ 1483 | return self._color 1484 | 1485 | @color.setter 1486 | def color(self, value: ColorValue) -> None: 1487 | self._color = value 1488 | self.touch(True) 1489 | 1490 | @property 1491 | def archived(self) -> bool: 1492 | """Get the archive state. 1493 | 1494 | Returns: 1495 | Whether this node is archived. 1496 | """ 1497 | return self._archived 1498 | 1499 | @archived.setter 1500 | def archived(self, value: bool) -> None: 1501 | self._archived = value 1502 | self.touch(True) 1503 | 1504 | @property 1505 | def pinned(self) -> bool: 1506 | """Get the pin state. 1507 | 1508 | Returns: 1509 | Whether this node is pinned. 1510 | """ 1511 | return self._pinned 1512 | 1513 | @pinned.setter 1514 | def pinned(self, value: bool) -> None: 1515 | self._pinned = value 1516 | self.touch(True) 1517 | 1518 | @property 1519 | def title(self) -> str: 1520 | """Get the title. 1521 | 1522 | Returns: 1523 | Title. 1524 | """ 1525 | return self._title 1526 | 1527 | @title.setter 1528 | def title(self, value: str) -> None: 1529 | self._title = value 1530 | self.touch(True) 1531 | 1532 | @property 1533 | def url(self) -> str: 1534 | """Get the url for this node. 1535 | 1536 | Returns: 1537 | Google Keep url. 1538 | """ 1539 | return "https://keep.google.com/u/0/#" + self._TYPE.value + "/" + self.id 1540 | 1541 | @property 1542 | def dirty(self) -> bool: # noqa: D102 1543 | return super().dirty or self.labels.dirty or self.collaborators.dirty 1544 | 1545 | @property 1546 | def blobs(self) -> list["Blob"]: 1547 | """Get all media blobs. 1548 | 1549 | Returns: 1550 | Media blobs. 1551 | """ 1552 | return [node for node in self.children if isinstance(node, Blob)] 1553 | 1554 | @property 1555 | def images(self) -> list["NodeImage"]: 1556 | """Get all image blobs""" 1557 | return [blob for blob in self.blobs if isinstance(blob.blob, NodeImage)] 1558 | 1559 | @property 1560 | def drawings(self) -> list["NodeDrawing"]: 1561 | """Get all drawing blobs""" 1562 | return [blob for blob in self.blobs if isinstance(blob.blob, NodeDrawing)] 1563 | 1564 | @property 1565 | def audio(self) -> list["NodeAudio"]: 1566 | """Get all audio blobs""" 1567 | return [blob for blob in self.blobs if isinstance(blob.blob, NodeAudio)] 1568 | 1569 | 1570 | class ListItem(Node): 1571 | """Represents a Google Keep listitem. 1572 | 1573 | Interestingly enough, :class:`Note`s store their content in a single 1574 | child :class:`ListItem`. 1575 | """ 1576 | 1577 | __slots__ = ( 1578 | "parent_item", 1579 | "parent_server_id", 1580 | "super_list_item_id", 1581 | "prev_super_list_item_id", 1582 | "_subitems", 1583 | "_checked", 1584 | ) 1585 | 1586 | def __init__( 1587 | self, 1588 | parent_id: str | None = None, 1589 | parent_server_id: str | None = None, 1590 | super_list_item_id: str | None = None, 1591 | **kwargs: dict, 1592 | ) -> None: 1593 | """Construct a list item node""" 1594 | super().__init__(type_=NodeType.ListItem, parent_id=parent_id, **kwargs) 1595 | self.parent_item = None 1596 | self.parent_server_id = parent_server_id 1597 | self.super_list_item_id = super_list_item_id 1598 | self.prev_super_list_item_id = None 1599 | self._subitems = {} 1600 | self._checked = False 1601 | 1602 | def _load(self, raw: dict) -> None: 1603 | super()._load(raw) 1604 | self.prev_super_list_item_id = self.super_list_item_id 1605 | self.super_list_item_id = raw.get("superListItemId") or None 1606 | self._checked = raw.get("checked", False) 1607 | 1608 | def save(self, clean: bool = True) -> dict: # noqa: D102 1609 | ret = super().save(clean) 1610 | ret["parentServerId"] = self.parent_server_id 1611 | ret["superListItemId"] = self.super_list_item_id 1612 | ret["checked"] = self._checked 1613 | return ret 1614 | 1615 | def add( 1616 | self, 1617 | text: str, 1618 | checked: bool = False, 1619 | sort: NewListItemPlacementValue | int | None = None, 1620 | ) -> "ListItem": 1621 | """Add a new sub item to the list. This item must already be attached to a list. 1622 | 1623 | Args: 1624 | text: The text. 1625 | checked: Whether this item is checked. 1626 | sort: Item id for sorting. 1627 | """ 1628 | if self.parent is None: 1629 | raise exception.InvalidException("Item has no parent") 1630 | node = self.parent.add(text, checked, sort) 1631 | self.indent(node) 1632 | return node 1633 | 1634 | def indent(self, node: "ListItem", dirty: bool = True) -> None: 1635 | """Indent an item. Does nothing if the target has subitems. 1636 | 1637 | Args: 1638 | node: Item to indent. 1639 | dirty: Whether this node should be marked dirty. 1640 | """ 1641 | if node.subitems: 1642 | return 1643 | 1644 | self._subitems[node.id] = node 1645 | node.super_list_item_id = self.id 1646 | node.parent_item = self 1647 | if dirty: 1648 | node.touch(True) 1649 | 1650 | def dedent(self, node: "ListItem", dirty: bool = True) -> None: 1651 | """Dedent an item. Does nothing if the target is not indented under this item. 1652 | 1653 | Args: 1654 | node: Item to dedent. 1655 | dirty : Whether this node should be marked dirty. 1656 | """ 1657 | if node.id not in self._subitems: 1658 | return 1659 | 1660 | del self._subitems[node.id] 1661 | node.super_list_item_id = "" 1662 | node.parent_item = None 1663 | if dirty: 1664 | node.touch(True) 1665 | 1666 | @property 1667 | def subitems(self) -> list["ListItem"]: 1668 | """Get subitems for this item. 1669 | 1670 | Returns: 1671 | Subitems. 1672 | """ 1673 | return List.sorted_items(self._subitems.values()) 1674 | 1675 | @property 1676 | def indented(self) -> bool: 1677 | """Get indentation state. 1678 | 1679 | Returns: 1680 | Whether this item is indented. 1681 | """ 1682 | return self.parent_item is not None 1683 | 1684 | @property 1685 | def checked(self) -> bool: 1686 | """Get the checked state. 1687 | 1688 | Returns: 1689 | Whether this item is checked. 1690 | """ 1691 | return self._checked 1692 | 1693 | @checked.setter 1694 | def checked(self, value: bool) -> None: 1695 | self._checked = value 1696 | self.touch(True) 1697 | 1698 | def __str__(self) -> str: 1699 | return "{}{} {}".format( 1700 | " " if self.indented else "", 1701 | "☑" if self.checked else "☐", 1702 | self.text, 1703 | ) 1704 | 1705 | 1706 | class Note(TopLevelNode): 1707 | """Represents a Google Keep note.""" 1708 | 1709 | __slots__ = () 1710 | 1711 | _TYPE = NodeType.Note 1712 | 1713 | def __init__(self, **kwargs: dict) -> None: 1714 | """Construct a note node""" 1715 | super().__init__(type_=self._TYPE, **kwargs) 1716 | 1717 | def _get_text_node(self) -> ListItem | None: 1718 | node = None 1719 | for child_node in self.children: 1720 | if isinstance(child_node, ListItem): 1721 | node = child_node 1722 | break 1723 | 1724 | return node 1725 | 1726 | @property 1727 | def text(self) -> str: # noqa: D102 1728 | node = self._get_text_node() 1729 | 1730 | if node is None: 1731 | return self._text 1732 | return node.text 1733 | 1734 | @text.setter 1735 | def text(self, value: str) -> None: 1736 | node = self._get_text_node() 1737 | if node is None: 1738 | node = ListItem(parent_id=self.id) 1739 | self.append(node, True) 1740 | node.text = value 1741 | self.touch(True) 1742 | 1743 | def __str__(self) -> str: 1744 | return f"{self.title}\n{self.text}" 1745 | 1746 | 1747 | class List(TopLevelNode): 1748 | """Represents a Google Keep list.""" 1749 | 1750 | _TYPE = NodeType.List 1751 | SORT_DELTA = 10000 # Arbitrary constant 1752 | 1753 | def __init__(self, **kwargs: dict) -> None: 1754 | """Construct a list node""" 1755 | super().__init__(type_=self._TYPE, **kwargs) 1756 | 1757 | def add( 1758 | self, 1759 | text: str, 1760 | checked: bool = False, 1761 | sort: NewListItemPlacementValue | int | None = None, 1762 | ) -> ListItem: 1763 | """Add a new item to the list. 1764 | 1765 | Args: 1766 | text: The text. 1767 | checked: Whether this item is checked. 1768 | sort: Item id for sorting or a placement policy. 1769 | """ 1770 | node = ListItem(parent_id=self.id, parent_server_id=self.server_id) 1771 | node.checked = checked 1772 | node.text = text 1773 | 1774 | items = list(self.items) 1775 | if isinstance(sort, int): 1776 | node.sort = sort 1777 | elif isinstance(sort, NewListItemPlacementValue) and len(items): 1778 | func = max 1779 | delta = self.SORT_DELTA 1780 | if sort == NewListItemPlacementValue.Bottom: 1781 | func = min 1782 | delta *= -1 1783 | 1784 | node.sort = func(int(item.sort) for item in items) + delta 1785 | 1786 | self.append(node, True) 1787 | self.touch(True) 1788 | return node 1789 | 1790 | @property 1791 | def text(self) -> str: # noqa: D102 1792 | return "\n".join(str(node) for node in self.items) 1793 | 1794 | @classmethod 1795 | def sorted_items(cls, items: list[ListItem]) -> list[ListItem]: # noqa: C901 1796 | """Generate a list of sorted list items, taking into account parent items. 1797 | 1798 | Args: 1799 | items: Items to sort. 1800 | 1801 | 1802 | Returns: 1803 | Sorted items. 1804 | """ 1805 | 1806 | class T(tuple): 1807 | """Tuple with element-based sorting""" 1808 | 1809 | __slots__ = () 1810 | 1811 | def __cmp__(self, other: "T") -> int: 1812 | for a, b in itertools.zip_longest(self, other): 1813 | if a != b: 1814 | if a is None: 1815 | return 1 1816 | if b is None: 1817 | return -1 1818 | return a - b 1819 | return 0 1820 | 1821 | def __lt__(self, other: "T") -> bool: # pragma: no cover 1822 | return self.__cmp__(other) < 0 1823 | 1824 | def __gt__(self, other: "T") -> bool: # pragma: no cover 1825 | return self.__cmp__(other) > 0 1826 | 1827 | def __le__(self, other: "T") -> bool: # pragma: no cover 1828 | return self.__cmp__(other) <= 0 1829 | 1830 | def __ge__(self, other: "T") -> bool: # pragma: no cover 1831 | return self.__cmp__(other) >= 0 1832 | 1833 | def __eq__(self, other: "T") -> bool: # pragma: no cover 1834 | return self.__cmp__(other) == 0 1835 | 1836 | def __ne__(self, other: "T") -> bool: # pragma: no cover 1837 | return self.__cmp__(other) != 0 1838 | 1839 | def key_func(x: ListItem) -> T: 1840 | if x.indented: 1841 | return T((int(x.parent_item.sort), int(x.sort))) 1842 | return T((int(x.sort),)) 1843 | 1844 | return sorted(items, key=key_func, reverse=True) 1845 | 1846 | def _items(self, checked: bool | None = None) -> list[ListItem]: 1847 | return [ 1848 | node 1849 | for node in self.children 1850 | if isinstance(node, ListItem) 1851 | and not node.deleted 1852 | and (checked is None or node.checked == checked) 1853 | ] 1854 | 1855 | def sort_items( 1856 | self, key: Callable = attrgetter("text"), reverse: bool = False 1857 | ) -> None: 1858 | """Sort list items in place. By default, the items are alphabetized, but a custom function can be specified. 1859 | 1860 | Args: 1861 | key: A filter function. 1862 | reverse: Whether to reverse the output. 1863 | """ 1864 | sorted_children = sorted(self._items(), key=key, reverse=reverse) 1865 | sort_value = random.randint(1000000000, 9999999999) # noqa: S311 1866 | 1867 | for node in sorted_children: 1868 | node.sort = sort_value 1869 | sort_value -= self.SORT_DELTA 1870 | 1871 | def __str__(self) -> str: 1872 | return "\n".join([self.title] + [str(node) for node in self.items]) 1873 | 1874 | @property 1875 | def items(self) -> list[ListItem]: 1876 | """Get all listitems. 1877 | 1878 | Returns: 1879 | List items. 1880 | """ 1881 | return self.sorted_items(self._items()) 1882 | 1883 | @property 1884 | def checked(self) -> list[ListItem]: 1885 | """Get all checked listitems. 1886 | 1887 | Returns: 1888 | List items. 1889 | """ 1890 | return self.sorted_items(self._items(True)) 1891 | 1892 | @property 1893 | def unchecked(self) -> list[ListItem]: 1894 | """Get all unchecked listitems. 1895 | 1896 | Returns: 1897 | List items. 1898 | """ 1899 | return self.sorted_items(self._items(False)) 1900 | 1901 | 1902 | class NodeBlob(Element): 1903 | """Represents a blob descriptor.""" 1904 | 1905 | __slots__ = ("blob_id", "type", "_media_id", "_mimetype") 1906 | 1907 | _TYPE = None 1908 | 1909 | def __init__(self, type_: str | None = None) -> None: 1910 | """Construct a node blob""" 1911 | super().__init__() 1912 | self.blob_id = None 1913 | self.type = type_ 1914 | self._media_id = None 1915 | self._mimetype = "" 1916 | 1917 | def _load(self, raw: dict) -> None: 1918 | super()._load(raw) 1919 | # Verify this is a valid type 1920 | BlobType(raw["type"]) 1921 | self.blob_id = raw.get("blob_id") 1922 | self._media_id = raw.get("media_id") 1923 | self._mimetype = raw.get("mimetype") 1924 | 1925 | def save(self, clean: bool = True) -> dict: 1926 | """Save the node blob""" 1927 | ret = super().save(clean) 1928 | ret["kind"] = "notes#blob" 1929 | ret["type"] = self.type.value 1930 | if self.blob_id is not None: 1931 | ret["blob_id"] = self.blob_id 1932 | if self._media_id is not None: 1933 | ret["media_id"] = self._media_id 1934 | ret["mimetype"] = self._mimetype 1935 | return ret 1936 | 1937 | 1938 | class NodeAudio(NodeBlob): 1939 | """Represents an audio blob.""" 1940 | 1941 | __slots__ = ("_length",) 1942 | 1943 | _TYPE = BlobType.Audio 1944 | 1945 | def __init__(self) -> None: 1946 | """Construct a node audio blob""" 1947 | super().__init__(type_=self._TYPE) 1948 | self._length = None 1949 | 1950 | def _load(self, raw: dict) -> None: 1951 | super()._load(raw) 1952 | self._length = raw.get("length") 1953 | 1954 | def save(self, clean: bool = True) -> dict: 1955 | """Save the node audio blob""" 1956 | ret = super().save(clean) 1957 | if self._length is not None: 1958 | ret["length"] = self._length 1959 | return ret 1960 | 1961 | @property 1962 | def length(self) -> int: 1963 | """Get length of the audio clip. 1964 | 1965 | Returns: 1966 | Audio length. 1967 | """ 1968 | return self._length 1969 | 1970 | 1971 | class NodeImage(NodeBlob): 1972 | """Represents an image blob.""" 1973 | 1974 | __slots__ = ( 1975 | "_is_uploaded", 1976 | "_width", 1977 | "_height", 1978 | "_byte_size", 1979 | "_extracted_text", 1980 | "_extraction_status", 1981 | ) 1982 | 1983 | _TYPE = BlobType.Image 1984 | 1985 | def __init__(self) -> None: 1986 | """Construct a node image blob""" 1987 | super().__init__(type_=self._TYPE) 1988 | self._is_uploaded = False 1989 | self._width = 0 1990 | self._height = 0 1991 | self._byte_size = 0 1992 | self._extracted_text = "" 1993 | self._extraction_status = "" 1994 | 1995 | def _load(self, raw: dict) -> None: 1996 | super()._load(raw) 1997 | self._is_uploaded = raw.get("is_uploaded") or False 1998 | self._width = raw.get("width") 1999 | self._height = raw.get("height") 2000 | self._byte_size = raw.get("byte_size") 2001 | self._extracted_text = raw.get("extracted_text") 2002 | self._extraction_status = raw.get("extraction_status") 2003 | 2004 | def save(self, clean: bool = True) -> dict: 2005 | """Save the node image blob""" 2006 | ret = super().save(clean) 2007 | ret["width"] = self._width 2008 | ret["height"] = self._height 2009 | ret["byte_size"] = self._byte_size 2010 | ret["extracted_text"] = self._extracted_text 2011 | ret["extraction_status"] = self._extraction_status 2012 | return ret 2013 | 2014 | @property 2015 | def width(self) -> int: 2016 | """Get width of image. 2017 | 2018 | Returns: 2019 | Image width. 2020 | """ 2021 | return self._width 2022 | 2023 | @property 2024 | def height(self) -> int: 2025 | """Get height of image. 2026 | 2027 | Returns: 2028 | Image height. 2029 | """ 2030 | return self._height 2031 | 2032 | @property 2033 | def byte_size(self) -> int: 2034 | """Get size of image in bytes. 2035 | 2036 | Returns: 2037 | Image byte size. 2038 | """ 2039 | return self._byte_size 2040 | 2041 | @property 2042 | def extracted_text(self) -> str: 2043 | """Get text extracted from image 2044 | 2045 | Returns: 2046 | Extracted text. 2047 | """ 2048 | return self._extracted_text 2049 | 2050 | @property 2051 | def url(self) -> str: 2052 | """Get a url to the image. 2053 | 2054 | Returns: 2055 | Image url. 2056 | """ 2057 | raise NotImplementedError 2058 | 2059 | 2060 | class NodeDrawing(NodeBlob): 2061 | """Represents a drawing blob.""" 2062 | 2063 | __slots__ = ("_extracted_text", "_extraction_status", "_drawing_info") 2064 | 2065 | _TYPE = BlobType.Drawing 2066 | 2067 | def __init__(self) -> None: 2068 | """Construct a node drawing blob""" 2069 | super().__init__(type_=self._TYPE) 2070 | self._extracted_text = "" 2071 | self._extraction_status = "" 2072 | self._drawing_info = None 2073 | 2074 | def _load(self, raw: dict) -> None: 2075 | super()._load(raw) 2076 | self._extracted_text = raw.get("extracted_text") 2077 | self._extraction_status = raw.get("extraction_status") 2078 | drawing_info = None 2079 | if "drawingInfo" in raw: 2080 | drawing_info = NodeDrawingInfo() 2081 | drawing_info.load(raw["drawingInfo"]) 2082 | self._drawing_info = drawing_info 2083 | 2084 | def save(self, clean: bool = True) -> dict: 2085 | """Save the node drawing blob""" 2086 | ret = super().save(clean) 2087 | ret["extracted_text"] = self._extracted_text 2088 | ret["extraction_status"] = self._extraction_status 2089 | if self._drawing_info is not None: 2090 | ret["drawingInfo"] = self._drawing_info.save(clean) 2091 | return ret 2092 | 2093 | @property 2094 | def extracted_text(self) -> str: 2095 | """Get text extracted from image 2096 | 2097 | Returns: 2098 | Extracted text. 2099 | """ 2100 | return ( 2101 | self._drawing_info.snapshot.extracted_text 2102 | if self._drawing_info is not None 2103 | else "" 2104 | ) 2105 | 2106 | 2107 | class NodeDrawingInfo(Element): 2108 | """Represents information about a drawing blob.""" 2109 | 2110 | __slots__ = ( 2111 | "drawing_id", 2112 | "snapshot", 2113 | "_snapshot_fingerprint", 2114 | "_thumbnail_generated_time", 2115 | "_ink_hash", 2116 | "_snapshot_proto_fprint", 2117 | ) 2118 | 2119 | def __init__(self) -> None: 2120 | """Construct a drawing info container""" 2121 | super().__init__() 2122 | self.drawing_id = "" 2123 | self.snapshot = NodeImage() 2124 | self._snapshot_fingerprint = "" 2125 | self._thumbnail_generated_time = NodeTimestamps.int_to_dt(0) 2126 | self._ink_hash = "" 2127 | self._snapshot_proto_fprint = "" 2128 | 2129 | def _load(self, raw: dict) -> None: 2130 | super()._load(raw) 2131 | self.drawing_id = raw["drawingId"] 2132 | self.snapshot.load(raw["snapshotData"]) 2133 | self._snapshot_fingerprint = raw.get( 2134 | "snapshotFingerprint", self._snapshot_fingerprint 2135 | ) 2136 | self._thumbnail_generated_time = NodeTimestamps.str_to_dt( 2137 | raw.get("thumbnailGeneratedTime") 2138 | ) 2139 | self._ink_hash = raw.get("inkHash", "") 2140 | self._snapshot_proto_fprint = raw.get( 2141 | "snapshotProtoFprint", self._snapshot_proto_fprint 2142 | ) 2143 | 2144 | def save(self, clean: bool = True) -> dict: # noqa: D102 2145 | ret = super().save(clean) 2146 | ret["drawingId"] = self.drawing_id 2147 | ret["snapshotData"] = self.snapshot.save(clean) 2148 | ret["snapshotFingerprint"] = self._snapshot_fingerprint 2149 | ret["thumbnailGeneratedTime"] = NodeTimestamps.dt_to_str( 2150 | self._thumbnail_generated_time 2151 | ) 2152 | ret["inkHash"] = self._ink_hash 2153 | ret["snapshotProtoFprint"] = self._snapshot_proto_fprint 2154 | return ret 2155 | 2156 | 2157 | class Blob(Node): 2158 | """Represents a Google Keep blob.""" 2159 | 2160 | __slots__ = ("blob",) 2161 | 2162 | _blob_type_map = { # noqa: RUF012 2163 | BlobType.Audio: NodeAudio, 2164 | BlobType.Image: NodeImage, 2165 | BlobType.Drawing: NodeDrawing, 2166 | } 2167 | 2168 | def __init__(self, parent_id: str | None = None, **kwargs: dict) -> None: 2169 | """Construct a blob""" 2170 | super().__init__(type_=NodeType.Blob, parent_id=parent_id, **kwargs) 2171 | self.blob = None 2172 | 2173 | @classmethod 2174 | def from_json(cls: type, raw: dict) -> NodeBlob | None: 2175 | """Helper to construct a blob from a dict. 2176 | 2177 | Args: 2178 | raw: Raw blob representation. 2179 | 2180 | Returns: 2181 | A NodeBlob object or None. 2182 | """ 2183 | if raw is None: 2184 | return None 2185 | 2186 | _type = raw.get("type") 2187 | if _type is None: 2188 | return None 2189 | 2190 | bcls = None 2191 | try: 2192 | bcls = cls._blob_type_map[BlobType(_type)] 2193 | except (KeyError, ValueError) as e: 2194 | logger.warning("Unknown blob type: %s", _type) 2195 | if DEBUG: # pragma: no cover 2196 | raise exception.ParseException(f"Parse error for {_type}", raw) from e 2197 | return None 2198 | blob = bcls() 2199 | blob.load(raw) 2200 | 2201 | return blob 2202 | 2203 | def _load(self, raw: dict) -> None: 2204 | super()._load(raw) 2205 | self.blob = self.from_json(raw.get("blob")) 2206 | 2207 | def save(self, clean: bool = True) -> dict: 2208 | """Save the blob""" 2209 | ret = super().save(clean) 2210 | if self.blob is not None: 2211 | ret["blob"] = self.blob.save(clean) 2212 | return ret 2213 | 2214 | 2215 | _type_map = { 2216 | NodeType.Note: Note, 2217 | NodeType.List: List, 2218 | NodeType.ListItem: ListItem, 2219 | NodeType.Blob: Blob, 2220 | } 2221 | 2222 | 2223 | def from_json(raw: dict) -> Node | None: 2224 | """Helper to construct a node from a dict. 2225 | 2226 | Args: 2227 | raw: Raw node representation. 2228 | 2229 | Returns: 2230 | A Node object or None. 2231 | """ 2232 | ncls = None 2233 | _type = raw.get("type") 2234 | try: 2235 | ncls = _type_map[NodeType(_type)] 2236 | except (KeyError, ValueError) as e: 2237 | logger.warning("Unknown node type: %s", _type) 2238 | if DEBUG: # pragma: no cover 2239 | raise exception.ParseException(f"Parse error for {_type}", raw) from e 2240 | return None 2241 | node = ncls() 2242 | node.load(raw) 2243 | 2244 | return node 2245 | 2246 | 2247 | if DEBUG: # pragma: no cover 2248 | Node.__load = Node._load # noqa: SLF001 2249 | 2250 | def _load(self, raw): # noqa: ANN001, ANN202 2251 | self.__load(raw) 2252 | self._find_discrepancies(raw) 2253 | 2254 | Node._load = _load # noqa: SLF001 2255 | -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kiwiz/gkeepapi/d56a9e388dc66a51ec7d0dd37e443c2beb37f5a7/test/__init__.py -------------------------------------------------------------------------------- /test/data/keep-01: -------------------------------------------------------------------------------- 1 | { 2 | "kind": "notes#downSync", 3 | "fromVersion": "AFOfgb7ywdXKsSnjsM3Pvqh5cwd5kTJzaW7azHCugtU=", 4 | "toVersion": "AFOfgb53D+BaE7kFNKe7iJjV7oYNW14FUfhGOuk3tIZU4Pu67riot2b56XG0/VMlAMPGMMG2siiXK9wpG5Tf9vfRuMUK/ucqwdOsvQ==", 5 | "nodes": [ 6 | { 7 | "kind": "notes#node", 8 | "id": "17084da1cc1.97dda8b1f14635ed", 9 | "serverId": "1UKMHcnjcQ9h3B_r1RvOZ--zIGSuc-6ydq0uKRcQMHA9d1YAp4qpPaWAQ043ooR4OVv8n", 10 | "parentId": "root", 11 | "type": "NOTE", 12 | "timestamps": { 13 | "kind": "notes#timestamps", 14 | "created": "2020-02-27T04:14:18.261Z", 15 | "updated": "2020-02-27T04:14:18.382Z", 16 | "trashed": "1970-01-01T00:00:00.000Z", 17 | "userEdited": "2020-02-27T04:14:18.261Z", 18 | "recentSharedChangesSeen": "2020-02-27T04:14:15.406Z" 19 | }, 20 | "title": "", 21 | "nodeSettings": { 22 | "newListItemPlacement": "BOTTOM", 23 | "checkedListItemsPolicy": "GRAVEYARD", 24 | "graveyardState": "EXPANDED" 25 | }, 26 | "isArchived": false, 27 | "isPinned": false, 28 | "color": "DEFAULT", 29 | "sortValue": "9744825649", 30 | "annotationsGroup": { 31 | "kind": "notes#annotationsGroup" 32 | }, 33 | "lastModifierEmail": "test@example.com", 34 | "moved": "1" 35 | }, 36 | { 37 | "kind": "notes#node", 38 | "id": "17084da1cc2.b2cbb131c3611b2f", 39 | "serverId": "130noyjV0mMQMlf8lCNbyZgucQLFtdcy9WuOQfijx3sn26oHwPiJfPviLqUn4U-7HZKZm", 40 | "parentId": "17084da1cc1.97dda8b1f14635ed", 41 | "parentServerId": "1UKMHcnjcQ9h3B_r1RvOZ--zIGSuc-6ydq0uKRcQMHA9d1YAp4qpPaWAQ043ooR4OVv8n", 42 | "type": "LIST_ITEM", 43 | "timestamps": { 44 | "kind": "notes#timestamps", 45 | "created": "2020-02-27T04:14:18.261Z", 46 | "updated": "2020-02-27T04:14:18.261Z" 47 | }, 48 | "text": "Wow", 49 | "checked": false, 50 | "baseVersion": "1", 51 | "nodeSettings": { 52 | "newListItemPlacement": "BOTTOM", 53 | "checkedListItemsPolicy": "GRAVEYARD", 54 | "graveyardState": "EXPANDED" 55 | }, 56 | "sortValue": "0", 57 | "annotationsGroup": { 58 | "kind": "notes#annotationsGroup" 59 | } 60 | } 61 | ], 62 | "truncated": false, 63 | "forceFullResync": false, 64 | "responseHeader": { 65 | "updateState": "UTD", 66 | "requestId": "" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /test/data/keep-02: -------------------------------------------------------------------------------- 1 | { 2 | "kind": "notes#downSync", 3 | "fromVersion": "AFOfgb53D+BaE7kFNKe7iJjV7oYNW14FUfhGOuk3tIZU4Pu67riot2b56XG0/VMlAMPGMMG2siiXK9wpG5Tf9vfRuMUK/ucqwdOsvQ==", 4 | "toVersion": "AFOfgb5IfnJsXRCULIVWWSIJaNbmTFhSlxUeeQozHilLl6ieQ4eTvPYRuG4jwkGf0mZ6nIZuklf0Za6wByyvyAsqq8fyLrdBhxyemg==", 5 | "nodes": [ 6 | { 7 | "kind": "notes#node", 8 | "id": "17084da58fc.1cb339317a0eb446", 9 | "serverId": "1li944JhyypOwBIO2SXxGvSgFVqoo51ZVZzMLOdn_pTmkfxIOVWs5xzbXYKrox_mSVc5j", 10 | "parentId": "root", 11 | "type": "NOTE", 12 | "timestamps": { 13 | "kind": "notes#timestamps", 14 | "created": "2020-02-27T04:14:27.622Z", 15 | "updated": "2020-02-27T04:14:27.622Z", 16 | "trashed": "1970-01-01T00:00:00.000Z", 17 | "userEdited": "2020-02-27T04:14:27.622Z" 18 | }, 19 | "title": "Test", 20 | "nodeSettings": { 21 | "newListItemPlacement": "BOTTOM", 22 | "checkedListItemsPolicy": "GRAVEYARD", 23 | "graveyardState": "COLLAPSED" 24 | }, 25 | "isArchived": false, 26 | "isPinned": false, 27 | "color": "DEFAULT", 28 | "sortValue": "9112214960", 29 | "annotationsGroup": { 30 | "kind": "notes#annotationsGroup" 31 | }, 32 | "lastModifierEmail": "test@example.com", 33 | "moved": "1" 34 | } 35 | ], 36 | "truncated": false, 37 | "forceFullResync": false, 38 | "responseHeader": { 39 | "updateState": "UTD", 40 | "requestId": "" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /test/data/reminder-00: -------------------------------------------------------------------------------- 1 | { 2 | "task": [ 3 | { 4 | "taskId": { 5 | "serverAssignedId": "1659661805927283615", 6 | "clientAssignedId": "KEEP___v3___1539756945840_121291030___R19b10b2d/master" 7 | }, 8 | "objectVersion": { 9 | "storageVersion": "23877" 10 | }, 11 | "taskList": { 12 | "systemListId": "memento" 13 | }, 14 | "title": "Reminder", 15 | "createdTimeMillis": "1582776838103", 16 | "snoozed": true, 17 | "dueDate": { 18 | "year": 2020, 19 | "month": 2, 20 | "day": 27, 21 | "time": { 22 | "hour": 8, 23 | "minute": 0, 24 | "second": 0 25 | } 26 | }, 27 | "extensions": { 28 | "keepExtension": { 29 | "clientNoteId": "1539756945840.121291030", 30 | "serverNoteId": "1KBcMLL7Ksz2W-6o-EaoTdh14YKUq_ESSWZTB5JVqahbaLVuCGeKW76N5szO05g" 31 | } 32 | }, 33 | "recurrenceInfo": { 34 | "recurrence": { 35 | "frequency": "daily", 36 | "recurrenceStart": { 37 | "startDateTime": { 38 | "year": 2020, 39 | "month": 2, 40 | "day": 27, 41 | "time": { 42 | "hour": 8, 43 | "minute": 0, 44 | "second": 0 45 | } 46 | } 47 | }, 48 | "recurrenceEnd": { 49 | "endDateTime": { 50 | "year": 2021, 51 | "month": 2, 52 | "day": 26 53 | }, 54 | "autoRenew": true 55 | }, 56 | "dailyPattern": { 57 | "timeOfDay": { 58 | "hour": 8, 59 | "minute": 0, 60 | "second": 0 61 | } 62 | } 63 | }, 64 | "recurrenceId": { 65 | "id": "KEEP___v3___1539756945840_121291030___R19b10b2d" 66 | }, 67 | "master": true 68 | }, 69 | "externalApplicationLink": { 70 | "application": "keepReminder", 71 | "id": "KEEP___v3___1539756945840_121291030___R19b10b2d/master" 72 | } 73 | } 74 | ], 75 | "storageVersion": "23892" 76 | } 77 | -------------------------------------------------------------------------------- /test/data/reminder-01: -------------------------------------------------------------------------------- 1 | { 2 | "highestStorageVersion": "23892", 3 | "hasMore": false 4 | } 5 | -------------------------------------------------------------------------------- /test/data/reminder-02: -------------------------------------------------------------------------------- 1 | { 2 | "task": [ 3 | { 4 | "taskId": { 5 | "serverAssignedId": "1659661805927283615", 6 | "clientAssignedId": "KEEP___v3___1539756945840_121291030___R19b10b2d/master" 7 | }, 8 | "objectVersion": { 9 | "storageVersion": "23877" 10 | }, 11 | "taskList": { 12 | "systemListId": "memento" 13 | }, 14 | "title": "Reminder", 15 | "createdTimeMillis": "1582776838103", 16 | "snoozed": true, 17 | "dueDate": { 18 | "year": 2020, 19 | "month": 2, 20 | "day": 27, 21 | "time": { 22 | "hour": 8, 23 | "minute": 0, 24 | "second": 0 25 | } 26 | }, 27 | "extensions": { 28 | "keepExtension": { 29 | "clientNoteId": "1539756945840.121291030", 30 | "serverNoteId": "1KBcMLL7Ksz2W-6o-EaoTdh14YKUq_ESSWZTB5JVqahbaLVuCGeKW76N5szO05g" 31 | } 32 | }, 33 | "recurrenceInfo": { 34 | "recurrence": { 35 | "frequency": "daily", 36 | "recurrenceStart": { 37 | "startDateTime": { 38 | "year": 2020, 39 | "month": 2, 40 | "day": 27, 41 | "time": { 42 | "hour": 8, 43 | "minute": 0, 44 | "second": 0 45 | } 46 | } 47 | }, 48 | "recurrenceEnd": { 49 | "endDateTime": { 50 | "year": 2021, 51 | "month": 2, 52 | "day": 26 53 | }, 54 | "autoRenew": true 55 | }, 56 | "dailyPattern": { 57 | "timeOfDay": { 58 | "hour": 8, 59 | "minute": 0, 60 | "second": 0 61 | } 62 | } 63 | }, 64 | "recurrenceId": { 65 | "id": "KEEP___v3___1539756945840_121291030___R19b10b2d" 66 | }, 67 | "master": true 68 | }, 69 | "externalApplicationLink": { 70 | "application": "keepReminder", 71 | "id": "KEEP___v3___1539756945840_121291030___R19b10b2d/master" 72 | } 73 | } 74 | ], 75 | "storageVersion": "23892" 76 | } 77 | -------------------------------------------------------------------------------- /test/data/reminder-03: -------------------------------------------------------------------------------- 1 | { 2 | "highestStorageVersion": "23892", 3 | "hasMore": false 4 | } 5 | -------------------------------------------------------------------------------- /test/data/reminder-04: -------------------------------------------------------------------------------- 1 | { 2 | "task": [ 3 | { 4 | "taskId": { 5 | "serverAssignedId": "1659661805927283615", 6 | "clientAssignedId": "KEEP___v3___1539756945840_121291030___R19b10b2d/master" 7 | }, 8 | "objectVersion": { 9 | "storageVersion": "23877" 10 | }, 11 | "taskList": { 12 | "systemListId": "memento" 13 | }, 14 | "title": "Reminder", 15 | "createdTimeMillis": "1582776838103", 16 | "snoozed": true, 17 | "dueDate": { 18 | "year": 2020, 19 | "month": 2, 20 | "day": 27, 21 | "time": { 22 | "hour": 8, 23 | "minute": 0, 24 | "second": 0 25 | } 26 | }, 27 | "extensions": { 28 | "keepExtension": { 29 | "clientNoteId": "1539756945840.121291030", 30 | "serverNoteId": "1KBcMLL7Ksz2W-6o-EaoTdh14YKUq_ESSWZTB5JVqahbaLVuCGeKW76N5szO05g" 31 | } 32 | }, 33 | "recurrenceInfo": { 34 | "recurrence": { 35 | "frequency": "daily", 36 | "recurrenceStart": { 37 | "startDateTime": { 38 | "year": 2020, 39 | "month": 2, 40 | "day": 27, 41 | "time": { 42 | "hour": 8, 43 | "minute": 0, 44 | "second": 0 45 | } 46 | } 47 | }, 48 | "recurrenceEnd": { 49 | "endDateTime": { 50 | "year": 2021, 51 | "month": 2, 52 | "day": 26 53 | }, 54 | "autoRenew": true 55 | }, 56 | "dailyPattern": { 57 | "timeOfDay": { 58 | "hour": 8, 59 | "minute": 0, 60 | "second": 0 61 | } 62 | } 63 | }, 64 | "recurrenceId": { 65 | "id": "KEEP___v3___1539756945840_121291030___R19b10b2d" 66 | }, 67 | "master": true 68 | }, 69 | "externalApplicationLink": { 70 | "application": "keepReminder", 71 | "id": "KEEP___v3___1539756945840_121291030___R19b10b2d/master" 72 | } 73 | } 74 | ], 75 | "storageVersion": "23892" 76 | } 77 | -------------------------------------------------------------------------------- /test/data/reminder-05: -------------------------------------------------------------------------------- 1 | { 2 | "highestStorageVersion": "23892", 3 | "hasMore": false 4 | } 5 | -------------------------------------------------------------------------------- /test/test_client.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import unittest 3 | import logging 4 | import gpsoauth 5 | import json 6 | from unittest import mock 7 | 8 | from gkeepapi import Keep, node 9 | 10 | logging.getLogger(node.__name__).addHandler(logging.NullHandler()) 11 | 12 | 13 | def resp(name): 14 | with open("test/data/%s" % name, "r") as fh: 15 | return json.load(fh) 16 | 17 | 18 | def mock_keep(keep): 19 | k_api = mock.MagicMock() 20 | r_api = mock.MagicMock() 21 | m_api = mock.MagicMock() 22 | keep._keep_api._session = k_api 23 | keep._reminders_api._session = r_api 24 | keep._media_api._session = m_api 25 | 26 | return k_api, r_api, m_api 27 | 28 | 29 | class KeepTests(unittest.TestCase): 30 | @mock.patch("gpsoauth.perform_oauth") 31 | @mock.patch("gpsoauth.perform_master_login") 32 | def test_sync(self, perform_master_login, perform_oauth): 33 | keep = Keep() 34 | k_api, r_api, m_api = mock_keep(keep) 35 | 36 | perform_master_login.return_value = { 37 | "Token": "FAKETOKEN", 38 | } 39 | perform_oauth.return_value = { 40 | "Auth": "FAKEAUTH", 41 | } 42 | k_api.request().json.side_effect = [ 43 | resp("keep-00"), 44 | ] 45 | r_api.request().json.side_effect = [ 46 | resp("reminder-00"), 47 | resp("reminder-01"), 48 | ] 49 | keep.login("user", "pass") 50 | 51 | self.assertEqual(39, len(keep.all())) 52 | -------------------------------------------------------------------------------- /test/test_nodes.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import unittest 3 | import logging 4 | 5 | from gkeepapi import node, exception 6 | from operator import attrgetter 7 | 8 | logging.getLogger(node.__name__).addHandler(logging.NullHandler()) 9 | 10 | 11 | def generate_save_load(cls): 12 | """Constructs an empty object and clones it from the serialized representation.""" 13 | a = cls() 14 | b = cls() 15 | b.load(a.save()) 16 | return a.save(), b.save() 17 | 18 | 19 | def clean_node(n): 20 | n.save() 21 | if isinstance(n, node.Node): 22 | for c in n.children: 23 | c.save() 24 | assert not n.dirty 25 | return n 26 | 27 | 28 | class AnnotationTests(unittest.TestCase): 29 | def test_save_load(self): 30 | a, b = generate_save_load(node.Annotation) 31 | self.assertEqual(a, b) 32 | 33 | # Test WebLink 34 | a, b = generate_save_load(node.WebLink) 35 | self.assertEqual(a, b) 36 | 37 | # Test Category 38 | def Category(): 39 | c = node.Category() 40 | c.category = node.CategoryValue.Books 41 | return c 42 | 43 | a, b = generate_save_load(Category) 44 | self.assertEqual(a, b) 45 | 46 | # Test TaskAssist 47 | a, b = generate_save_load(node.TaskAssist) 48 | self.assertEqual(a, b) 49 | 50 | def test_weblink_fields(self): 51 | n = node.WebLink() 52 | 53 | TITLE = "Title" 54 | URL = "https://url.url" 55 | IMAGEURL = "https://img.url" 56 | PROVENANCEURL = "https://provenance.url" 57 | DESCRIPTION = "Description" 58 | 59 | clean_node(n) 60 | self.assertEqual(None, n.title) 61 | self.assertEqual("", n.url) 62 | self.assertEqual(None, n.image_url) 63 | self.assertEqual("", n.provenance_url) 64 | self.assertEqual(None, n.description) 65 | 66 | clean_node(n) 67 | n.title = TITLE 68 | self.assertTrue(n.dirty) 69 | self.assertEqual(TITLE, n.title) 70 | 71 | clean_node(n) 72 | n.url = URL 73 | self.assertTrue(n.dirty) 74 | self.assertEqual(URL, n.url) 75 | 76 | clean_node(n) 77 | n.image_url = IMAGEURL 78 | self.assertTrue(n.dirty) 79 | self.assertEqual(IMAGEURL, n.image_url) 80 | 81 | clean_node(n) 82 | n.provenance_url = PROVENANCEURL 83 | self.assertTrue(n.dirty) 84 | self.assertEqual(PROVENANCEURL, n.provenance_url) 85 | 86 | clean_node(n) 87 | n.description = DESCRIPTION 88 | self.assertTrue(n.dirty) 89 | self.assertEqual(DESCRIPTION, n.description) 90 | 91 | def test_category_fields(self): 92 | n = node.Category() 93 | n.category = node.CategoryValue.TV 94 | 95 | CATEGORY = node.CategoryValue.Books 96 | 97 | clean_node(n) 98 | n.category = CATEGORY 99 | self.assertTrue(n.dirty) 100 | self.assertEqual(CATEGORY, n.category) 101 | 102 | def test_taskassist_fields(self): 103 | n = node.TaskAssist() 104 | 105 | SUGGEST = "UNKNOWN" 106 | 107 | clean_node(n) 108 | n.suggest = SUGGEST 109 | self.assertTrue(n.dirty) 110 | self.assertEqual(SUGGEST, n.suggest) 111 | 112 | 113 | class ContextTests(unittest.TestCase): 114 | def test_save_load(self): 115 | a, b = generate_save_load(node.Context) 116 | self.assertEqual(a, b) 117 | 118 | def test_subannotations(self): 119 | n = node.Context() 120 | 121 | URL = "https://url.url" 122 | 123 | sub = node.WebLink() 124 | sub.id = None 125 | sub.url = URL 126 | 127 | n._entries["x"] = sub 128 | self.assertTrue(n.dirty) 129 | 130 | self.assertEqual([sub], list(n.all())) 131 | 132 | data = n.save() 133 | n.load(data) 134 | self.assertEqual(1, len(n.all())) 135 | 136 | 137 | class NodeAnnotationsTests(unittest.TestCase): 138 | def test_save_load(self): 139 | a, b = generate_save_load(node.NodeAnnotations) 140 | self.assertEqual(a, b) 141 | 142 | def test_fields(self): 143 | n = node.NodeAnnotations() 144 | 145 | CATEGORY = node.CategoryValue.Books 146 | CATEGORY_2 = node.CategoryValue.TV 147 | 148 | clean_node(n) 149 | n.category = None 150 | self.assertTrue(n.dirty) 151 | self.assertEqual(None, n.category) 152 | 153 | sub = node.Category() 154 | sub.category = CATEGORY 155 | clean_node(sub) 156 | 157 | clean_node(n) 158 | n.append(sub) 159 | self.assertTrue(n.dirty) 160 | self.assertEqual(CATEGORY, n.category) 161 | 162 | clean_node(n) 163 | sub.category = CATEGORY_2 164 | self.assertTrue(n.dirty) 165 | self.assertEqual(CATEGORY_2, n.category) 166 | 167 | clean_node(n) 168 | n.remove(sub) 169 | self.assertTrue(n.dirty) 170 | 171 | self.assertEqual([], n.links) 172 | 173 | sub = node.WebLink() 174 | clean_node(sub) 175 | 176 | clean_node(n) 177 | n.append(sub) 178 | self.assertTrue(n.dirty) 179 | self.assertEqual([sub], n.links) 180 | self.assertEqual(1, len(n)) 181 | 182 | 183 | class NodeTimestampsTests(unittest.TestCase): 184 | def test_save_load(self): 185 | a, b = generate_save_load(node.NodeTimestamps) 186 | self.assertEqual(a, b) 187 | 188 | def test_fields(self): 189 | n = node.NodeTimestamps(0) 190 | 191 | TZ = node.NodeTimestamps.int_to_dt(0) 192 | 193 | clean_node(n) 194 | n.created = TZ 195 | self.assertTrue(n.dirty) 196 | self.assertEqual(TZ, n.created) 197 | 198 | clean_node(n) 199 | n.deleted = TZ 200 | self.assertTrue(n.dirty) 201 | self.assertEqual(TZ, n.deleted) 202 | 203 | clean_node(n) 204 | n.trashed = TZ 205 | self.assertTrue(n.dirty) 206 | self.assertEqual(TZ, n.trashed) 207 | 208 | clean_node(n) 209 | n.updated = TZ 210 | self.assertTrue(n.dirty) 211 | self.assertEqual(TZ, n.updated) 212 | 213 | clean_node(n) 214 | n.edited = TZ 215 | self.assertTrue(n.dirty) 216 | self.assertEqual(TZ, n.edited) 217 | 218 | 219 | class NodeSettingsTests(unittest.TestCase): 220 | def test_save_load(self): 221 | a, b = generate_save_load(node.NodeSettings) 222 | self.assertEqual(a, b) 223 | 224 | def test_fields(self): 225 | n = node.NodeSettings() 226 | 227 | ITEMPLACEMENT = node.NewListItemPlacementValue.Bottom 228 | GRAVEYARDSTATE = node.GraveyardStateValue.Collapsed 229 | ITEMPOLICY = node.CheckedListItemsPolicyValue.Graveyard 230 | 231 | clean_node(n) 232 | n.new_listitem_placement = ITEMPLACEMENT 233 | self.assertTrue(n.dirty) 234 | self.assertEqual(ITEMPLACEMENT, n.new_listitem_placement) 235 | 236 | clean_node(n) 237 | n.graveyard_state = GRAVEYARDSTATE 238 | self.assertTrue(n.dirty) 239 | self.assertEqual(GRAVEYARDSTATE, n.graveyard_state) 240 | 241 | clean_node(n) 242 | n.checked_listitems_policy = ITEMPOLICY 243 | self.assertTrue(n.dirty) 244 | self.assertEqual(ITEMPOLICY, n.checked_listitems_policy) 245 | 246 | 247 | class NodeLabelsTests(unittest.TestCase): 248 | def test_save_load(self): 249 | a, b = generate_save_load(node.NodeLabels) 250 | self.assertEqual(a, b) 251 | 252 | def test_fields(self): 253 | n = node.NodeLabels() 254 | 255 | LABEL = "Label" 256 | 257 | sub = node.Label() 258 | sub.name = LABEL 259 | clean_node(sub) 260 | 261 | clean_node(n) 262 | n.add(sub) 263 | self.assertTrue(n.dirty) 264 | self.assertEqual(sub, n.get(sub.id)) 265 | self.assertEqual([sub], n.all()) 266 | self.assertEqual(1, len(n)) 267 | 268 | clean_node(n) 269 | n.remove(sub) 270 | self.assertTrue(n.dirty) 271 | self.assertEqual([], n.all()) 272 | 273 | 274 | class NodeTests(unittest.TestCase): 275 | def test_save_load(self): 276 | a, b = generate_save_load(lambda: node.Node(type_=node.NodeType.Note)) 277 | self.assertEqual(a, b) 278 | 279 | a, b = generate_save_load(lambda: node.TopLevelNode(type_=node.NodeType.Note)) 280 | self.assertEqual(a, b) 281 | 282 | a, b = generate_save_load(node.Note) 283 | self.assertEqual(a, b) 284 | 285 | a, b = generate_save_load(node.List) 286 | self.assertEqual(a, b) 287 | 288 | a, b = generate_save_load(node.ListItem) 289 | self.assertEqual(a, b) 290 | 291 | # a, b = generate_save_load(node.Blob) # FIXME: Broken 292 | # self.assertEqual(a, b) 293 | 294 | def test_fields(self): 295 | n = node.Node(type_=node.NodeType.Note) 296 | 297 | TZ = node.NodeTimestamps.int_to_dt(0) 298 | SORT = 1 299 | TEXT = "Text" 300 | ITEMPLACEMENT = node.NewListItemPlacementValue.Bottom 301 | 302 | clean_node(n) 303 | n.timestamps.created = TZ 304 | self.assertTrue(n.dirty) 305 | self.assertEqual(TZ, n.timestamps.created) 306 | 307 | clean_node(n) 308 | n.sort = SORT 309 | self.assertTrue(n.dirty) 310 | self.assertEqual(SORT, n.sort) 311 | 312 | clean_node(n) 313 | n.text = TEXT 314 | self.assertTrue(n.dirty) 315 | self.assertEqual(TEXT, n.text) 316 | 317 | clean_node(n) 318 | n.settings.new_listitem_placement = ITEMPLACEMENT 319 | self.assertTrue(n.dirty) 320 | self.assertEqual(ITEMPLACEMENT, n.settings.new_listitem_placement) 321 | 322 | clean_node(n) 323 | n.annotations.category = None 324 | self.assertTrue(n.dirty) 325 | self.assertEqual(None, n.annotations.category) 326 | 327 | sub = node.ListItem() 328 | clean_node(sub) 329 | 330 | clean_node(n) 331 | n.append(sub) 332 | self.assertTrue(n.dirty) 333 | 334 | clean_node(n) 335 | sub.text = TEXT 336 | self.assertTrue(n.dirty) 337 | 338 | clean_node(n) 339 | sub.delete() 340 | self.assertTrue(n.dirty) 341 | 342 | def test_delete(self): 343 | n = node.Node(type_=node.NodeType.Note) 344 | clean_node(n) 345 | 346 | n.delete() 347 | self.assertTrue(n.timestamps.deleted) 348 | self.assertTrue(n.dirty) 349 | 350 | 351 | class RootTests(unittest.TestCase): 352 | def test_fields(self): 353 | r = node.Root() 354 | 355 | self.assertFalse(r.dirty) 356 | 357 | 358 | class TestElement(node.Element, node.TimestampsMixin): 359 | def __init__(self): 360 | super(TestElement, self).__init__() 361 | self.timestamps = node.NodeTimestamps(0) 362 | 363 | 364 | class TimestampsMixinTests(unittest.TestCase): 365 | def test_touch(self): 366 | n = TestElement() 367 | n.touch() 368 | self.assertTrue(n.dirty) 369 | self.assertTrue(n.timestamps.updated > node.NodeTimestamps.int_to_dt(0)) 370 | self.assertTrue(n.timestamps.edited == node.NodeTimestamps.int_to_dt(0)) 371 | 372 | n.touch(True) 373 | self.assertTrue(n.timestamps.updated > node.NodeTimestamps.int_to_dt(0)) 374 | self.assertTrue(n.timestamps.edited > node.NodeTimestamps.int_to_dt(0)) 375 | 376 | def test_deleted(self): 377 | n = TestElement() 378 | self.assertFalse(n.deleted) 379 | 380 | n.timestamps.deleted = None 381 | self.assertFalse(n.deleted) 382 | 383 | n.timestamps.deleted = node.NodeTimestamps.int_to_dt(0) 384 | self.assertFalse(n.deleted) 385 | 386 | n.timestamps.deleted = node.NodeTimestamps.int_to_dt(1) 387 | self.assertTrue(n.deleted) 388 | 389 | def test_trashed(self): 390 | n = TestElement() 391 | self.assertFalse(n.trashed) 392 | 393 | n.timestamps.trashed = None 394 | self.assertFalse(n.trashed) 395 | 396 | n.timestamps.trashed = node.NodeTimestamps.int_to_dt(0) 397 | self.assertFalse(n.trashed) 398 | 399 | n.timestamps.trashed = node.NodeTimestamps.int_to_dt(1) 400 | self.assertTrue(n.trashed) 401 | 402 | def test_trash(self): 403 | n = TestElement() 404 | 405 | clean_node(n) 406 | n.trash() 407 | 408 | self.assertTrue(n.trashed) 409 | self.assertTrue(n.timestamps.dirty) 410 | self.assertTrue(n.timestamps.trashed > node.NodeTimestamps.int_to_dt(0)) 411 | 412 | clean_node(n) 413 | n.untrash() 414 | 415 | self.assertTrue(n.timestamps.dirty) 416 | self.assertFalse(n.trashed) 417 | 418 | def test_delete(self): 419 | n = TestElement() 420 | 421 | clean_node(n) 422 | n.delete() 423 | 424 | self.assertTrue(n.timestamps.dirty) 425 | self.assertTrue(n.timestamps.deleted > node.NodeTimestamps.int_to_dt(0)) 426 | 427 | clean_node(n) 428 | n.undelete() 429 | 430 | self.assertTrue(n.timestamps.dirty) 431 | self.assertIsNone(n.timestamps.deleted) 432 | 433 | 434 | class TopLevelNodeTests(unittest.TestCase): 435 | def test_fields(self): 436 | n = node.TopLevelNode(type_=node.NodeType.Note) 437 | 438 | COLOR = node.ColorValue.White 439 | ARCHIVED = True 440 | PINNED = True 441 | TITLE = "Title" 442 | LABEL = "x" 443 | 444 | clean_node(n) 445 | n.color = COLOR 446 | self.assertTrue(n.dirty) 447 | self.assertEqual(COLOR, n.color) 448 | 449 | clean_node(n) 450 | n.archived = ARCHIVED 451 | self.assertTrue(n.dirty) 452 | self.assertEqual(ARCHIVED, n.archived) 453 | 454 | clean_node(n) 455 | n.pinned = PINNED 456 | self.assertTrue(n.dirty) 457 | self.assertEqual(PINNED, n.pinned) 458 | 459 | clean_node(n) 460 | n.title = TITLE 461 | self.assertTrue(n.dirty) 462 | self.assertEqual(TITLE, n.title) 463 | 464 | l = node.Label() 465 | l.name = LABEL 466 | clean_node(l) 467 | 468 | clean_node(n) 469 | n.labels.add(l) 470 | self.assertTrue(n.dirty) 471 | 472 | b = node.Blob() 473 | clean_node(b) 474 | 475 | clean_node(n) 476 | n.append(b) 477 | self.assertEqual([b], n.blobs) 478 | 479 | clean_node(n) 480 | n.labels.remove(l) 481 | self.assertTrue(n.dirty) 482 | 483 | 484 | class NoteTests(unittest.TestCase): 485 | def test_fields(self): 486 | n = node.Note(id_="3") 487 | 488 | TEXT = "Text" 489 | 490 | clean_node(n) 491 | n.text = TEXT 492 | self.assertTrue(n.dirty) 493 | self.assertEqual(TEXT, n.text) 494 | 495 | self.assertEqual("https://keep.google.com/u/0/#NOTE/3", n.url) 496 | 497 | def test_str(self): 498 | n = node.Note() 499 | 500 | TITLE = "Title" 501 | TEXT = "Test" 502 | 503 | self.assertEqual("", n.text) 504 | 505 | n.title = TITLE 506 | n.text = TEXT 507 | self.assertEqual("%s\n%s" % (TITLE, TEXT), str(n)) 508 | self.assertEqual(TEXT, n.text) 509 | 510 | 511 | class ListTests(unittest.TestCase): 512 | def test_fields(self): 513 | n = node.List() 514 | 515 | TEXT = "Text" 516 | 517 | clean_node(n) 518 | sub_a = n.add(TEXT, sort=1) 519 | sub_b = n.add(TEXT, True, sort=2) 520 | self.assertTrue(n.dirty) 521 | 522 | self.assertEqual([sub_b], n.checked) 523 | self.assertEqual([sub_a], n.unchecked) 524 | 525 | def test_indent(self): 526 | n = node.List() 527 | 528 | TEXT = "Test" 529 | 530 | clean_node(n) 531 | sub_a = node.ListItem() 532 | clean_node(sub_a) 533 | 534 | sub_b = n.add(TEXT) 535 | clean_node(sub_b) 536 | 537 | with self.assertRaises(exception.InvalidException): 538 | sub_a.add(sub_b) 539 | 540 | sub_c = sub_b.add(TEXT) 541 | self.assertIsInstance(sub_c, node.ListItem) 542 | 543 | clean_node(sub_b) 544 | sub_a.indent(sub_b) 545 | self.assertFalse(sub_b.dirty) 546 | 547 | clean_node(sub_b) 548 | sub_a.dedent(sub_b) 549 | self.assertFalse(sub_b.dirty) 550 | 551 | clean_node(sub_c) 552 | sub_b.dedent(sub_c) 553 | self.assertTrue(sub_c.dirty) 554 | 555 | def test_sort_items(self): 556 | n = node.List() 557 | 558 | sub_a = n.add("a", sort=3) 559 | sub_b = n.add("b", sort=0) 560 | sub_c = n.add("c", sort=5) 561 | sub_d = n.add("d", sort=1) 562 | sub_e = n.add("e", sort=2) 563 | sub_f = n.add("f", sort=4) 564 | 565 | n.sort_items() 566 | 567 | self.assertEqual(sub_a.id, n.items[0].id) 568 | self.assertEqual(sub_b.id, n.items[1].id) 569 | self.assertEqual(sub_c.id, n.items[2].id) 570 | self.assertEqual(sub_d.id, n.items[3].id) 571 | self.assertEqual(sub_e.id, n.items[4].id) 572 | self.assertEqual(sub_f.id, n.items[5].id) 573 | 574 | n = node.List() 575 | 576 | sub_a = n.add("a", sort=3) 577 | sub_ba = sub_a.add("ba", sort=3) 578 | sub_aa = sub_a.add("aa", sort=2) 579 | sub_b = n.add("b", sort=4) 580 | sub_bd = sub_b.add("bd", sort=4) 581 | sub_bc = sub_b.add("bc", sort=3) 582 | 583 | n.sort_items() 584 | 585 | self.assertEqual(sub_a.id, n.items[0].id) 586 | self.assertEqual(sub_aa.id, n.items[1].id) 587 | self.assertEqual(sub_ba.id, n.items[2].id) 588 | self.assertEqual(sub_b.id, n.items[3].id) 589 | self.assertEqual(sub_bc.id, n.items[4].id) 590 | self.assertEqual(sub_bd.id, n.items[5].id) 591 | 592 | n = node.List() 593 | 594 | time_1 = n.add("test1") 595 | time_2 = n.add("test2") 596 | time_3 = n.add("test3") 597 | 598 | n.sort_items(key=attrgetter("timestamps.created"), reverse=True) 599 | 600 | self.assertEqual(time_3.id, n.items[0].id) 601 | self.assertEqual(time_2.id, n.items[1].id) 602 | self.assertEqual(time_1.id, n.items[2].id) 603 | 604 | def test_str(self): 605 | n = node.List() 606 | 607 | TITLE = "Title" 608 | 609 | n.title = TITLE 610 | sub_a = n.add("a", sort=0) 611 | sub_b = n.add("b", sort=0) 612 | sub_c = n.add("c", sort=1) 613 | sub_d = n.add("d", sort=2) 614 | sub_e = n.add("e", sort=3) 615 | sub_f = n.add("f", sort=4) 616 | sub_g = n.add("g", sort=node.NewListItemPlacementValue.Bottom) 617 | 618 | sub_c.indent(sub_d) 619 | sub_f.indent(sub_e) 620 | 621 | n_str = "%s\n☐ f\n ☐ e\n☐ c\n ☐ d\n☐ a\n☐ b\n☐ g" % TITLE 622 | n_text = "☐ f\n ☐ e\n☐ c\n ☐ d\n☐ a\n☐ b\n☐ g" 623 | self.assertEqual(n_str, str(n)) 624 | self.assertEqual(n_text, n.text) 625 | 626 | 627 | class ListItemTests(unittest.TestCase): 628 | def test_fields(self): 629 | n = node.ListItem() 630 | 631 | TEXT = "Text" 632 | CHECKED = False 633 | 634 | clean_node(n) 635 | n.text = TEXT 636 | self.assertTrue(n.dirty) 637 | self.assertEqual(TEXT, n.text) 638 | 639 | clean_node(n) 640 | n.checked = CHECKED 641 | self.assertTrue(n.dirty) 642 | self.assertEqual(CHECKED, n.checked) 643 | 644 | 645 | class CollaboratorTests(unittest.TestCase): 646 | def test_save_load(self): 647 | a = node.NodeCollaborators() 648 | b = node.NodeCollaborators() 649 | b.load(*a.save()) 650 | self.assertEqual(a.save(), b.save()) 651 | 652 | def test_fields(self): 653 | n = node.TopLevelNode(type_=node.NodeType.Note) 654 | 655 | collab = "user@google.com" 656 | 657 | clean_node(n) 658 | n.collaborators.add(collab) 659 | self.assertTrue(n.dirty) 660 | self.assertEqual(1, len(n.collaborators)) 661 | 662 | clean_node(n) 663 | n.collaborators.remove(collab) 664 | self.assertTrue(n.dirty) 665 | 666 | 667 | class BlobTests(unittest.TestCase): 668 | def test_save_load(self): 669 | a, b = generate_save_load(node.NodeImage) 670 | self.assertEqual(a, b) 671 | 672 | a, b = generate_save_load(node.NodeDrawing) 673 | self.assertEqual(a, b) 674 | 675 | a, b = generate_save_load(node.NodeAudio) 676 | self.assertEqual(a, b) 677 | 678 | def test_fields(self): 679 | # FIXME: Not implemented 680 | pass 681 | 682 | 683 | class LabelTests(unittest.TestCase): 684 | def test_save_load(self): 685 | a, b = generate_save_load(node.Label) 686 | self.assertEqual(a, b) 687 | 688 | def test_fields(self): 689 | n = node.Label() 690 | 691 | NAME = "name" 692 | CATEGORY_2 = node.CategoryValue.TV 693 | TZ = node.NodeTimestamps.int_to_dt(0) 694 | 695 | clean_node(n) 696 | n.name = NAME 697 | self.assertTrue(n.dirty) 698 | self.assertEqual(NAME, n.name) 699 | self.assertEqual(NAME, str(n)) 700 | 701 | clean_node(n) 702 | n.merged = TZ 703 | self.assertTrue(n.dirty) 704 | self.assertEqual(TZ, n.merged) 705 | 706 | 707 | class ElementTests(unittest.TestCase): 708 | def test_load(self): 709 | with self.assertRaises(exception.ParseException): 710 | node.Node().load({}) 711 | 712 | def test_save(self): 713 | n = node.Element() 714 | data = n.save(False) 715 | 716 | self.assertIn("_dirty", data) 717 | 718 | 719 | class LoadTests(unittest.TestCase): 720 | def test_load(self): 721 | self.assertIsNone(node.from_json({})) 722 | 723 | data = { 724 | "id": "", 725 | "parentId": "", 726 | "timestamps": { 727 | "created": "2000-01-01T00:00:00.000Z", 728 | "updated": "2000-01-01T00:00:00.000Z", 729 | }, 730 | "nodeSettings": { 731 | "newListItemPlacement": "TOP", 732 | "graveyardState": "COLLAPSED", 733 | "checkedListItemsPolicy": "DEFAULT", 734 | }, 735 | "annotationsGroup": {}, 736 | "kind": "notes#node", 737 | "type": "NOTE", 738 | } 739 | self.assertIsInstance(node.from_json(data), node.Note) 740 | 741 | 742 | if __name__ == "__main__": 743 | unittest.main() 744 | --------------------------------------------------------------------------------