├── .gitignore ├── .pylintrc ├── Makefile ├── README.md ├── config.example.yml ├── pyproject.toml └── src └── keep_cli ├── __init__.py ├── __main__.py ├── application.py ├── commands.py ├── constants.py ├── query.py ├── util.py └── widget ├── __init__.py ├── edit.py ├── grid.py ├── help.py ├── kanban.py ├── labels.py ├── note.py ├── search.py ├── status.py ├── util.py └── views.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | config.yml 3 | *.pyc 4 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [BASIC] 2 | good-names=logger 3 | [MESSAGES CONTROL] 4 | disable=line-too-long,bad-continuation,too-many-instance-attributes,invalid-name,too-many-lines 5 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build clean upload all 2 | 3 | build: src 4 | python3 -m build 5 | 6 | clean: 7 | rm -rf dist 8 | 9 | upload: 10 | twine upload dist/*.whl 11 | 12 | all: build upload 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | keep-cli 2 | ======== 3 | 4 | Google Keep frontend for terminals. 5 | 6 | *keep-cli is not supported nor endorsed by Google.* 7 | 8 | This is alpha quality code - take care if using in production! Feel free to open an issue if you have questions, see any bugs or have a feature request. PRs are welcome too! 9 | 10 | Installation 11 | ------------ 12 | 13 | ``` 14 | pip install google-keep-cli 15 | ``` 16 | 17 | Screen cast (WIP) 18 | ----------------- 19 | 20 | [![asciicast](https://asciinema.org/a/fS2aTxTTeWbmSetmhaa8AMzpa.png)](https://asciinema.org/a/fS2aTxTTeWbmSetmhaa8AMzpa) 21 | 22 | Features 23 | -------- 24 | 25 | - Terminal based UI for Google Keep 26 | - Subcommands for viewing and editing notes 27 | - Import/Export notes from/to markdown 28 | 29 | Config 30 | ------ 31 | 32 | Configuration is stored in the `~/.keep`. A config file is automatically created if one doesn't already exist, but you can inspect `config.example.yml` for an example. 33 | 34 | Usage 35 | ----- 36 | 37 | To get a list of commands: 38 | ``` 39 | $ keep -h 40 | ``` 41 | 42 | ### TUI mode ### 43 | 44 | If you want to manage and view notes via the TUI interface: 45 | ``` 46 | $ keep tui 47 | ``` 48 | 49 | Press `?` to see a list of keyboard shortcuts in this mode 50 | 51 | ### Commands ### 52 | 53 | `find`: Get the IDs of all notes that match the specified criteria 54 | 55 | `get`: View data from a note 56 | 57 | `set`: Update data from a note 58 | 59 | `sync`: Manually trigger a sync (this is typically unnecessary) 60 | 61 | `import`: Import markdown files into Keep 62 | 63 | `export`: Export notes to markdown files 64 | 65 | #### Note about performance #### 66 | 67 | A sync is performed automatically prior to every command. This is useful for simple usage, as it ensures that the data is up to date, but can be slow if performing a large number of actions. To work around this, pass the `--offline` flag to operate on the local cache and then flush all changes with a manual sync. 68 | 69 | Example: 70 | 71 | ``` 72 | $ keep sync 73 | $ keep --offline set --note id1 --text One 74 | $ keep --offline set --note id2 --text Two 75 | $ keep --offline set --note id3 --text Three 76 | $ keep sync 77 | ``` 78 | 79 | Todo 80 | ---- 81 | 82 | There are still many missing/incomplete features: 83 | 84 | - Search 85 | - Saving a search 86 | - Views 87 | - View management (Edit/Delete) 88 | - Kanban view 89 | - Scrolling support 90 | - Editing 91 | - Label management (Add/Remove) 92 | - Color picker 93 | 94 | -------------------------------------------------------------------------------- /config.example.yml: -------------------------------------------------------------------------------- 1 | username: 'user@gmail.com' 2 | size: # Size of notes 3 | width: 26 4 | height: 10 5 | views: # Saved note views 6 | default: 7 | type: grid 8 | query: 9 | name: Active notes 10 | archived: false 11 | trashed: false 12 | archived: 13 | type: grid 14 | query: 15 | name: Archived notes 16 | archived: true 17 | trashed: false 18 | # example: 19 | # type: grid 20 | # query: 21 | # name: Example 22 | # labels: 23 | # - todo 24 | # colors: 25 | # - yellow 26 | # pinned: true 27 | # archived: true 28 | # trashed: true 29 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "google-keep-cli" 3 | version = "0.0.1" 4 | authors = [ 5 | { name="Kai", email="z@kwi.li" }, 6 | ] 7 | description = "Google Keep frontend for terminals" 8 | readme = "README.md" 9 | requires-python = ">=3.7" 10 | classifiers = [ 11 | "Programming Language :: Python :: 3", 12 | "License :: OSI Approved :: MIT License", 13 | "Operating System :: OS Independent", 14 | ] 15 | dependencies = [ 16 | 'gkeepapi >= 0.14.2', 17 | 'wcwidth >= 0.2.6', 18 | 'urwid >= 2.0', 19 | 'urwid_readline >= 0.13', 20 | 'PyYAML >= 6.0', 21 | 'keyring >= 23.13.1', 22 | ] 23 | 24 | [project.scripts] 25 | keep = 'keep_cli.__main__:main' 26 | 27 | 28 | [project.urls] 29 | "Homepage" = "https://github.com/kiwiz/keep-cli" 30 | "Bug Tracker" = "https://github.com/kiwiz/keep-cli/issues" 31 | -------------------------------------------------------------------------------- /src/keep_cli/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kiwiz/keep-cli/60c8d18bc9f92619b872611742cddde4b53c4e3e/src/keep_cli/__init__.py -------------------------------------------------------------------------------- /src/keep_cli/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import sys 3 | import os 4 | import re 5 | import argparse 6 | import getpass 7 | import logging 8 | import yaml 9 | import keyring 10 | import gkeepapi 11 | from keep_cli import commands 12 | from keep_cli import util 13 | 14 | logger = logging.getLogger("keep-cli") 15 | logger.setLevel(logging.INFO) 16 | ch = logging.StreamHandler(sys.stdout) 17 | formatter = logging.Formatter("[%(levelname)s] %(message)s") 18 | ch.setFormatter(formatter) 19 | logger.addHandler(ch) 20 | 21 | 22 | def path_type(path: str): 23 | return os.path.expanduser(path) 24 | 25 | 26 | def re_type(exp: str): 27 | try: 28 | return re.compile(exp) 29 | except re.error as e: 30 | raise argparse.ArgumentTypeError(e.msg) 31 | 32 | 33 | def bool_type(b: str): 34 | if b.lower() in ("yes", "true", "t", "y", "1"): 35 | return True 36 | elif b.lower() in ("no", "false", "f", "n", "0"): 37 | return False 38 | else: 39 | raise argparse.ArgumentTypeError("Boolean value expected") 40 | 41 | 42 | def main(): 43 | parser = argparse.ArgumentParser("Keep-CLI") 44 | parser.add_argument( 45 | "--config-dir", 46 | type=path_type, 47 | default="~/.keep", 48 | help="Configuration directory", 49 | ) 50 | parser.add_argument("--offline", action="store_true", help="Offline mode") 51 | subparsers = parser.add_subparsers(help="Command", dest="command", required=True) 52 | 53 | parent_note_parser = argparse.ArgumentParser(add_help=False) 54 | parent_note_parser.add_argument("--note", type=str, help="Note id", required=True) 55 | 56 | tui_parser = subparsers.add_parser("tui", help="TUI interface") 57 | tui_parser.add_argument("--note", type=str, help="Note id") 58 | tui_parser.set_defaults(func=commands.tui) 59 | 60 | find_parser = subparsers.add_parser("find", help="Find all matching notes") 61 | find_parser.add_argument( 62 | "--query", type=re_type, help="Regular expression match on title & text" 63 | ) 64 | find_parser.add_argument( 65 | "--colors", type=str, nargs="*", help="List of colors to match. Comma separated" 66 | ) 67 | find_parser.add_argument( 68 | "--labels", type=str, nargs="*", help="List of labels to match. Comma separated" 69 | ) 70 | find_parser.add_argument("--pinned", type=bool_type, help="Pinned status to match") 71 | find_parser.add_argument( 72 | "--archived", type=bool_type, help="Archived status to match" 73 | ) 74 | find_parser.add_argument( 75 | "--trashed", type=bool_type, help="Trashed status to match" 76 | ) 77 | find_parser.set_defaults(func=commands.find) 78 | 79 | get_parser = subparsers.add_parser( 80 | "get", help="Get data on a note", parents=[parent_note_parser] 81 | ) 82 | get_parser.add_argument( 83 | "--title", action="store_true", help="Display title of a note" 84 | ) 85 | get_parser.add_argument( 86 | "--text", action="store_true", help="Display body of a note" 87 | ) 88 | get_parser.add_argument( 89 | "--checked", action="store_true", help="Display list checked items on a list" 90 | ) 91 | get_parser.add_argument( 92 | "--unchecked", action="store_true", help="Display unchecked items on a list" 93 | ) 94 | get_parser.add_argument( 95 | "--labels", action="store_true", help="Display labels on a note" 96 | ) 97 | get_parser.set_defaults(func=commands.get) 98 | 99 | set_parser = subparsers.add_parser( 100 | "set", help="Set data on a note", parents=[parent_note_parser] 101 | ) 102 | set_parser.add_argument("--title", type=str, help="Set title of a note") 103 | set_parser.add_argument("--text", type=str, help="Set body of a note") 104 | set_parser.set_defaults(func=commands.set_) 105 | 106 | sync_parser = subparsers.add_parser("sync", help="Manually sync keep notes") 107 | sync_parser.set_defaults(func=commands.sync) 108 | 109 | import_parser = subparsers.add_parser( 110 | "import", help="Import notes from a file or directory" 111 | ) 112 | import_parser.add_argument( 113 | "--label", type=str, help="Label to add to imported notes" 114 | ) 115 | import_parser.add_argument( 116 | "--delete", action="store_true", help="Delete missing notes" 117 | ) 118 | import_parser.add_argument( 119 | "--dir", type=str, default="notes", help="Source directory" 120 | ) 121 | import_parser.set_defaults(func=commands.import_) 122 | 123 | export_parser = subparsers.add_parser("export", help="Export notes to a directory") 124 | export_parser.add_argument( 125 | "--dir", type=str, default="notes", help="Target directory" 126 | ) 127 | export_parser.set_defaults(func=commands.export) 128 | 129 | args = parser.parse_args() 130 | 131 | if not os.path.isdir(args.config_dir): 132 | os.makedirs(args.config_dir) 133 | 134 | config_file = os.path.join(args.config_dir, "config.yml") 135 | config = {} 136 | if os.path.isfile(config_file): 137 | with open(config_file, "r") as fh: 138 | config = yaml.load(fh, Loader=yaml.Loader) 139 | else: 140 | config = { 141 | "username": input("Username: "), 142 | "views": { 143 | "default": { 144 | "name": "Default", 145 | "type": "grid", 146 | }, 147 | }, 148 | } 149 | with open(config_file, "w") as fh: 150 | yaml.dump(config, fh, default_flow_style=False) 151 | 152 | keep = gkeepapi.Keep() 153 | 154 | logged_in = False 155 | 156 | if args.offline: 157 | logger.info("Offline mode") 158 | state = util.load(args.config_dir, config["username"]) 159 | keep.load(None, state, False) 160 | logged_in = True 161 | 162 | token = keyring.get_password("google-keep-token", config["username"]) 163 | if not logged_in and token: 164 | logger.info("Authenticating with token") 165 | state = util.load(args.config_dir, config["username"]) 166 | 167 | try: 168 | keep.resume(config["username"], token, state=state, sync=False) 169 | logged_in = True 170 | logger.info("Success") 171 | except gkeepapi.exception.LoginException: 172 | logger.info("Invalid token") 173 | 174 | if not logged_in: 175 | password = getpass.getpass() 176 | try: 177 | keep.login(config["username"], password, sync=False) 178 | logged_in = True 179 | del password 180 | token = keep.getMasterToken() 181 | keyring.set_password("google-keep-token", config["username"], token) 182 | logger.info("Success") 183 | except gkeepapi.exception.LoginException: 184 | logger.info("Login failed") 185 | 186 | if not logged_in: 187 | logger.error("Failed to authenticate") 188 | sys.exit(1) 189 | 190 | args.func(args, keep, config) 191 | 192 | 193 | if __name__ == "__main__": 194 | main() 195 | -------------------------------------------------------------------------------- /src/keep_cli/application.py: -------------------------------------------------------------------------------- 1 | import urwid 2 | import logging 3 | import json 4 | import gkeepapi 5 | from .widget import status 6 | from .widget import grid 7 | from .widget import kanban 8 | from .widget import search 9 | from .widget import views 10 | from .widget import help 11 | from . import query 12 | from . import constants 13 | from . import util 14 | 15 | 16 | class Application(urwid.Frame): 17 | """ 18 | Base application widget 19 | """ 20 | 21 | def __init__( 22 | self, keep: gkeepapi.Keep, config: dict, config_dir: str, offline: bool = False 23 | ): 24 | self.keep = keep 25 | self.config = config 26 | self.config_dir = config_dir 27 | self.offline = offline 28 | self.w_overlay = None 29 | self.stack = [] 30 | 31 | self.w_status = status.Status(self) 32 | 33 | w_main = self.hydrateView("default") 34 | self.stack.append(w_main) 35 | 36 | super(Application, self).__init__(w_main, footer=self.w_status) 37 | self.refresh() 38 | 39 | def push(self, w: urwid.Widget): 40 | """ 41 | Push a widget onto the rendering stack 42 | """ 43 | self.stack.append(w) 44 | self.body = w 45 | self.body.refresh(self.keep) 46 | 47 | def pop(self): 48 | """ 49 | Pop a widget off the rendering stack 50 | """ 51 | if len(self.stack) <= 1: 52 | return 53 | 54 | self.stack.pop() 55 | self.body = self.stack[-1] 56 | self.body.refresh(self.keep) 57 | 58 | def replace(self, w: urwid.Widget): 59 | """ 60 | Replace the active widget on the rendering stack 61 | """ 62 | self.body = self.stack[-1] = w 63 | self.body.refresh(self.keep) 64 | 65 | def overlay(self, w: urwid.Widget = None): 66 | self.w_overlay = w 67 | w_top = self.stack[-1] 68 | 69 | if w is None: 70 | self.body = w_top 71 | else: 72 | self.body = urwid.Overlay( 73 | w, w_top, urwid.CENTER, (urwid.RELATIVE, 80), urwid.MIDDLE, urwid.PACK 74 | ) 75 | 76 | def refresh(self): 77 | """ 78 | Refresh keep and the active widget 79 | """ 80 | if not self.offline: 81 | self.keep.sync() 82 | util.save(self.keep, self.config_dir, self.config["username"]) 83 | self.body.refresh(self.keep) 84 | 85 | def keypress(self, size, key): 86 | """ 87 | Handle global keypresses 88 | """ 89 | key = super(Application, self).keypress(size, key) 90 | if key == "r": 91 | self.refresh() 92 | key = None 93 | elif key == "/": 94 | self.overlay(search.Search(self)) 95 | key = None 96 | elif key == "?": 97 | self.overlay(help.Help(self)) 98 | key = None 99 | elif key == "g": 100 | self.overlay(views.Views(self)) 101 | key = None 102 | elif key == "esc": 103 | if self.w_overlay is not None: 104 | self.overlay(None) 105 | elif len(self.stack) <= 1: 106 | self.refresh() 107 | raise urwid.ExitMainLoop() 108 | else: 109 | self.pop() 110 | return key 111 | 112 | def hydrateView(self, key: str) -> query.Query: 113 | views = self.config.get("views") or {} 114 | view = views.get(key) or {} 115 | _type = view.get("type", "grid") 116 | 117 | if _type == "kanban": 118 | raw_queries = view.get("queries") or [] 119 | return kanban.KanBan( 120 | self, 121 | [ 122 | query.Query.fromConfig(self.keep, raw_query) 123 | for raw_query in raw_queries 124 | ], 125 | ) 126 | 127 | raw_query = view.get("query") or {} 128 | q = query.Query.fromConfig(self.keep, raw_query) 129 | return grid.Grid(self, q) 130 | 131 | def run(self): 132 | loop = urwid.MainLoop(self, constants.Palette) 133 | loop.screen.set_terminal_properties(colors=256) 134 | 135 | while True: 136 | try: 137 | loop.run() 138 | except KeyboardInterrupt: 139 | if loop.process_input(["ctrl c"]): 140 | continue 141 | loop.stop() 142 | 143 | break 144 | -------------------------------------------------------------------------------- /src/keep_cli/commands.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | import argparse 4 | import gkeepapi 5 | import os 6 | import glob 7 | import re 8 | import io 9 | import random 10 | import itertools 11 | from typing import List, Dict, Tuple, Optional 12 | from . import application 13 | from . import util 14 | 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | 19 | NONWORD_RE = re.compile(r"\W") 20 | FILE_RE = re.compile(r"^.+ \((.+?)\)\.md$") 21 | TITLE_RE = re.compile(r"^# (.*)$") 22 | OPTION_RE = re.compile(r"^$") 23 | LISTITEM_RE = re.compile(r"^( )?- \[(x| )\] (.*)$") 24 | 25 | 26 | def _ensure_note(keep: gkeepapi.Keep, note_id: str) -> gkeepapi.node.TopLevelNode: 27 | note = keep.get(note_id) 28 | if note is None: 29 | logger.error("Note not found") 30 | sys.exit(2) 31 | return note 32 | 33 | 34 | def _sync(args: argparse.Namespace, keep: gkeepapi.Keep, config: dict, save: bool): 35 | if not args.offline: 36 | keep.sync() 37 | 38 | if save: 39 | util.save(keep, args.config_dir, config["username"]) 40 | 41 | 42 | def tui(args: argparse.Namespace, keep: gkeepapi.Keep, config: dict): 43 | app = application.Application(keep, config, args.config_dir, args.offline) 44 | app.run() 45 | 46 | 47 | def find(args: argparse.Namespace, keep: gkeepapi.Keep, config: dict): 48 | _sync(args, keep, config, True) 49 | notes = keep.find( 50 | query=args.query, 51 | labels=args.labels, 52 | colors=args.colors, 53 | pinned=args.pinned, 54 | archived=args.archived, 55 | trashed=args.trashed, 56 | ) 57 | for note in notes: 58 | print(note.id) 59 | 60 | 61 | def get(args: argparse.Namespace, keep: gkeepapi.Keep, config: dict): 62 | _sync(args, keep, config, True) 63 | note = _ensure_note(keep, args.note) 64 | none = not ( 65 | args.title or args.text or args.unchecked or args.checked or args.labels 66 | ) 67 | 68 | if none or args.title: 69 | print(note.title) 70 | 71 | if none or args.text: 72 | if isinstance(note, gkeepapi.node.List): 73 | none_completion = not (args.unchecked or args.checked) 74 | entries = [] 75 | if none_completion or args.unchecked: 76 | entries += note.unchecked 77 | if none_completion or args.checked: 78 | entries += note.checked 79 | for entry in entries: 80 | print(entry) 81 | 82 | else: 83 | print(note.text) 84 | 85 | if none or args.labels: 86 | for label in note.labels.all(): 87 | print(label) 88 | 89 | 90 | def set_(args: argparse.Namespace, keep: gkeepapi.Keep, config: dict): 91 | _sync(args, keep, config, False) 92 | note = _ensure_note(keep, args.note) 93 | 94 | if args.title is not None: 95 | note.title = args.title 96 | 97 | if args.text is not None: 98 | note.text = args.text 99 | 100 | # FIXME: Handle list items 101 | 102 | _sync(args, keep, config, True) 103 | 104 | 105 | def sync(args: argparse.Namespace, keep: gkeepapi.Keep, config: dict): 106 | _sync(argparse.Namespace(offline=False), keep, config, True) 107 | 108 | 109 | def _init_export_dir(root: str): 110 | os.makedirs(root, exist_ok=True) 111 | os.makedirs(os.path.join(root, "archived"), exist_ok=True) 112 | os.makedirs(os.path.join(root, "trashed"), exist_ok=True) 113 | os.makedirs(os.path.join(root, "deleted"), exist_ok=True) 114 | 115 | 116 | def _get_export_path(note: gkeepapi.node.TopLevelNode, root: str) -> str: 117 | title = note.title.strip() 118 | if title: 119 | title = NONWORD_RE.sub("-", title.lower()) 120 | else: 121 | title = "untitled" 122 | 123 | fn = "%s (%s).md" % (title, note.id) 124 | 125 | path_parts = [root] 126 | if note.trashed: 127 | path_parts.append("trashed") 128 | elif note.archived: 129 | path_parts.append("archived") 130 | path_parts.append(fn) 131 | return os.path.join(*path_parts) 132 | 133 | 134 | def _enum_export_fns( 135 | root: str, keep: gkeepapi.Keep 136 | ) -> Tuple[List[str], List[str], Dict]: 137 | new_files = [] 138 | untracked_files = [] 139 | existing_files = {} 140 | 141 | # Enumerate existing files 142 | for path in itertools.chain( 143 | glob.glob( 144 | os.path.join(root, "*.md"), 145 | ), 146 | glob.glob( 147 | os.path.join(root, "{archived,trashed}", "*.md"), 148 | ), 149 | ): 150 | fn = os.path.basename(path) 151 | m = FILE_RE.search(fn) 152 | if not m: 153 | # If filename format doesn't match, assume new file 154 | new_files.append(path) 155 | continue 156 | 157 | note = keep.get(m.group(1)) 158 | if not note: 159 | # If note id doesn't exist, assume deleted file 160 | untracked_files.append(path) 161 | else: 162 | # Otherwise, the file maps to an existing note 163 | if note.id in existing_files: 164 | logger.warning("Multiple files found for note with id: %s", note.id) 165 | existing_files[note.id] = path 166 | 167 | return new_files, untracked_files, existing_files 168 | 169 | 170 | def _read_export_file( 171 | keep: gkeepapi.Keep, 172 | fh: io.IOBase, 173 | note: Optional[gkeepapi.node.TopLevelNode] = None, 174 | ) -> gkeepapi.node.TopLevelNode: 175 | lines = fh.readlines() 176 | 177 | title = "" 178 | color = gkeepapi.node.ColorValue.White 179 | pinned = False 180 | archived = False 181 | labels = set() 182 | items = [] 183 | 184 | # Extract the title 185 | i = 0 186 | m = TITLE_RE.search(lines[i]) 187 | if m: 188 | title = m.group(1) 189 | i += 1 190 | 191 | # Extract all the options 192 | options = [] 193 | while i < len(lines): 194 | m = OPTION_RE.search(lines[i]) 195 | if not m: 196 | break 197 | options.append(m.group(1)) 198 | i += 1 199 | 200 | # Process the options 201 | for option in options: 202 | parts = option.split(" ", 1) 203 | if parts[0] == "pinned": 204 | pinned = True 205 | elif parts[0] == "archived": 206 | archived = True 207 | elif parts[0] == "color": 208 | if len(parts) == 2: 209 | try: 210 | color = gkeepapi.node.ColorValue(parts[1].upper()) 211 | except ValueError: 212 | logger.warning("Unknown color option: %s", parts[1]) 213 | elif parts[0] == "label": 214 | labels.add(parts[1]) 215 | else: 216 | logger.warning("Unknown option: %s", parts[0]) 217 | 218 | # Initialize note (if necessary) 219 | if note is None: 220 | labels.add(args.label) 221 | if len(lines) > i and LISTITEM_RE.search(lines[i]): 222 | note = gkeepapi.node.List() 223 | else: 224 | note = gkeepapi.node.Note() 225 | 226 | # Extract content 227 | if isinstance(note, gkeepapi.node.List): 228 | # Extract list items 229 | first = True 230 | item = [] 231 | indented = False 232 | checked = False 233 | while i < len(lines): 234 | m = LISTITEM_RE.search(lines[i]) 235 | if not m: 236 | if first: 237 | logger.warning("Invalid listitem entry: %s", lines[i]) 238 | else: 239 | item.append(lines[i]) 240 | else: 241 | if not first: 242 | items.append((indented, checked, "\n".join(item))) 243 | item = [] 244 | 245 | indented_str, checked_str, content = m.groups() 246 | indented = bool(indented_str) 247 | checked = " " != checked_str 248 | item.append(content) 249 | 250 | first = False 251 | i += 1 252 | 253 | if not first: 254 | items.append((indented, checked, "\n".join(item))) 255 | 256 | # Sync up items to the list 257 | i = 0 258 | list_items = note.items 259 | sort = random.randint(1000000000, 9999999999) 260 | 261 | while True: 262 | a_ok = i < len(items) 263 | b_ok = i < len(list_items) 264 | 265 | # Update an existing item 266 | if a_ok and b_ok: 267 | indented, checked, content = items[i] 268 | list_item = list_items[i] 269 | if indented != list_item.indented: 270 | list_item.indented = indented 271 | if checked != list_item.checked: 272 | list_item.checked = checked 273 | if content != list_item.text: 274 | list_item.text = content 275 | sort = int(list_item.sort) 276 | # Create a new item 277 | elif a_ok: 278 | indented, checked, content = items[i] 279 | list_item = note.add(content, checked, sort) 280 | if indented: 281 | list_item.indent() 282 | sort -= gkeepapi.node.List.SORT_DELTA 283 | # Remove a deleted item 284 | elif b_ok: 285 | list_items[i].delete() 286 | else: 287 | break 288 | i += 1 289 | else: 290 | text = "\n".join(lines[i:]) 291 | if note.text != text: 292 | note.text = text 293 | 294 | # Apply labels 295 | note_labels = set((label.name for label in note.labels.all())) 296 | new_labels = labels - note_labels 297 | del_labels = note_labels - labels 298 | for label in new_labels: 299 | note.labels.add(keep.findLabel(label, True)) 300 | for label in del_labels: 301 | note.labels.remove(keep.findLabel(label)) 302 | 303 | # Apply all other changes 304 | if note.title != title: 305 | note.title = title 306 | if note.pinned != pinned: 307 | note.pinned = pinned 308 | if note.archived != archived: 309 | note.archived = archived 310 | if note.color != color: 311 | note.color = color 312 | 313 | return note 314 | 315 | 316 | def import_(args: argparse.Namespace, keep: gkeepapi.Keep, config: dict): 317 | _sync(args, keep, config, False) 318 | _init_export_dir(args.dir) 319 | 320 | new_files, untracked_files, existing_files = _enum_export_fns(args.dir, keep) 321 | 322 | # Process notes to update 323 | for note_id, path in existing_files.items(): 324 | note = keep.get(note_id) 325 | 326 | # Read in the markdown file 327 | with open(path, "r") as fh: 328 | note = _read_export_file(keep, fh, note) 329 | 330 | # Process notes to create 331 | for path in new_files: 332 | # Read in the markdown file 333 | with open(path, "r") as fh: 334 | note = _read_export_file(keep, fh) 335 | keep.add(note) 336 | new_path = _get_export_path(note, args.dir) 337 | os.rename(path, new_path) 338 | logger.info("Created new note: %s", new_path) 339 | 340 | # Process notes to delete 341 | if args.delete: 342 | for note in keep.all(): 343 | path = _get_export_path(note, args.dir) 344 | if os.path.exists(path): 345 | continue 346 | note.delete() 347 | logger.warning("Removed deleted note: %s", path) 348 | 349 | _sync(args, keep, config, True) 350 | 351 | 352 | def _write_export_file(fh: io.IOBase, note: gkeepapi.node.TopLevelNode): 353 | # Write title 354 | fh.write("# %s\n" % note.title) 355 | 356 | # Write all options 357 | if note.pinned: 358 | fh.write("\n") 359 | if note.color: 360 | fh.write("\n" % note.color.value.lower()) 361 | for label in note.labels.all(): 362 | fh.write("\n" % label.name) 363 | 364 | # Write content 365 | if isinstance(note, gkeepapi.node.List): 366 | # Write out each list item 367 | for item in note.items: 368 | fh.write( 369 | "%s- [%s] %s\n" 370 | % ( 371 | " " if item.indented else "", 372 | "x" if item.checked else " ", 373 | item.text, 374 | ) 375 | ) 376 | else: 377 | # Write out the text 378 | fh.write(note.text) 379 | 380 | 381 | def export(args: argparse.Namespace, keep: gkeepapi.Keep, config: dict): 382 | _sync(args, keep, config, True) 383 | _init_export_dir(args.dir) 384 | 385 | _, untracked_files, existing_files = _enum_export_fns(args.dir, keep) 386 | 387 | # Move deleted files to the "deleted" directory 388 | for deleted_file in untracked_files: 389 | fn = os.path.basename(deleted_file) 390 | os.rename(deleted_file, os.path.join(args.dir, "deleted", fn)) 391 | logger.warning("Removed deleted note: %s", deleted_file) 392 | 393 | # Sync down existing notes 394 | for note in keep.all(): 395 | # Determine target filename 396 | path = _get_export_path(note, args.dir) 397 | 398 | # Move existing file to new location if necessary 399 | if note.id in existing_files and path != existing_files[note.id]: 400 | os.rename(existing_files[note.id], path) 401 | 402 | # Write out the markdown file 403 | with open(path, "w") as fh: 404 | _write_export_file(fh, note) 405 | -------------------------------------------------------------------------------- /src/keep_cli/constants.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | import gkeepapi 5 | import enum 6 | 7 | 8 | class Attribute(enum.Enum): 9 | Title = "title" 10 | Text = "text" 11 | Selected = "selected" 12 | 13 | 14 | TextColor = ("", "#222") 15 | MutedColor = ("", "#666") 16 | 17 | ColorMap = { 18 | gkeepapi.node.ColorValue.White: ("white", "h231"), 19 | gkeepapi.node.ColorValue.Red: ("dark red", "h210"), 20 | gkeepapi.node.ColorValue.Orange: ("light red", "h222"), 21 | gkeepapi.node.ColorValue.Yellow: ("yellow", "h228"), 22 | gkeepapi.node.ColorValue.Green: ("dark green", "h192"), 23 | gkeepapi.node.ColorValue.Teal: ("dark cyan", "h159"), 24 | gkeepapi.node.ColorValue.Blue: ("light blue", "h117"), 25 | gkeepapi.node.ColorValue.DarkBlue: ("dark blue", "h111"), 26 | gkeepapi.node.ColorValue.Purple: ("dark magenta", "h141"), 27 | gkeepapi.node.ColorValue.Pink: ("light magenta", "h218"), 28 | gkeepapi.node.ColorValue.Brown: ("brown", "h181"), 29 | gkeepapi.node.ColorValue.Gray: ("light gray", "h188"), 30 | } 31 | 32 | 33 | def _(*attrs): 34 | return ",".join(attrs) 35 | 36 | 37 | Palette = [ 38 | (Attribute.Selected.value, "black", "dark gray", "", TextColor[1], "h242"), 39 | ("BORDER", "light gray", "white", "", "h254", "h231"), 40 | ("TEXT", "black", "white", "", TextColor[1], "h231"), 41 | ( 42 | "buTEXT", 43 | _("black", "underline", "bold"), 44 | "white", 45 | "", 46 | _(TextColor[1], "underline", "bold"), 47 | "h231", 48 | ), 49 | ("bTEXT", _("black", "bold"), "white", "", _(TextColor[1], "bold"), "h231"), 50 | ("mTEXT", "dark gray", "white", "", MutedColor[1], "h231"), 51 | ("STATUS", "black", "yellow", "", TextColor[1], "h214"), 52 | ] 53 | 54 | for k, v in ColorMap.items(): 55 | Palette.append((k.value, "black", v[0], "", TextColor[1], v[1])) 56 | 57 | # Bold variant 58 | Palette.append( 59 | ("b" + k.value, "black", v[0], "", _(TextColor[1], "underline", "bold"), v[1]) 60 | ) 61 | # Italicized variant 62 | Palette.append(("i" + k.value, "black", v[0], "", _(TextColor[1], "italics"), v[1])) 63 | # Label variant 64 | Palette.append( 65 | ("l" + k.value, "black", v[0], "", _(TextColor[1], "standout"), v[1]) 66 | ) 67 | # Underlined label variant 68 | Palette.append( 69 | ( 70 | "lu" + k.value, 71 | "black", 72 | v[0], 73 | "", 74 | _(TextColor[1], "underline", "standout"), 75 | v[1], 76 | ) 77 | ) 78 | # Bold label variant 79 | Palette.append( 80 | ("lb" + k.value, "black", v[0], "", _(TextColor[1], "standout", "bold"), v[1]) 81 | ) 82 | # Underlined bold label variant 83 | Palette.append( 84 | ( 85 | "lub" + k.value, 86 | "black", 87 | v[0], 88 | "", 89 | _(TextColor[1], "underline", "standout", "bold"), 90 | v[1], 91 | ) 92 | ) 93 | # Color variant 94 | Palette.append(("c" + k.value, v[0], v[0], "", TextColor[1], v[1])) 95 | # Underlined color variant 96 | Palette.append(("cu" + k.value, v[0], v[0], "", _(TextColor[1], "underline"), v[1])) 97 | -------------------------------------------------------------------------------- /src/keep_cli/query.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import logging 3 | import gkeepapi 4 | import typing 5 | from typing import List, Optional, Union 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | class Query(object): 11 | @classmethod 12 | def fromConfig(cls, keep: gkeepapi.Keep, config: dict) -> "Query": 13 | config = copy.deepcopy(config) 14 | 15 | name = config.get("name", "") 16 | query = config.get("query") 17 | labels = None 18 | colors = None 19 | pinned = config.get("pinned") 20 | archived = config.get("archived", False) 21 | trashed = config.get("trashed", False) 22 | 23 | if "labels" in config: 24 | labels = [] 25 | raw = config.get("labels", []) 26 | 27 | if raw: 28 | for i in raw: 29 | l = keep.findLabel(i) 30 | labels.append(l) 31 | if l is None: 32 | logger.warning("Label not found: %s", i) 33 | config["labels"] = labels 34 | 35 | if "colors" in config: 36 | colors = [] 37 | 38 | for i in config.get("colors", []): 39 | try: 40 | c = gkeepapi.node.ColorValue(i.upper()) 41 | colors.append(c) 42 | except ValueError: 43 | logger.warning("Color not found: %s", i) 44 | config["colors"] = colors 45 | 46 | return cls(name, query, labels, colors, pinned, archived, trashed) 47 | 48 | def toConfig(cls, q: "Query") -> dict: 49 | return { 50 | name: q.name, 51 | query: str(q.query), 52 | labels: [label.name for label in q.labels] if q.labels else None, 53 | colors: [color.value.lower() for color in q.colors] if q.colors else None, 54 | pinned: q.pinned, 55 | archived: q.archived, 56 | trashed: q.trashed, 57 | } 58 | 59 | def __init__( 60 | self, 61 | name: str = "", 62 | query: Optional[Union[str, typing.re.Pattern]] = None, 63 | labels: Optional[List[gkeepapi.node.Label]] = None, 64 | colors: Optional[List[gkeepapi.node.ColorValue]] = None, 65 | pinned: Optional[bool] = None, 66 | archived: Optional[bool] = False, 67 | trashed: Optional[bool] = False, 68 | ): 69 | self.name = name 70 | self.query = query 71 | self.labels = labels 72 | self.colors = colors 73 | self.pinned = pinned 74 | self.archived = archived 75 | self.trashed = trashed 76 | 77 | def filter(self, keep: gkeepapi.Keep) -> List[gkeepapi.node.TopLevelNode]: 78 | return keep.find( 79 | query=self.query, 80 | labels=self.labels, 81 | colors=self.colors, 82 | pinned=self.pinned, 83 | archived=self.archived, 84 | trashed=self.trashed, 85 | ) 86 | -------------------------------------------------------------------------------- /src/keep_cli/util.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import logging 4 | import gkeepapi 5 | 6 | 7 | def load(config_dir: str, username: str) -> dict: 8 | cache_file = os.path.join(config_dir, "%s.json" % username) 9 | 10 | try: 11 | fh = open(cache_file, "r") 12 | except FileNotFoundError: 13 | logging.warning("Unable to find state file: %s", cache_file) 14 | return None 15 | 16 | try: 17 | state = json.load(fh) 18 | except json.decoder.JSONDecodeError: 19 | logging.warning("Unable to load state file: %s", cache_file) 20 | return None 21 | finally: 22 | fh.close() 23 | 24 | return state 25 | 26 | 27 | def save(keep: gkeepapi.Keep, config_dir: str, username: str): 28 | cache_file = os.path.join(config_dir, "%s.json" % username) 29 | 30 | state = keep.dump() 31 | fh = open(cache_file, "w") 32 | json.dump(state, fh) 33 | fh.close() 34 | -------------------------------------------------------------------------------- /src/keep_cli/widget/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kiwiz/keep-cli/60c8d18bc9f92619b872611742cddde4b53c4e3e/src/keep_cli/widget/__init__.py -------------------------------------------------------------------------------- /src/keep_cli/widget/edit.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import urwid 3 | import urwid_readline 4 | import gkeepapi 5 | from . import labels 6 | from .. import constants 7 | import logging 8 | 9 | from typing import List 10 | 11 | NEXT_SELECTABLE = 'next selectable' 12 | PREV_SELECTABLE = 'prev selectable' 13 | 14 | class Color(urwid.AttrMap): 15 | def __init__(self, color: gkeepapi.node.ColorValue, selected=False): 16 | self.color = color 17 | super(Color, self).__init__( 18 | urwid.Text(''), 19 | 'c' + self.color.value, 20 | 'cu' + self.color.value 21 | ) 22 | self.selected = selected 23 | self.update() 24 | 25 | def keypress(self, size, key): 26 | if key == ' ': 27 | self.selected = not self.selected 28 | self.update() 29 | key = None 30 | 31 | return key 32 | 33 | def update(self): 34 | self.original_widget.set_text(' ✔ ' if self.selected else ' ') 35 | 36 | def selectable(self): 37 | return True 38 | 39 | class Colors(urwid.GridFlow): 40 | def __init__(self): 41 | super(Colors, self).__init__([ 42 | Color(color) for color in gkeepapi.node.ColorValue 43 | ], 3, 1, 0, urwid.LEFT) 44 | 45 | def getSelected(self) -> List[gkeepapi.node.ColorValue]: 46 | return [item.color for item, _ in self.contents if item.selected] 47 | 48 | class Item(urwid.Columns): 49 | def __init__(self, item: gkeepapi.node.ListItem, indented=None): 50 | self.id = item.id 51 | self.checked = item.checked 52 | self.indented = indented if indented is not None else item.indented 53 | self.w_indent = urwid.Text('') 54 | self.w_checkbox = urwid.Text('') 55 | self.w_text = urwid_readline.ReadlineEdit(edit_text=item.text) 56 | super(Item, self).__init__([ 57 | (urwid.PACK, self.w_indent), 58 | (urwid.PACK, self.w_checkbox), 59 | self.w_text, 60 | ], dividechars=1) 61 | 62 | self._updateIndent() 63 | self.updateChecked(self.checked) 64 | 65 | def indent(self): 66 | self.indented = True 67 | self._updateIndent() 68 | 69 | def dedent(self): 70 | self.indented = False 71 | self._updateIndent() 72 | 73 | def _updateIndent(self): 74 | self.w_indent.set_text(' ' if self.indented else '') 75 | self._invalidate() 76 | 77 | def toggleCheck(self): 78 | self.updateChecked(not self.checked) 79 | 80 | def updateChecked(self, checked): 81 | self.checked = checked 82 | self.w_checkbox.set_text(u'☑' if checked else u'☐') 83 | self._invalidate() 84 | 85 | def setPos(self, pos): 86 | self.w_text.edit_pos = pos 87 | 88 | def getText(self): 89 | return self.w_text.get_edit_text() 90 | 91 | def appendText(self, s): 92 | pos = len(self.getText()) 93 | self.w_text.edit_pos = pos 94 | self.w_text.insert_text(s) 95 | self.w_text.edit_pos = pos 96 | 97 | def cutToEnd(self): 98 | text = self.getText() 99 | pos = self.w_text.edit_pos 100 | 101 | suffix = text[pos:] 102 | self.w_text.set_edit_text(text[:pos]) 103 | return suffix 104 | 105 | def keypress(self, size, key): 106 | if key == 'backspace' and self.w_text.edit_pos == 0: 107 | return key 108 | if key == 'enter': 109 | return key 110 | 111 | key = super(Item, self).keypress(size, key) 112 | return key 113 | 114 | class Items(urwid.ListBox): 115 | def __init__(self): 116 | super(Items, self).__init__(urwid.SimpleFocusListWalker([])) 117 | 118 | def refresh(self, items: List[gkeepapi.node.ListItem]): 119 | self.body[:] = [Item(item) for item in items] 120 | 121 | def keypress(self, size, key): 122 | def actual_key(unhandled): 123 | if unhandled: 124 | return key 125 | 126 | if self._command_map[key] == PREV_SELECTABLE: 127 | return actual_key(self._keypress_up(size)) 128 | elif self._command_map[key] == NEXT_SELECTABLE: 129 | return actual_key(self._keypress_down(size)) 130 | 131 | key = super(Items, self).keypress(size, key) 132 | if key == 'enter': 133 | pos = 0 134 | text = '' 135 | indented = None 136 | if self.focus is not None: 137 | text = self.focus.cutToEnd() 138 | pos = self.focus_position + 1 139 | 140 | if pos > 0: 141 | indented = self.body[pos - 1].indented 142 | item = gkeepapi.node.ListItem() 143 | item.text = text 144 | self.body.insert(pos, Item(item, indented)) 145 | self.focus.setPos(0) 146 | self.focus_position = pos 147 | key = None 148 | elif key == 'backspace': 149 | pos = self.focus_position 150 | if pos > 0: 151 | text = self.body[pos].getText() 152 | last = pos == len(self.body) - 1 153 | del self.body[pos] 154 | if not last: 155 | self.focus_position -= 1 156 | self.focus.appendText(text) 157 | elif key == 'meta [': 158 | pos = self.focus_position 159 | self.body[pos].dedent() 160 | key = None 161 | elif key == 'meta ]': 162 | pos = self.focus_position 163 | if pos > 0: 164 | self.body[pos].indent() 165 | key = None 166 | elif key == 'meta p': 167 | pos = self.focus_position 168 | if pos > 0: 169 | tmp = self.body[pos] 170 | self.body[pos] = self.body[pos - 1] 171 | self.body[pos - 1] = tmp 172 | if pos - 1 == 0: 173 | self.body[pos - 1].dedent() 174 | self.focus_position = pos - 1 175 | key = None 176 | elif key == 'meta n': 177 | pos = self.focus_position 178 | if pos < len(self.body) - 1: 179 | tmp = self.body[pos] 180 | self.body[pos] = self.body[pos + 1] 181 | self.body[pos + 1] = tmp 182 | self.focus_position = pos + 1 183 | key = None 184 | elif key == 'meta x': 185 | pos = self.focus_position 186 | self.body[pos].toggleCheck() 187 | key = None 188 | 189 | return key 190 | 191 | class Edit(urwid.AttrMap): 192 | def __init__(self, app: 'application.Application', note: gkeepapi.node.TopLevelNode): 193 | self.application = app 194 | self.note = note 195 | 196 | tmp = urwid.Text('') 197 | 198 | self.w_title = urwid_readline.ReadlineEdit(wrap=urwid.CLIP) 199 | self.w_text = urwid_readline.ReadlineEdit(multiline=True) 200 | self.w_list = Items() 201 | self.w_labels = labels.Labels() 202 | 203 | self.w_state = urwid.Text(u'', align=urwid.RIGHT) 204 | self.w_footer = urwid.Text(u'', align=urwid.RIGHT) 205 | self.w_content = urwid.Frame( 206 | tmp, 207 | header=tmp, 208 | footer=tmp 209 | ) 210 | self.w_frame = urwid.Frame( 211 | urwid.Padding( 212 | self.w_content, 213 | align=urwid.CENTER, 214 | left=1, 215 | right=1 216 | ), 217 | header=self.w_state, 218 | footer=self.w_footer, 219 | ) 220 | 221 | self.zen_mode = False 222 | 223 | super(Edit, self).__init__( 224 | self.w_frame, 225 | note.color.value 226 | ) 227 | 228 | self._updateContent() 229 | self._updateLabels() 230 | self._updateState() 231 | 232 | def _updateContent(self): 233 | self.w_title.set_edit_text(self.note.title) 234 | self.w_content.contents['header'] = ( 235 | urwid.AttrMap(self.w_title, 'b' + self.note.color.value), 236 | self.w_content.options() 237 | ) 238 | 239 | w_body = None 240 | if isinstance(self.note, gkeepapi.node.List): 241 | self.w_list.refresh(self.note.items) 242 | w_body = (self.w_list, self.w_content.options()) 243 | else: 244 | self.w_text.set_edit_text(self.note.text) 245 | w_body = ( 246 | urwid.Filler(self.w_text, valign=urwid.TOP), 247 | self.w_content.options() 248 | ) 249 | self.w_content.contents['body'] = w_body 250 | 251 | def _updateLabels(self): 252 | w_labels = (None, self.w_content.options()) 253 | if len(self.note.labels): 254 | w_labels = (self.w_labels, self.w_content.options()) 255 | self.w_labels.setLabels(self.note.labels.all(), self.note.color) 256 | 257 | self.w_content.contents['footer'] = w_labels 258 | 259 | def _updateState(self): 260 | parts = [ 261 | '🔄' if self.note.dirty else ' ', 262 | '📦' if self.note.archived else ' ', 263 | '📍' if self.note.pinned else ' ', 264 | ] 265 | self.w_state.set_text(''.join(parts)) 266 | 267 | def _updateMode(self): 268 | self.original_widget = self.w_content.contents['body'][0] \ 269 | if self.zen_mode \ 270 | else self.w_frame 271 | self._invalidate() 272 | 273 | def _save(self): 274 | title = self.w_title.get_edit_text() 275 | if self.note.title != title: 276 | self.note.title = title 277 | 278 | if not isinstance(self.note, gkeepapi.node.List): 279 | text = self.w_text.get_edit_text() 280 | if self.note.text != text: 281 | self.note.text = text 282 | return 283 | 284 | old_items = set((item.id for item in self.note.items)) 285 | 286 | for i, w_item in enumerate(self.w_list.body): 287 | item = gkeepapi.node.ListItem(id_=w_item.id, parent_id=self.note.id) 288 | if w_item.id in old_items: 289 | item = self.note.get(w_item.id) 290 | old_items.remove(w_item.id) 291 | 292 | if item.checked != w_item.checked: 293 | item.checked = w_item.checked 294 | text = w_item.getText() 295 | if item.text != text: 296 | item.text = text 297 | 298 | if item.new: 299 | self.note.append(item) 300 | 301 | curr = None 302 | prev = item.super_list_item_id 303 | if i > 0 and w_item.indented: 304 | curr = self.w_list.body[i - 1].id 305 | 306 | if prev != curr: 307 | if prev is not None: 308 | self.note.get(prev).dedent(item) 309 | 310 | if curr is not None: 311 | self.note.get(curr).indent(item) 312 | 313 | for id_ in old_items: 314 | self.note.get(id_).delete() 315 | 316 | def keypress(self, size, key): 317 | key = super(Edit, self).keypress(size, key) 318 | if key == 'f': 319 | self.note.pinned = not self.note.pinned 320 | self._updateState() 321 | key = None 322 | elif key == 'e': 323 | self.note.archived = not self.note.archived 324 | self._updateState() 325 | key = None 326 | elif key == 'meta z': 327 | self.zen_mode = not self.zen_mode 328 | self._updateMode() 329 | key = None 330 | elif key == 'ctrl c': 331 | self.application.pop() 332 | key = None 333 | elif key == 'esc': 334 | self._save() 335 | self.application.pop() 336 | key = None 337 | return key 338 | 339 | def refresh(self, keep: gkeepapi.Keep): 340 | pass 341 | -------------------------------------------------------------------------------- /src/keep_cli/widget/grid.py: -------------------------------------------------------------------------------- 1 | import urwid 2 | import logging 3 | import gkeepapi 4 | from . import note 5 | from . import edit 6 | from .. import query 7 | 8 | class Grid(urwid.Filler): 9 | def __init__(self, app: 'application.Application', q: query.Query): 10 | self.application = app 11 | self.query = q 12 | 13 | size = app.config.get('size', {}) 14 | self.size = (size.get('width', 26), size.get('height', 10)) 15 | 16 | self.w_grid = urwid.GridFlow([], self.size[0], 1, 1, urwid.LEFT) 17 | 18 | super(Grid, self).__init__(self.w_grid, valign=urwid.TOP) 19 | 20 | def refresh(self, keep: gkeepapi.Keep): 21 | self.w_grid.contents = [ 22 | (urwid.BoxAdapter(note.Note(n), self.size[1]), self.w_grid.options()) for n in self.query.filter(keep) 23 | ] 24 | 25 | def selectable(self): 26 | return True 27 | 28 | def keypress(self, size, key): 29 | if key == 'j': 30 | key = 'down' 31 | elif key == 'k': 32 | key = 'up' 33 | elif key == 'h': 34 | key = 'left' 35 | elif key == 'l': 36 | key = 'right' 37 | elif key == 'c': 38 | note = self.application.keep.createNote() 39 | w_edit = edit.Edit(self.application, note) 40 | self.application.push(w_edit) 41 | key = None 42 | elif key == 'C': 43 | note = self.application.keep.createList() 44 | w_edit = edit.Edit(self.application, note) 45 | self.application.push(w_edit) 46 | key = None 47 | elif key == 'enter': 48 | if self.w_grid.focus is not None: 49 | w_edit = edit.Edit(self.application, self.w_grid.focus.note) 50 | self.application.push(w_edit) 51 | key = None 52 | if self.w_grid.focus is not None: 53 | key = super(Grid, self).keypress(size, key) 54 | return key 55 | -------------------------------------------------------------------------------- /src/keep_cli/widget/help.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import urwid 3 | from . import util 4 | 5 | from typing import Union 6 | 7 | docs = [ 8 | 'Navigation', 9 | (('k', 'up'), 'Navigate up'), 10 | (('j', 'down'), 'Navigate down'), 11 | (('h', 'left'), 'Navigate left'), 12 | (('l', 'right'), 'Navigate right'), 13 | ('/', 'Search notes'), 14 | 15 | 'Action', 16 | ('c', 'Compose a new note'), 17 | ('C', 'Compose a new list'), 18 | ('r', 'Sync with server'), 19 | ('#', 'Trash note'), 20 | ('f', 'Pin or unpin notes'), 21 | ('e', 'Archive note'), 22 | 23 | 'Editor', 24 | ('Meta + ] / [', 'Indent/Dedent list item'), 25 | ('Meta + n / p', 'Move list item to next/previous position'), 26 | ('Meta + x', 'Check/Uncheck list item'), 27 | ('Meta + z', 'Enable/Disable zen mode'), 28 | 29 | 'Misc', 30 | ('?', 'Open keyboard shortcut help'), 31 | ('Ctrl + c', 'Discard changes / Quit'), 32 | ('Esc', 'Finish editing / Quit'), 33 | ] 34 | 35 | class Line(urwid.Columns): 36 | def __init__(self, key: Union[str, tuple], doc: str): 37 | super(Line, self).__init__([ 38 | (urwid.WEIGHT, 2, urwid.Text(('mTEXT', doc))), 39 | urwid.Text(', '.join(key) if isinstance(key, tuple) else key), 40 | ], dividechars=1) 41 | 42 | class Help(util.Border): 43 | def __init__(self, app: 'application.Application'): 44 | self.application = app 45 | 46 | content = [ 47 | urwid.Text(('buTEXT', 'Keyboard shortcuts'), align=urwid.CENTER), 48 | ] 49 | 50 | for line in docs: 51 | if isinstance(line, str): 52 | content.append(urwid.Divider()) 53 | content.append(urwid.Text(('bTEXT', line))) 54 | else: 55 | content.append(Line(line[0], line[1])) 56 | 57 | super(Help, self).__init__(urwid.Pile(content)) 58 | 59 | def selectable(self): 60 | return True 61 | 62 | def keypress(self, size, key): 63 | key = super(Help, self).keypress(size, key) 64 | if key == 'esc': 65 | self.application.overlay(None) 66 | key = None 67 | return key 68 | -------------------------------------------------------------------------------- /src/keep_cli/widget/kanban.py: -------------------------------------------------------------------------------- 1 | import urwid 2 | import gkeepapi 3 | from . import note 4 | from .. import query 5 | from .. import constants 6 | 7 | from typing import List 8 | 9 | class NoteList(urwid.Frame): 10 | def __init__(self, q: query.Query): 11 | self.query = q 12 | self.w_list = urwid.ListBox(urwid.SimpleFocusListWalker([])) 13 | 14 | super(NoteList, self).__init__( 15 | self.w_list, 16 | header=urwid.Text(self.query.name, align=urwid.CENTER) 17 | ) 18 | 19 | def refresh(self, keep: gkeepapi.Keep): 20 | self.w_list.body[:] = [ 21 | urwid.BoxAdapter(note.Note(n), 10) for n in self.query.filter(keep) 22 | ] 23 | 24 | class KanBan(urwid.Columns): 25 | def __init__(self, app: 'application.Application', queries: List[query.Query]): 26 | self.lists = [NoteList(q) for q in queries] 27 | 28 | super(KanBan, self).__init__(self.lists, dividechars=1) 29 | 30 | def refresh(self, keep: gkeepapi.Keep): 31 | for l in self.lists: 32 | l.refresh(keep) 33 | -------------------------------------------------------------------------------- /src/keep_cli/widget/labels.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import urwid 3 | import gkeepapi 4 | import logging 5 | 6 | from typing import List 7 | 8 | class Label(urwid.AttrMap): 9 | def __init__(self, label: gkeepapi.node.Label, color: gkeepapi.node.ColorValue, selected=False): 10 | self.label = label 11 | self.color = color 12 | self.selected = selected 13 | 14 | super(Label, self).__init__( 15 | urwid.Text(label.name), 16 | 'l' + color.value, 17 | 'lu' + color.value, 18 | ) 19 | 20 | def update(self): 21 | self.set_attr_map({None: ('lb' if self.selected else 'l') + self.color.value}) 22 | self.set_focus_map({None: ('lub' if self.selected else 'lu') + self.color.value}) 23 | 24 | def selectable(self): 25 | return True 26 | 27 | def keypress(self, size, key): 28 | if key == ' ': 29 | self.selected = not self.selected 30 | self.update() 31 | key = None 32 | return key 33 | 34 | class Labels(urwid.Columns): 35 | def __init__(self): 36 | super(Labels, self).__init__([], dividechars=1) 37 | 38 | def setLabels(self, labels: List[gkeepapi.node.Label], color: gkeepapi.node.ColorValue): 39 | self.contents = [ 40 | (Label(label, color), self.options(urwid.PACK)) for label in labels 41 | ] 42 | 43 | def getSelected(self) -> List[gkeepapi.node.Label]: 44 | return [item.label for item, _ in self.contents if item.selected] 45 | 46 | -------------------------------------------------------------------------------- /src/keep_cli/widget/note.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import urwid 3 | import gkeepapi 4 | from . import labels 5 | from .. import constants 6 | import logging 7 | 8 | class Note(urwid.AttrMap): 9 | def __init__(self, note: gkeepapi.node.TopLevelNode): 10 | self.note = note 11 | 12 | tmp = urwid.Text('') 13 | 14 | self.w_title = urwid.Text(u'', wrap=urwid.CLIP) 15 | self.w_text = urwid.Text(u'') 16 | self.w_labels = labels.Labels() 17 | 18 | self.w_state = urwid.Text(u'', align=urwid.RIGHT) 19 | self.w_header = urwid.AttrMap(self.w_state, None) 20 | self.w_footer = urwid.Text(u'', align=urwid.RIGHT) 21 | self.w_content = urwid.Frame( 22 | urwid.Filler(self.w_text, valign=urwid.TOP), 23 | header=tmp, 24 | footer=tmp 25 | ) 26 | 27 | super(Note, self).__init__( 28 | urwid.Frame( 29 | urwid.Padding( 30 | self.w_content, 31 | align='center', 32 | left=1, 33 | right=1 34 | ), 35 | header=self.w_header, 36 | footer=self.w_footer, 37 | ), 38 | note.color.value 39 | ) 40 | 41 | self._updateContent() 42 | self._updateLabels() 43 | self._updateState() 44 | 45 | def _updateContent(self): 46 | w_title = (None, self.w_content.options()) 47 | if self.note.title: 48 | w_title = (self.w_title, self.w_content.options()) 49 | self.w_title.set_text(('b' + self.note.color.value, self.note.title)) 50 | 51 | self.w_content.contents['header'] = w_title 52 | self.w_text.set_text(self.note.text) 53 | 54 | def _updateLabels(self): 55 | w_labels = (None, self.w_content.options()) 56 | if len(self.note.labels): 57 | w_labels = (self.w_labels, self.w_content.options()) 58 | self.w_labels.setLabels(self.note.labels.all(), self.note.color) 59 | 60 | self.w_content.contents['footer'] = w_labels 61 | 62 | def _updateFocus(self, focus): 63 | self.w_header.set_attr_map({None: constants.Attribute.Selected.value if focus else None}) 64 | 65 | def _updateState(self): 66 | parts = [ 67 | '🔄' if self.note.dirty else ' ', 68 | '📦' if self.note.archived else ' ', 69 | '📍' if self.note.pinned else ' ', 70 | ] 71 | self.w_state.set_text(''.join(parts)) 72 | 73 | def render(self, size, focus=False): 74 | self._updateFocus(focus) 75 | return super(Note, self).render(size, focus) 76 | 77 | def keypress(self, size, key): 78 | if key == 'f': 79 | self.note.pinned = not self.note.pinned 80 | self._updateState() 81 | key = None 82 | elif key == 'e': 83 | self.note.archived = not self.note.archived 84 | self._updateState() 85 | key = None 86 | elif key == '#': 87 | self.note.trashed = True 88 | key = None 89 | 90 | key = super(Note, self).keypress(size, key) 91 | return key 92 | -------------------------------------------------------------------------------- /src/keep_cli/widget/search.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import urwid 3 | import gkeepapi 4 | import logging 5 | from . import labels 6 | from . import edit 7 | from . import util 8 | from . import grid 9 | from .. import query 10 | from .. import constants 11 | 12 | from typing import List 13 | 14 | class Search(util.Border): 15 | def __init__(self, app: 'application.Application'): 16 | self.application = app 17 | 18 | self.w_pinned = urwid.CheckBox('Pinned', state='mixed', has_mixed=True) 19 | self.w_archived = urwid.CheckBox('Archived', state=False, has_mixed=True) 20 | self.w_trashed = urwid.CheckBox('Trashed', state=False, has_mixed=True) 21 | 22 | self.w_note = urwid.CheckBox('Note', state=True) 23 | self.w_list = urwid.CheckBox('List', state=True) 24 | 25 | self.w_labels = labels.Labels() 26 | self.w_colors = edit.Colors() 27 | 28 | self.w_labels.setLabels(app.keep.labels(), gkeepapi.node.ColorValue.White) 29 | 30 | self.w_header = urwid.Text(u'', align=urwid.RIGHT) 31 | self.w_footer = urwid.Text(u'', align=urwid.RIGHT) 32 | 33 | super(Search, self).__init__(urwid.Pile([ 34 | urwid.Text(('buTEXT', 'Search'), align=urwid.CENTER), 35 | 36 | urwid.Divider(), 37 | 38 | urwid.Text(('bTEXT', 'State')), 39 | self.w_pinned, 40 | self.w_archived, 41 | self.w_trashed, 42 | 43 | urwid.Divider(), 44 | 45 | urwid.Text(('bTEXT', 'Type')), 46 | self.w_note, 47 | self.w_list, 48 | 49 | urwid.Divider(), 50 | 51 | urwid.Text(('bTEXT', 'Labels')), 52 | self.w_labels, 53 | 54 | urwid.Divider(), 55 | 56 | urwid.Text(('bTEXT', 'Colors')), 57 | self.w_colors, 58 | 59 | urwid.Divider(), 60 | 61 | urwid.Columns([ 62 | urwid.Button('Apply', lambda x: self.onSearch()), 63 | urwid.Button('Cancel', lambda x: self.onCancel()), 64 | ], dividechars=1) 65 | ])) 66 | 67 | def onSearch(self): 68 | q = query.Query( 69 | labels=self.w_labels.getSelected() or None, 70 | colors=self.w_colors.getSelected() or None, 71 | pinned=self._getCheckboxValue(self.w_pinned), 72 | archived=self._getCheckboxValue(self.w_archived), 73 | trashed=self._getCheckboxValue(self.w_trashed) 74 | ) 75 | 76 | self.application.replace(grid.Grid(self.application, q)) 77 | self.application.overlay(None) 78 | 79 | def onCancel(self): 80 | self.application.overlay(None) 81 | 82 | def _getCheckboxValue(self, checkbox): 83 | state = checkbox.get_state() 84 | if isinstance(state, bool): 85 | return state 86 | return None 87 | 88 | def keypress(self, size, key): 89 | key = super(Search, self).keypress(size, key) 90 | return key 91 | -------------------------------------------------------------------------------- /src/keep_cli/widget/status.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import urwid 3 | 4 | class Status(urwid.AttrMap): 5 | def __init__(self, app: 'application.Application'): 6 | self.application = app 7 | 8 | super(Status, self).__init__(urwid.Columns([ 9 | urwid.Text(self.application.config['username']), 10 | urwid.Text('Press ? for help', align=urwid.RIGHT), 11 | ]), 'STATUS') 12 | -------------------------------------------------------------------------------- /src/keep_cli/widget/util.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import urwid 3 | 4 | class Border(urwid.AttrMap): 5 | def __init__(self, original_widget): 6 | super(Border, self).__init__(urwid.LineBox( 7 | urwid.AttrMap(urwid.Padding(original_widget, left=1, right=1), 'TEXT'), 8 | tlcorner='█', trcorner='█', 9 | blcorner='█', brcorner='█', 10 | tline='▀', lline='█', rline='█', bline='▄' 11 | ), 'BORDER') 12 | -------------------------------------------------------------------------------- /src/keep_cli/widget/views.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import urwid 3 | from . import util 4 | 5 | from typing import Union 6 | 7 | class Item(urwid.AttrMap): 8 | def __init__(self, key, label): 9 | self.key = key 10 | super(Item, self).__init__( 11 | urwid.Text(label), 12 | 'TEXT', 'bTEXT' 13 | ) 14 | 15 | def keypress(self, size, key): 16 | return key 17 | 18 | def selectable(self): 19 | return True 20 | 21 | class Views(util.Border): 22 | def __init__(self, app: 'application.Application'): 23 | self.application = app 24 | 25 | views = app.config.get('views') 26 | self.w_list = urwid.Pile([ 27 | (urwid.PACK, Item(key, view.get('name', key))) 28 | for key, view in views.items()]) 29 | 30 | 31 | super(Views, self).__init__(urwid.Pile([ 32 | urwid.Text(('buTEXT', 'Views'), align=urwid.CENTER), 33 | urwid.Divider(), 34 | self.w_list, 35 | ])) 36 | 37 | def selectable(self): 38 | return True 39 | 40 | def keypress(self, size, key): 41 | key = super(Views, self).keypress(size, key) 42 | if key == 'enter': 43 | view = self.w_list.focus.key 44 | w_view = self.application.hydrateView(view) 45 | self.application.replace(w_view) 46 | self.application.overlay(None) 47 | self.application.refresh() 48 | key = None 49 | return key 50 | --------------------------------------------------------------------------------