├── .conf.json ├── .gitignore ├── README.rst ├── client └── __init__.py ├── demo.gif ├── makefile ├── setup.py └── tests └── tests.py /.conf.json: -------------------------------------------------------------------------------- 1 | {"host": "noteit.com"} 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .tmp 3 | .dockerfiles 4 | .env 5 | .cache 6 | 7 | static 8 | node_modules 9 | bower_components 10 | dist 11 | 12 | *.sqlite3* 13 | *.db 14 | *.pyc 15 | .vscode 16 | *.egg-info/ 17 | build/ 18 | setup.cfg 19 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ==================================================================== 2 | :ledger: noteit v2 - note taking with CLI (use GitHub Gist as store) 3 | ==================================================================== 4 | 5 | Make Notes with CLI (zero dependence) 6 | ------------------------------------- 7 | 8 | I created this tool for my own purposes, but I will be glad if you'll use it too. 9 | 10 | I love commandline tools like `howdoi `_ , they are really awesome. 11 | Sometimes it is necessary to note something simple and useful: commands like *tar zxvf* or any password. That would be great, if you could make a note simple and fast, and then get it anywhere. I hope, you will enjoy this tool! 12 | 13 | 14 | Features 15 | ======== 16 | 17 | * \:octocat: Store data in your gists, so you need GitHub account. By default all notes stored at private gist. 18 | * \:earth_americas: Share your notes with others. 19 | * \:books: Use notebooks to organize your notes. 20 | * \:closed_lock_with_key: Secure. You can encrypt your notes by your own key. 21 | * \:package: Minimal dependence and size (about 7 kb), all you need is python. 22 | * \:snake: Support python 2.6, 2.7, 3.4 and upper (for 2.6 you need to install argparse package). 23 | * \:rocket: Easy to install (curl --silent --show-error --retry 5 http://krukov.pythonanywhere.com//install.sh | sudo sh). 24 | * \:beginner: Easy to use. 25 | * >_ CLI - that's awesome. Work at all platforms (I hope). 26 | 27 | 28 | How it works 29 | ------------- 30 | 31 | pass 32 | 33 | How to install 34 | -------------- 35 | 36 | There are 3 ways to install this tool: 37 | 38 | * simple/true/pythonic way: 39 | 40 | :: 41 | 42 | pip install noteit 43 | 44 | * manual install way (for those who do not use pip) 45 | 46 | :: 47 | 48 | $ wget https://raw.githubusercontent.com/Krukov/noteit/stable/noteit/noteit -O /usr/bin/noteit --no-check-certificate 49 | $ chmod +x /usr/bin/noteit 50 | 51 | or just 52 | 53 | :: 54 | 55 | $ curl --silent --show-error --retry 5 http://krukov.pythonanywhere.com/install.sh | sudo sh 56 | 57 | 58 | How to use 59 | ---------- 60 | 61 | :: 62 | 63 | $ /# noteit 64 | >Input username: github_login 65 | >Input your password: **** 66 | ALIAS UPDATED PUBLIC 67 | public 09-04-16 22:31 ✓ 68 | readme 10-04-16 23:39 69 | $ /# noteit new note -a new 70 | Saved 71 | $ /# echo "Noteit can get note from pipe" | noteit -a print_pipe 72 | Saved 73 | $ /# noteit 74 | ALIAS UPDATED PUBLIC 75 | public 09-04-16 22:31 ✓ 76 | readme 10-04-16 23:39 77 | print_pipe 13-04-16 23:17 78 | $ /# noteit echo "You can run it" -a test 79 | Saved 80 | $ /# noteit -l | sh 81 | You can run it 82 | $ /# noteit Create note with alias and in notebook -a alias -n mynotebook 83 | Saved 84 | $ /# noteit --all 85 | NOTEBOOK ALIAS UPDATED PUBLIC 86 | __main__ public 09-04-16 22:31 ✓ 87 | __main__ readme 10-04-16 23:39 88 | __main__ print_pipe 13-04-16 23:17 89 | mynotebook alias 13-04-16 23:24 90 | $ /# noteit -a alias -n mynotebook 91 | Create note with alias and in notebook 92 | $ /# noteit Super secret note -a ss --key 93 | Input encryption key: ***** 94 | Saved 95 | 96 | 97 | *FUTURE* 98 | ======== 99 | - colorize 100 | - search 101 | -------------------------------------------------------------------------------- /client/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- encoding: utf-8 -*- 3 | from __future__ import print_function, unicode_literals 4 | 5 | import argparse 6 | import base64 7 | import getpass 8 | import json 9 | import os 10 | import platform 11 | import hashlib 12 | import logging 13 | import select 14 | import sys 15 | import time 16 | import traceback 17 | from collections import namedtuple 18 | from datetime import datetime 19 | from itertools import cycle 20 | from socket import gaierror 21 | from binascii import Error as AsciiError 22 | 23 | PY = 2 24 | try: 25 | # Python 2 26 | from httplib import HTTPConnection, HTTPSConnection 27 | from urllib import urlencode 28 | from urlparse import urlparse 29 | from socket import error as ConnectionError 30 | input = raw_input 31 | chr = unichr 32 | 33 | from itertools import izip 34 | zip = izip 35 | 36 | def base64encode(message): 37 | return base64.urlsafe_b64encode(message) 38 | 39 | def base64decode(message): 40 | return base64.urlsafe_b64decode(message.encode()).decode('utf-8') 41 | 42 | except ImportError: 43 | # Python 3 44 | PY = 3 45 | from http.client import HTTPConnection, HTTPSConnection 46 | from urllib.parse import urlencode, urlparse 47 | 48 | def base64encode(message): 49 | if isinstance(message, type(b'')): 50 | message = message.decode() 51 | return base64.urlsafe_b64encode(message.encode()).decode() 52 | 53 | def base64decode(message): 54 | return base64.urlsafe_b64decode(message).decode() 55 | 56 | 57 | Note = namedtuple('Note', ['alias', 'notebook', 'date', 'public']) 58 | SORTING = { 59 | 'date': lambda x: x.date, 60 | 'alias': lambda x: x.alias, 61 | 'default': lambda x: [x.notebook, x.alias], 62 | } 63 | _DEBUG = False 64 | _CACHED_ATTR = '_cache' 65 | _PASS_CACHE_KWARG = 'not_cached' 66 | __VERSION__ = '1.1.0' 67 | 68 | _PATH = os.path.expanduser('~/.noteit/') 69 | _TOKEN_PATH = os.path.join(_PATH, 'noteit.v2.tkn') 70 | _CACHE_PATH = os.path.join(_PATH, '.cache') 71 | _TOKEN_ENV_VALUE = 'GIST_TOKEN' 72 | 73 | GET, POST, PUT, PATCH, DELETE = 'GET', 'POST', 'PUT', 'PATCH', 'DELETE' 74 | _USER_AGENT_HEADER = 'User-Agent' 75 | _ACCEPT_HEADER = 'Accept' 76 | _APPLICATION_JSON = 'application/json' 77 | _AUTH_HEADER = 'Authorization' 78 | _TOKEN_HEADER = 'Authorization' 79 | _CONTENT_TYPE_HEADER = 'Content-type' 80 | _SUCCESS = range(200, 206) 81 | _API_URL = 'https://api.github.com{path}' 82 | _SCOPE = 'gist' 83 | _TIMEOUT = 10 84 | _ANONYMOUS_USER_AGENT = 'anonymous' 85 | _URL_MAP = { 86 | 'gists': '/gists/{id}', 87 | 'user_gists': '/users/{user}/gists/{id}', 88 | 'token': '/authorizations/{id}', 89 | } 90 | 91 | _REPORT_GIST = 'noteit.report' 92 | _GIST_NAME_PREFIX = 'noteit' 93 | _GIST_FILENAME = '{alias}.{type}' 94 | _REPORT_TOKEN = 'woLCrMKhw5nCvcK8wrDCoX_Cj8KFwqDCsHbCjsOMwo_CsnlswrB9wrHCm8KPwqjChGnCj8KMwp_Cn8KPwobCmsOJwr3Dn8Kowp_CisKMecKhwrrCrMKXwpXCkMKMesKlwq53wqBw' 95 | _TYPES = _TEXT_TYPE, _FILE_TYPE, _ENCRYPT_TYPE = ['text', 'file', 'entext'] 96 | 97 | _DECRYPT_ERROR_MSG = u"Error - can't decrypt note" 98 | _TEMPLATE = u' {n.alias:^35} {n.date:^20} {n.public:^6}' 99 | _TEMPLATE_N = u' {n.notebook:^12} ' + _TEMPLATE 100 | _TRUE = u'\u2713' 101 | _YES = [u'yes', u'y', u'poehali'] 102 | _FORMAT = _DATE_FORMAT = '%d-%m-%y %H:%M' 103 | _CREDENTIALS_WARNING = u''' 104 | WARNING: Noteit uses the GitHub Gist as store for notes! 105 | Noteit does not store your password or login. 106 | Noteit does not use your credentials for taking something from your GitHub account, except some of your Gists. 107 | Noteit creates the Personal token containing access only to gists, encrypt and save them locally. 108 | Input username and password for your github account or use --anon option: 109 | ''' 110 | _ANON_INTRODUCTION = u'''At 'anonymous' mode your notes saved at the overall github account, so everybody have access to them. 111 | Noteit asks for your name just to separate the general account to some namespace, so you can use any username. 112 | We recommend use '--anon' option with '-u' option to skip prompt and '-k' option to encrypt your notes. 113 | ''' 114 | 115 | if sys.getdefaultencoding().lower() != 'utf-8': 116 | _TRUE = '*' 117 | logging.captureWarnings(True) 118 | 119 | 120 | class AuthenticationError(Exception): 121 | """Error raising at wrong password """ 122 | pass 123 | 124 | 125 | class ServerError(Exception): 126 | """Error if server is return 50x status""" 127 | pass 128 | 129 | 130 | class DecryptError(Exception): 131 | """Error if can't decrypt note""" 132 | pass 133 | 134 | 135 | class NotFoundError(Exception): 136 | """Error if can't decrypt note""" 137 | pass 138 | 139 | 140 | class UpdateRequiredError(Exception): 141 | """Error at wrong version""" 142 | pass 143 | 144 | 145 | def cached_function(func): 146 | """Decorator that cache function result at first call and return cached result other calls """ 147 | def _func(*args, **kwargs): 148 | force = kwargs.pop(_PASS_CACHE_KWARG, False) 149 | _cache_name = _CACHED_ATTR + _md5(json.dumps({'k': kwargs, 'a': args})) 150 | if not hasattr(func, _cache_name) or force or _is_debug(): 151 | result = func(*args, **kwargs) 152 | if result is not None: 153 | setattr(func, _cache_name, result) 154 | return result 155 | return getattr(func, _cache_name) 156 | return _func 157 | 158 | 159 | def get_notes(all=False, notebook=None, public=False, user=''): 160 | """Return user's notes as string""" 161 | 162 | objects = get_gist_manager() 163 | 164 | for gist in objects.noteit_gists(user): 165 | meta = gist.description.split('.')[2 if user else 1:] 166 | if len(meta) > 2: 167 | continue 168 | _notebook, _rule = meta if len(meta) == 2 else (None, meta[0]) 169 | if user and gist.description.split('.')[1] != user: 170 | continue 171 | if not all: 172 | if public and _rule != 'public': 173 | continue 174 | if _notebook != notebook: 175 | continue 176 | for _file in gist.files: 177 | alias, _, updated = _parse_file_name(_file.name) 178 | yield Note(alias, _notebook or '__main__', updated, _TRUE if gist.public else '') 179 | 180 | 181 | def get_note(alias, notebook=None, public=False, user=''): 182 | """Return user note of given alias""" 183 | _f = _get_gistfile_with_alias(alias, notebook=notebook, public=public, user=user) 184 | if _f is None: 185 | raise NotFoundError('Note not found') 186 | if _parse_file_name(_f.name)[1] == _ENCRYPT_TYPE or get_options().key: 187 | return _decrypt(_f.full_content, _get_key()) 188 | return _f.full_content 189 | 190 | 191 | def delete_note(alias, notebook=None, public=False, user=''): 192 | """Delete/remove user note of given alias""" 193 | _f = _get_gistfile_with_alias(alias, notebook=notebook, public=public, user=user) 194 | if _f is None: 195 | raise NotFoundError('Note not found') 196 | return _f.delete() 197 | 198 | 199 | def get_last_note(notebook=None, public=False, user=''): 200 | """Return last saved note""" 201 | gist = _get_gist_by_name(_get_gist_name(notebook, public, user=user)) 202 | now = datetime.utcnow() 203 | last_f = sorted(gist.files, key=lambda _f: _parse_file_name(_f.name)[-1] or now) 204 | if not last_f: 205 | raise NotFoundError('Note not found') 206 | if _parse_file_name(last_f[0].name)[1] == _ENCRYPT_TYPE: 207 | return _decrypt(last_f[0].full_content, _get_key()) 208 | return last_f[0].full_content 209 | 210 | 211 | def delete_notebook(notebook, public=False, user=''): 212 | _g = _get_gist_by_name(_get_gist_name(notebook, public, user=user)) 213 | if _g is None: 214 | raise NotFoundError('Notebook not found') 215 | return _g.delete() 216 | 217 | 218 | def create_note(note, alias=None, notebook=None, public=False, type=_TEXT_TYPE, user=''): 219 | """Make note""" 220 | if type == _ENCRYPT_TYPE: 221 | note = _encrypt(note, _get_key()) 222 | gist_name = _get_gist_name(notebook, public, user=user) 223 | gist = _get_gist_by_name(gist_name) 224 | if gist: 225 | for _f in gist.files: 226 | if _parse_file_name(_f.name)[0] != alias: 227 | continue 228 | overwrite = input('Note with given alias already exists, Do you wanna overwrite it? ') in _YES 229 | if not overwrite: 230 | return 231 | _f.content = note 232 | _f.rename(_get_name_for_file(alias, type)) 233 | _f.save() 234 | return note 235 | else: 236 | gist = Gist(get_gist_manager(), public=public, description=gist_name) 237 | gist.add_file(_get_name_for_file(alias, type), note) 238 | gist.save() 239 | return note 240 | 241 | 242 | def report(tb): 243 | """Make traceback and etc. to server""" 244 | _g = Gist(manager=_get_spec_manager(), description=(get_options().user or 'unknown') + '.' + str(time.time())) 245 | _g.public = False 246 | _g.add_file('tb', tb) 247 | _g.add_file('info', _gen_info()) 248 | _g.save() 249 | 250 | # END API METHODS 251 | # GIST COMMUNICATION 252 | 253 | 254 | class GistManager: 255 | 256 | def __init__(self, user=None, password=None, token=None): 257 | self.__user, self.__password = user, password 258 | self.__token = token 259 | 260 | @staticmethod 261 | def _build_url(name, **kwargs): 262 | kwargs.setdefault('id', '') 263 | url = _URL_MAP[name].format(**kwargs) 264 | if url.endswith('/'): 265 | return url[:-1] 266 | return url 267 | 268 | @property 269 | def token(self): 270 | if self.__token is None: 271 | self.__token = self._get_token() 272 | if self.__token: 273 | self.__password = None 274 | return self.__token 275 | 276 | def _get_token(self): 277 | _date = _get_auth_data() 278 | responce = self._request(self._build_url('token'), POST, _date) 279 | if 'token' in responce: 280 | return responce['token'] 281 | if 'errors' in responce and responce['errors'][0]['code'] == 'already_exists': 282 | for auth in self._request(self._build_url('token')): 283 | if auth['note'] == _date['note'] and auth['fingerprint'] == _date['fingerprint']: 284 | self._request(self._build_url('token', id=auth['id']), DELETE) 285 | return self._request(self._build_url('token'), POST, _date)['token'] 286 | 287 | def _get_headers(self): 288 | headers = {} 289 | if self.__password: 290 | headers[_AUTH_HEADER] = _get_encoding_basic_credentials(self.__user, self.__password) 291 | elif self.__token: 292 | headers[_TOKEN_HEADER] = b'token ' + self.token.encode('ascii') 293 | return headers 294 | 295 | def _request(self, path, method=GET, data=None): 296 | url = _API_URL.format(path=path) 297 | if method in [DELETE]: 298 | return _do_request(url, method, data, headers=self._get_headers()) 299 | return json.loads(_do_request(url, method, data, headers=self._get_headers())) 300 | 301 | @property 302 | def list(self): 303 | return list(self.iter) 304 | 305 | @property 306 | def iter(self): 307 | if self.__password or self.__token: 308 | return (Gist(manager=self, **_g) for _g in self._request(self._build_url('gists'))) 309 | return (Gist(manager=self, **_g) for _g in self._request(self._build_url('user_gists', user=self.__user))) 310 | 311 | def noteit_gists(self, user=''): 312 | return (_g for _g in self.iter if _g.description.startswith(_GIST_NAME_PREFIX + '.' + user)) 313 | 314 | def get(self, id): 315 | if self.__password or self.__token: 316 | return Gist(manager=self, **self._request(self._build_url('gists', id=id))) 317 | for _g in self.iter: 318 | if str(_g.id) == str(id): 319 | return _g 320 | 321 | def create(self, description, files, public=False): 322 | data = {'description': description, 'files': files, 'public': public} 323 | return Gist(manager=self, **self._request(self._build_url('gists'), POST, data=data)) 324 | 325 | def update(self, id, description, files): 326 | data = {'description': description, 'files': files} 327 | return Gist(self._request(self._build_url('gists', id=id), PATCH, data=data)) 328 | 329 | def delete(self, id): 330 | self._request(self._build_url('gists', id=id), DELETE) 331 | 332 | 333 | class _ProxyProperty(object): 334 | 335 | def __init__(self, name, default=None): 336 | self.name, self._default = name, default 337 | 338 | def __get__(self, instance, owner): 339 | if not instance: 340 | return self 341 | return instance._data.get(self.name, self._default() if self._default else None) 342 | 343 | def __set__(self, instance, value): 344 | if not instance: 345 | return 346 | instance._data[self.name] = value 347 | instance._edited = True 348 | 349 | 350 | class Gist(object): 351 | 352 | def __init__(self, manager, **kwargs): 353 | self._manager, self._data = manager, kwargs 354 | self._data.setdefault('public', False) 355 | self.files = [GistFile(name, self, **_f) for name, _f in self._files.items()] 356 | 357 | id = _ProxyProperty('id') 358 | description = _ProxyProperty('description') 359 | public = _ProxyProperty('public') 360 | _files = _ProxyProperty('files', lambda: {}) 361 | _created = _ProxyProperty('created_at') 362 | 363 | @property 364 | def _files_dict(self): 365 | return dict([(f.name, f.as_dict()) for f in self.files]) 366 | 367 | @property 368 | def _edited_files_dict(self): 369 | return dict([(f.name, f.as_dict()) for f in self.files if f._edited]) 370 | 371 | def add_file(self, name, content): 372 | _f = self.get_file(name, GistFile) 373 | if _f.content: 374 | raise ValueError('File with name {0} already exists'.format(name)) 375 | _f.content = content 376 | self.files.append(_f) 377 | 378 | def edit_file(self, name, content): 379 | self.get_file(name).content = content 380 | 381 | def get_file(self, name, default=None): 382 | for _f in self.files: 383 | if _f.name == name: 384 | return _f 385 | if default is not None: 386 | return default(name=name, gist=self) if callable(default) else default 387 | raise ValueError('No file with name {0}'.format(name)) 388 | 389 | def get_file_content(self, name): 390 | return self.get_file(name).full_content 391 | 392 | def save(self): 393 | if self.id: 394 | return self._manager.update(self.id, self.description, self._edited_files_dict) 395 | return self._manager.create(self.description, self._files_dict, public=self.public) 396 | 397 | def delete(self): 398 | self._manager.delete(self.id) 399 | 400 | 401 | class GistFile(object): 402 | def __init__(self, name, gist=None, **kwargs): 403 | self.__name, self._data = name, kwargs 404 | self._edited = self._delited = False 405 | self.__gist = gist 406 | self._new_name = None 407 | 408 | truncated = property(lambda self: self._data.get('truncated')) 409 | content = _ProxyProperty('content') 410 | raw_url = _ProxyProperty('raw_url') 411 | 412 | @property 413 | def name(self): 414 | return self.__name 415 | 416 | def rename(self, value): 417 | self._new_name = value 418 | 419 | def as_dict(self): 420 | if self._delited: 421 | return 422 | out = {} 423 | if self._new_name: 424 | out['filename'] = self._new_name 425 | if self._edited: 426 | out['content'] = self.content 427 | return out 428 | 429 | def delete(self): 430 | self._edited = True 431 | self._delited = True 432 | if self.__gist: 433 | self.save() 434 | 435 | @property 436 | def full_content(self): 437 | if self.truncated or not self.content: 438 | return _do_request(self.raw_url) 439 | return self.content 440 | 441 | def save(self): 442 | self.__gist.save() 443 | 444 | 445 | def _get_auth_data(): 446 | return {'scopes': [_SCOPE], 'note': 'Noteit', 'fingerprint': platform.system() + '-' + platform.node()} 447 | 448 | 449 | def _get_gistfile_with_alias(alias, notebook=None, public=False, user=''): 450 | gist = _get_gist_by_name(_get_gist_name(notebook, public, user=user)) 451 | if gist is None: 452 | raise NotFoundError('Gist not found') 453 | for _f in gist.files: 454 | if _parse_file_name(_f.name)[0] == alias: 455 | return _f 456 | 457 | 458 | def _get_gist_name(notebook=None, public=False, user=''): 459 | name = '.'.join([_GIST_NAME_PREFIX, user, notebook or '', 'public' if public else 'private']) 460 | return name.replace('..', '.').replace('..', '.') 461 | 462 | 463 | def _get_gist_by_name(name): 464 | for _g in get_gist_manager().noteit_gists(): 465 | if _g.description == name: 466 | return _g 467 | 468 | 469 | def _get_name_for_file(alias, type): 470 | return '{0}#{1}.{2}'.format(alias, type, int(time.time() * 1000000)) 471 | 472 | 473 | def _parse_file_name(name): 474 | alias, meta = name.split('#') 475 | try: 476 | type, updated = meta.split('.') 477 | updated = datetime.utcfromtimestamp(int(updated) / 1000000.0) 478 | except ValueError: 479 | type, updated = meta, 0 480 | return alias, type, updated 481 | 482 | 483 | def _do_request(url, *args, **kwargs): 484 | """Make request and handle response""" 485 | kwargs.setdefault('headers', {}).update(_get_default_headers()) 486 | response = _make_request(url, *args, **kwargs) 487 | resp = _response_handler(response) 488 | return resp 489 | 490 | 491 | def _response_handler(response): 492 | """Handle response status""" 493 | response_body = response.read().decode('utf-8') 494 | if response.status in [401, ]: 495 | raise AuthenticationError 496 | elif response.status > 500: 497 | raise ServerError 498 | elif response.status in [301, 302, 303, 307] and response._method != POST: 499 | raise AuthenticationError 500 | return response_body 501 | 502 | 503 | @cached_function 504 | def _get_connection(host): 505 | """Create and return connection with host""" 506 | return HTTPSConnection(host, timeout=_TIMEOUT) 507 | 508 | 509 | def _make_request(url, method=GET, data=None, headers=None): 510 | """Generate request and send it""" 511 | headers = headers or {} 512 | method = method.upper() 513 | conn = _get_connection(urlparse(url).hostname) 514 | if data: 515 | data = json.dumps(data).encode('ascii') 516 | if method == GET: 517 | url = '?'.join([url, data.decode('ascii') or '']) 518 | data = None 519 | 520 | if method in [POST, PUT, PATCH]: 521 | headers.update({_CONTENT_TYPE_HEADER: "application/x-www-form-urlencoded"}) 522 | conn.request(method, url, body=data, headers=headers) 523 | return conn.getresponse() 524 | 525 | 526 | @cached_function 527 | def _get_user_agent(): 528 | """Return User-Agent for request header""" 529 | if get_options().anon: 530 | return _ANONYMOUS_USER_AGENT 531 | return _generate_user_agent_with_info() 532 | 533 | 534 | def _generate_user_agent_with_info(): 535 | """Generate User-Agent with environment info""" 536 | return ' '.join([ 537 | u'{0}/{1}'.format('Noteit', get_version()), 538 | ]) 539 | 540 | 541 | def _get_encoding_basic_credentials(user, password=''): 542 | """Return value of header for Basic Authentication""" 543 | return b'Basic ' + base64.b64encode('{0}:{1}'.format(user, password).encode('ascii')) 544 | 545 | 546 | def _get_default_headers(): 547 | """Return dict of headers for request""" 548 | return { 549 | _ACCEPT_HEADER: _APPLICATION_JSON, 550 | _USER_AGENT_HEADER: _get_user_agent(), 551 | _CONTENT_TYPE_HEADER: _APPLICATION_JSON, 552 | } 553 | 554 | # END GIST COMMUNICATIONS 555 | 556 | 557 | @cached_function 558 | def get_gist_manager(): 559 | if get_options().anon: 560 | return _get_spec_manager() 561 | if get_options().public and get_options().user: 562 | return GistManager(user=get_options().user) 563 | token = _get_token_from_system() 564 | if token: 565 | return GistManager(token=token) 566 | if not get_options().user: 567 | print(_CREDENTIALS_WARNING) 568 | first = GistManager(_get_user(), _get_password()) 569 | if first.token: 570 | _save_token(first.token) 571 | return first 572 | 573 | 574 | def _get_spec_manager(): 575 | manager = GistManager(token=_decrypt(_REPORT_TOKEN, get_version().replace('.', '_'))) 576 | try: 577 | manager.list 578 | except AuthenticationError: 579 | raise UpdateRequiredError() 580 | return manager 581 | 582 | 583 | @cached_function 584 | def _get_password(): 585 | """Return password from argument or asks user for it""" 586 | return get_options().password or getpass.getpass(u'Input your password: ') 587 | 588 | 589 | @cached_function 590 | def _get_user(): 591 | """Return user from argument or asks user for it""" 592 | return get_options().user or input(u'Input username: ') 593 | 594 | 595 | @cached_function 596 | def _get_key(): 597 | """Return key to encode/decode from argument or from local""" 598 | return getpass.getpass(u'Input encryption key: ') 599 | 600 | 601 | def _md5(message): 602 | md5 = hashlib.md5() 603 | md5.update(message.encode()) 604 | return md5.hexdigest() 605 | 606 | 607 | def _double_md5(message): 608 | return _md5(_md5(message)) 609 | 610 | 611 | def _get_token_from_system(): 612 | """Return token from file""" 613 | if _TOKEN_ENV_VALUE in os.environ: 614 | return os.environ.get(_TOKEN_ENV_VALUE) 615 | if get_options().token: 616 | return get_options().token 617 | return _get_saved_token() 618 | 619 | 620 | def _save_token(token): 621 | """Save token to file""" 622 | _save_file_or_ignore(_TOKEN_PATH, _encrypt(token, platform.node())) 623 | 624 | 625 | def _get_saved_token(): 626 | if os.path.isfile(_TOKEN_PATH): 627 | with open(_TOKEN_PATH) as _file: 628 | encrypt_token = _file.read().strip() 629 | return _decrypt(encrypt_token, platform.node()) 630 | 631 | 632 | def _delete_token(): 633 | """Delete file with token""" 634 | if os.path.exists(_TOKEN_PATH): 635 | os.remove(_TOKEN_PATH) 636 | 637 | 638 | def _get_from_pipe(): 639 | """Read stdin if pipe is open | """ 640 | try: 641 | is_in_pipe = select.select([sys.stdin], [], [], 0.0)[0] 642 | except (select.error, TypeError): 643 | return 644 | else: 645 | return sys.stdin.read() if is_in_pipe else None 646 | 647 | 648 | def _is_debug(): 649 | if _DEBUG: 650 | return True 651 | return u'--debug' in sys.argv 652 | 653 | 654 | @cached_function 655 | def get_version(): 656 | """Return version of client""" 657 | return __VERSION__ 658 | 659 | 660 | def _save_file_or_ignore(path, content): 661 | if get_options().do_not_save: 662 | return 663 | if not os.path.isdir(os.path.dirname(path)): 664 | os.makedirs(os.path.dirname(path)) 665 | with open(path, 'w') as _file: 666 | _file.write(content) 667 | 668 | 669 | def _format_alias(alias): 670 | return alias 671 | 672 | 673 | def _encrypt(message, key): 674 | """Encrypt message with b64encoding and {} alg""" 675 | message = base64encode(message) 676 | crypted = '' 677 | for pair in zip(message, cycle(_double_md5(key))): 678 | crypted += chr((ord(pair[0]) + ord(pair[1])) % 256) 679 | return base64encode(crypted.encode('utf-8')) 680 | 681 | 682 | def _decrypt(message, key): 683 | try: 684 | return __decrypt(message, key) 685 | except (UnicodeDecodeError, TypeError, AsciiError, ValueError, AttributeError): 686 | raise DecryptError 687 | 688 | 689 | def __decrypt(message, key): 690 | """Decrypt message with b64decoding and {} alg""" 691 | message = base64decode(message) 692 | decrypted = '' 693 | for pair in zip(message, cycle(_double_md5(key))): 694 | decrypted += chr((ord(pair[0]) - ord(pair[1])) % 256) 695 | return base64decode(decrypted.encode('utf-8')) 696 | 697 | 698 | def _gen_info(): 699 | return ' '.join([ 700 | u'{0}/{1}'.format('Noteit', get_version()), 701 | u'{i[0]}-{i[1]}/{i[2]}-{i[5]}'.format(i=platform.uname()), 702 | u'{0}/{1}'.format(platform.python_implementation(), platform.python_version()), 703 | ]) 704 | 705 | 706 | def get_options_parser(): 707 | """Arguments definition""" 708 | parser = argparse.ArgumentParser(description='Tool for creating notes in your gists', prog='noteit') 709 | 710 | parser.add_argument('note', metavar='NOTE', nargs='*', default=_get_from_pipe(), help='new note') 711 | 712 | parser.add_argument('-u', '--user', help='username') 713 | parser.add_argument('--password', help='password') 714 | parser.add_argument('-t', '--token', help='token') 715 | parser.add_argument('--anon', help='for users without accounts', action='store_true') 716 | 717 | parser.add_argument('-n', '--notebook', help='set notebook for note / display notes with given notebook') 718 | parser.add_argument('-k', '--key', help='key to encrypting/decrypting notes', action='store_true') 719 | 720 | parser.add_argument('-p', '--public', help='Public notes', action='store_true') 721 | parser.add_argument('-s', '--sort', help='Sort type for notes', 722 | choices=list(SORTING.keys()) + ['n' + k for k in SORTING.keys()] + ['-'], default='default') 723 | parser.add_argument('--all', help='display all notes', action='store_true') 724 | 725 | parser.add_argument('-l', '--last', help='display last note', action='store_true') 726 | parser.add_argument('-a', '--alias', help='set alias for note / display note with given alias') 727 | parser.add_argument('-d', '--delete', help='delete note/notebook', action='store_true') 728 | 729 | parser.add_argument('--do-not-save', help='disable savings any data locally', action='store_true') 730 | 731 | parser.add_argument('-r', '--report', help=argparse.SUPPRESS, action='store_true') 732 | parser.add_argument('--debug', action='store_true', help=argparse.SUPPRESS) 733 | 734 | parser.add_argument('--version', action='version', version='%(prog)s ' + get_version(), 735 | help='displays the current version of %(prog)s and exit') 736 | 737 | return parser 738 | 739 | 740 | @cached_function 741 | def get_options(): 742 | """Return parsed arguments""" 743 | return get_options_parser().parse_args() 744 | 745 | 746 | def main(retry=True): 747 | """Main""" 748 | options = get_options() 749 | user = '' 750 | if options.user or options.token or options.anon: 751 | retry = False 752 | try: 753 | if options.anon: 754 | if not options.user: 755 | print(_ANON_INTRODUCTION) 756 | user = _get_user().decode('utf-8') if PY == 2 else _get_user() 757 | alias = options.alias.decode('utf-8') if PY == 2 and options.alias else options.alias 758 | notebook = options.notebook.decode('utf-8') if PY == 2 and options.notebook else options.notebook 759 | if options.note: 760 | if not alias: 761 | sys.exit('You must specify alias with option -a ') 762 | if PY == 2: 763 | note = u' '.join([w.decode('utf-8') for w in options.note]) if isinstance(options.note, (list, tuple)) \ 764 | else options.note.decode('utf-8') 765 | else: 766 | note = u' '.join([w for w in options.note]) if isinstance(options.note, (list, tuple)) \ 767 | else options.note 768 | res = create_note(note, alias, notebook, options.public, 769 | _TEXT_TYPE if not options.key else _ENCRYPT_TYPE, user) 770 | if res: 771 | print('Saved') 772 | else: 773 | print('Canceled') 774 | elif alias is not None: 775 | if options.delete and input(u'Are you really want to delete note? ') in _YES: 776 | delete_note(_format_alias(alias), notebook, options.public, user) 777 | print(u'Note "{0}"" deleted'.format(alias)) 778 | else: 779 | print(get_note(_format_alias(alias), notebook, options.public, user)) 780 | elif options.last: 781 | print(get_last_note(notebook, options.public, user)) 782 | elif options.delete and notebook: 783 | if input(u'Are you really want to delete all notes in notebook "{0}"?' 784 | u' '.format(options.notebook)) not in _YES: 785 | print(u'Canceled') 786 | else: 787 | delete_notebook(options.notebook, options.public, user) 788 | print('Notebook "{0}" deleted'.format(notebook)) 789 | else: 790 | template = _TEMPLATE 791 | if options.all: 792 | template = _TEMPLATE_N 793 | print(template.replace(u'<', u'^').format(n=Note('ALIAS', 'NOTEBOOK', 'UPDATED', 'PUBLIC'))) 794 | notes = get_notes(all=options.all, notebook=notebook, public=options.public, user=user) 795 | key, reverse = '-default' if options.sort == '-' else options.sort, False 796 | if key.startswith('n'): 797 | key, reverse = key[1:], True 798 | for note in sorted(notes, key=SORTING[key], reverse=reverse): 799 | note = Note(note.alias, note.notebook, note.date.strftime(_FORMAT), note.public) 800 | print(template.format(n=note)) 801 | 802 | except KeyboardInterrupt: 803 | sys.exit('\n') 804 | except AuthenticationError: 805 | if retry: 806 | _delete_token() 807 | main(retry=False) 808 | 809 | sys.exit(u'Error in authentication') 810 | except ServerError: 811 | sys.exit(u'Sorry there is server error. Please, try again later') 812 | except NotFoundError as e: 813 | sys.exit(str(e)) 814 | except DecryptError: 815 | sys.exit('Decrypt Error') 816 | except UpdateRequiredError: 817 | sys.exit('Please, update noteit with "pip install -U noteit"') 818 | except (ConnectionError, gaierror): 819 | sys.exit(u'Something wrong with connection, check your internet connection or try again later') 820 | except Exception: 821 | if _is_debug(): 822 | raise 823 | if not options.report: 824 | sys.exit(u'Something went wrong! You can sent report to us with "-r" option') 825 | report(traceback.format_exc()) 826 | 827 | 828 | if __name__ == '__main__': 829 | main() 830 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Krukov/noteit/2b1eb371f9d702644d7e76a4918b33234e2e7179/demo.gif -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | DIR?=noteit 2 | 3 | 4 | mini: clean 5 | rm -rf $(DIR) 6 | mkdir $(DIR) 7 | pyminifier --gzip client/__init__.py > $(DIR)/noteit 8 | cp client/__init__.py $(DIR)/__init__.py 9 | 10 | tag: 11 | git tag $(shell python3.4 client/__init__.py --version| grep "\d+.\d+.\d+" -Po) 12 | git push origin stable --tags 13 | 14 | pypi_pull: 15 | python setup.py register 16 | python setup.py sdist upload 17 | 18 | commit: 19 | git commit -am "Release auto commit. ver. $(shell python3.4 client/__init__.py --version| grep "\d+.\d+.\d+" -Po)" 20 | 21 | merge: 22 | git merge master --no-edit 23 | 24 | gch_stable: 25 | git checkout stable 26 | 27 | clean: 28 | find . -name *.pyc -delete 29 | rm -rf $(shell find . -name __pycache__) build *.egg-info dist 30 | 31 | release: gch_stable merge mini commit tag pypi_pull 32 | echo 'YO!' 33 | 34 | encrypt%: 35 | @python -c "from client import _encrypt, get_version; print(_encrypt('$*'.strip(), get_version().replace('.', '_')))" 36 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from setuptools import setup 3 | 4 | from noteit import get_version 5 | 6 | 7 | setup( 8 | name='noteit', 9 | version=get_version(), 10 | packages=['noteit'], 11 | 12 | entry_points={ 13 | 'console_scripts': [ 14 | 'noteit = noteit.__init__:main', 15 | ], 16 | }, 17 | url='https://github.com/Krukov/noteit', 18 | download_url='https://github.com/Krukov/noteit/tarball/' + get_version(), 19 | license='MIT', 20 | author='Dmitry Krukov', 21 | author_email='glebov.ru@gmail.com', 22 | description='The tool for creating notes', 23 | long_description=open('README.rst').read(), 24 | keywords='noteit note console command line messages', 25 | classifiers=[ 26 | 'Development Status :: 4 - Beta', 27 | 'Environment :: Console', 28 | 'Intended Audience :: Developers', 29 | 'License :: OSI Approved :: MIT License', 30 | 'Programming Language :: Python', 31 | 'Programming Language :: Python :: 2', 32 | 'Programming Language :: Python :: 2.7', 33 | 'Programming Language :: Python :: 3', 34 | 'Programming Language :: Python :: 3.4', 35 | 'Programming Language :: Python :: 3.5', 36 | ], 37 | ) -------------------------------------------------------------------------------- /tests/tests.py: -------------------------------------------------------------------------------- 1 | 2 | import os 3 | 4 | from unittest import TestCase 5 | 6 | import mock 7 | from client import Gist, GistFile, cached_function 8 | 9 | 10 | class TestsGistApi(TestCase): 11 | 12 | def setUp(self): 13 | self.user = os.environ.get('GIST_USER') 14 | self.password = os.environ.get('GIST_PASSWORD') 15 | 16 | def test_getting_gists_list(self): 17 | pass 18 | 19 | 20 | class TestGistObject(TestCase): 21 | 22 | def setUp(self): 23 | self._manager = mock.Mock() 24 | 25 | def test_properties(self): 26 | data = { 27 | "url": "https://api.github.com/gists/aa5a315d61ae9438b18d", 28 | "commits_url": "https://api.github.com/gists/aa5a315d61ae9438b18d/commits", 29 | "id": "aa5a315d61ae9438b18d", 30 | "description": "description of gist", 31 | "public": True, 32 | "files": { 33 | "ring.erl": { 34 | "size": 932, 35 | "raw_url": "https://gist.githubusercontent.com/raw/365370/8c4d2d43d178df44f4c03a7f2ac0ff512853564e/ring.erl", 36 | "type": "text/plain", 37 | "truncated": False, 38 | "language": "Erlang", 39 | "content": "contents of gist" 40 | } 41 | }, 42 | "truncated": False, 43 | "html_url": "https://gist.github.com/aa5a315d61ae9438b18d", 44 | "git_pull_url": "https://gist.github.com/aa5a315d61ae9438b18d.git", 45 | "git_push_url": "https://gist.github.com/aa5a315d61ae9438b18d.git", 46 | "created_at": "2010-04-14T02:15:15Z", 47 | "updated_at": "2011-06-20T11:34:15Z" 48 | } 49 | gist = Gist(manager=self._manager, **data) 50 | 51 | self.assertEqual(gist.description, "description of gist") 52 | self.assertEqual(gist.public, True) 53 | self.assertEqual(gist._created, "2010-04-14T02:15:15Z") 54 | 55 | def test_files_methods(self): 56 | data = { 57 | "url": "https://api.github.com/gists/aa5a315d61ae9438b18d", 58 | "commits_url": "https://api.github.com/gists/aa5a315d61ae9438b18d/commits", 59 | "id": "aa5a315d61ae9438b18d", 60 | "description": "description of gist", 61 | "public": True, 62 | "files": { 63 | "ring.erl": { 64 | "size": 932, 65 | "raw_url": "https://gist.githubusercontent.com/raw/365370/8c4d2d43d178df44f4c03a7f2ac0ff512853564e/ring.erl", 66 | "type": "text/plain", 67 | "truncated": False, 68 | "language": "Erlang", 69 | "content": "contents of gist" 70 | } 71 | }, 72 | "truncated": False, 73 | "html_url": "https://gist.github.com/aa5a315d61ae9438b18d", 74 | "git_pull_url": "https://gist.github.com/aa5a315d61ae9438b18d.git", 75 | "git_push_url": "https://gist.github.com/aa5a315d61ae9438b18d.git", 76 | "created_at": "2010-04-14T02:15:15Z", 77 | "updated_at": "2011-06-20T11:34:15Z" 78 | } 79 | gist = Gist(manager=self._manager, **data) 80 | self.assertEqual(gist._files_dict, {'ring.erl': {'content': 'contents of gist'}}) 81 | self.assertEqual(gist._edited_files_dict, {}) 82 | 83 | gist.edit_file('ring.erl', 'new') 84 | self.assertEqual(gist._files_dict, {'ring.erl': {'content': 'new'}}) 85 | self.assertEqual(gist._edited_files_dict, {'ring.erl': {'content': 'new'}}) 86 | self.assertEqual(gist.get_file_content('ring.erl'), 'new') 87 | 88 | gist.add_file('test.txt', 'test') 89 | self.assertEqual(gist._files_dict, {'ring.erl': {'content': 'new'}, 'test.txt': {'content': 'test'}}) 90 | self.assertEqual(gist._edited_files_dict, {'ring.erl': {'content': 'new'}, 'test.txt': {'content': 'test'}}) 91 | 92 | with self.assertRaises(ValueError): 93 | gist.add_file('test.txt', 'test') 94 | 95 | @mock.patch('client._do_request', mock.Mock(return_value='from row')) 96 | def test_getting_file_content(self, m=None): 97 | data = { 98 | "url": "https://api.github.com/gists/aa5a315d61ae9438b18d", 99 | "commits_url": "https://api.github.com/gists/aa5a315d61ae9438b18d/commits", 100 | "id": "aa5a315d61ae9438b18d", 101 | "description": "description of gist", 102 | "public": True, 103 | "files": { 104 | "ring.erl": { 105 | "size": 932, 106 | "raw_url": "https://gist.githubusercontent.com/raw/365370/8c4d2d43d178df44f4c03a7f2ac0ff512853564e/ring.erl", 107 | "type": "text/plain", 108 | "truncated": False, 109 | "language": "Erlang", 110 | "content": "contents of gist" 111 | }, 112 | "other.ring.erl": { 113 | "size": 932, 114 | "raw_url": "https://gist.githubusercontent.com/raw/365370/8c4d2d43d178df44f4c03a7f2ac0ff512853564e/ring.erl", 115 | "type": "text/plain", 116 | "truncated": True, 117 | "language": "Erlang", 118 | "content": "contents of gist" 119 | } 120 | 121 | }, 122 | "truncated": False, 123 | "html_url": "https://gist.github.com/aa5a315d61ae9438b18d", 124 | "git_pull_url": "https://gist.github.com/aa5a315d61ae9438b18d.git", 125 | "git_push_url": "https://gist.github.com/aa5a315d61ae9438b18d.git", 126 | "created_at": "2010-04-14T02:15:15Z", 127 | "updated_at": "2011-06-20T11:34:15Z" 128 | } 129 | 130 | gist = Gist(self._manager, **data) 131 | 132 | self.assertEqual(gist.get_file_content('ring.erl'), 'contents of gist') 133 | self.assertEqual(gist.get_file_content('other.ring.erl'), 'from row') 134 | 135 | def test_save_method(self): 136 | self._manager.create = mock.Mock(return_value=None) 137 | gist = Gist(self._manager) 138 | gist.description = 'New' 139 | gist.add_file('test.ft', 'new file') 140 | gist.save() 141 | 142 | self._manager.create.assert_called_once_with('New', {'test.ft': {'content': 'new file'}}, public=False) 143 | 144 | data = { 145 | "id": "id", 146 | "description": "description of gist", 147 | "public": True, 148 | "files": {}, 149 | "truncated": False, 150 | } 151 | self._manager.update = mock.Mock(return_value=None) 152 | gist = Gist(manager=self._manager, **data) 153 | gist.description = 'New' 154 | gist.add_file('test.ft', 'new file') 155 | gist.save() 156 | self._manager.update.assert_called_once_with('id', 'New', {'test.ft': {'content': 'new file'}}) 157 | 158 | 159 | class TestsGistFileObject(TestCase): 160 | 161 | def test_properties(self): 162 | URL = "https://gist.githubusercontent.com/raw/365370/8c4d2d43d178df44f4c03a7f2ac0ff512853564e/ring.erl" 163 | data = { 164 | "size": 932, 165 | "raw_url": URL, 166 | "type": "text/plain", 167 | "language": "Erlang", 168 | "truncated": False, 169 | "content": "contents of gist" 170 | } 171 | target = GistFile(name='filename', **data) 172 | 173 | self.assertEqual(target.truncated, False) 174 | self.assertEqual(target.content, "contents of gist") 175 | self.assertEqual(target.raw_url, URL) 176 | 177 | def test_as_dict_method(self): 178 | data = { 179 | "size": 932, 180 | "language": "Erlang", 181 | "truncated": False, 182 | "content": "contents of gist" 183 | } 184 | target = GistFile(name='filename', **data) 185 | 186 | self.assertEqual(target.as_dict(), {'content': "contents of gist"}) 187 | 188 | target.delete() 189 | self.assertEqual(target.as_dict(), None) 190 | 191 | 192 | 193 | class TestCacheDecor(TestCase): 194 | 195 | def test_simple(self): 196 | m = mock.Mock(return_value=10) 197 | func = cached_function(lambda: m()) 198 | 199 | self.assertEqual(func(), 10) 200 | self.assertEqual(m.call_count, 1) 201 | self.assertEqual(func(), 10) 202 | self.assertEqual(m.call_count, 1) 203 | 204 | def test_params(self): 205 | m = mock.Mock(return_value=10) 206 | func = cached_function(lambda x=1: m() * x) 207 | 208 | self.assertEqual(func(x=1), 10) 209 | self.assertEqual(m.call_count, 1) 210 | self.assertEqual(func(x=1), 10) 211 | self.assertEqual(func(x=1), 10) 212 | self.assertEqual(m.call_count, 1) 213 | 214 | self.assertEqual(func(2), 20) 215 | self.assertEqual(m.call_count, 2) 216 | self.assertEqual(func(x=1), 10) 217 | self.assertEqual(m.call_count, 2) 218 | self.assertEqual(func(10), 100) 219 | self.assertEqual(m.call_count, 3) 220 | --------------------------------------------------------------------------------