├── .gitignore ├── .tool-versions ├── .travis.yml ├── COPYING ├── MANIFEST.in ├── README.md ├── circle.yml ├── completion ├── bash_completion │ └── _geeknote └── zsh_completion │ └── _geeknote ├── geeknote.rb ├── geeknote ├── __init__.py ├── argparser.py ├── config.py ├── editor.py ├── gclient.py ├── geeknote.py ├── gnsync.py ├── log.py ├── oauth.py ├── out.py ├── storage.py └── tools.py ├── proxy_support.md ├── requirements.txt ├── setup.cfg ├── setup.py ├── tests ├── fixtures │ └── Test Note with non-ascii.xml ├── helpers.py ├── pseudoedit.py ├── test_argparser.py ├── test_editor.py ├── test_gclient.py ├── test_geeknote.py ├── test_gnsync.py ├── test_out.py ├── test_sandbox.py ├── test_storage.py └── test_tools.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[co] 2 | *~ 3 | # Packages 4 | *.egg 5 | *.egg-info 6 | .eggs 7 | dist 8 | build 9 | eggs 10 | parts 11 | bin 12 | var 13 | sdist 14 | develop-eggs 15 | .installed.cfg 16 | .idea 17 | .venv 18 | /venv/ 19 | 20 | # Installer logs 21 | pip-log.txt 22 | installed_files.txt 23 | 24 | # Unit test / coverage reports 25 | .coverage 26 | .tox 27 | .cache 28 | .pytest_cache/ 29 | 30 | #Translations 31 | *.mo 32 | 33 | #Mr Developer 34 | .mr.developer.cfg 35 | 36 | #Mac files 37 | .DS_Store 38 | 39 | # virtualenv 40 | include/ 41 | lib/ 42 | local/ 43 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | python 2.7.16 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | # geeknote uses features of python not present in 2.6 4 | # - "2.6" 5 | - "2.7" 6 | # The official Evernote SDK doesn't support Python 3 yet 7 | # Keep an eye on https://github.com/evernote/evernote-sdk-python3 8 | # - "3.2" 9 | # - "3.3" 10 | # - "3.4" 11 | # - "3.5" 12 | # - "3.5-dev" # 3.5 development branch 13 | # - "nightly" # currently points to 3.6-dev 14 | 15 | os: 16 | - linux 17 | # - osx 18 | 19 | install: 20 | - pip install -r requirements.txt 21 | 22 | # command to run tests 23 | script: python -m pytest 24 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md requirements.txt MANIFEST.in 2 | include completion/zsh_completion/_geeknote completion/bash_completion/_geeknote 3 | exclude *.orig *.pyc 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Archived 2 | === 3 | I no longer maintain this repository. If you've come here looking for bug fixes, please check one of the more recently updated forks to see if they are more recently maintained. 4 | 5 | 6 | Important Note 7 | === 8 | This is an updated version of geeknote for Python 3 using evernote-sdk-python3 (which is also in beta). Even though things are not throughly tested, basic functionality works. 9 | 10 | Geeknote for Evernote (or 印象笔记) [![Travis CI](https://travis-ci.org/jeffkowalski/geeknote.svg?branch=master)](https://travis-ci.org/jeffkowalski/geeknote) [![Circle CI](https://circleci.com/gh/jeffkowalski/geeknote.svg?&style=shield)](https://circleci.com/gh/jeffkowalski/geeknote) 11 | === 12 | 13 | Geeknote is a command line client for Evernote that can be use on Linux, FreeBSD and OS X. 14 | 15 | It allows you to: 16 | * create notes in your Evernote account; 17 | * create tags, notebooks; 18 | * use Evernote search from the console using different filters; 19 | * edit notes directly in the console using any editor, such as nano, vim or mcedit; 20 | * synchronize your local files and directories with Evernote; 21 | * use Evernote with cron or any scripts. 22 | 23 | Geeknote is open source and written in Python. Geeknote can be used anywhere you have Python installed (even in Windows if you like). 24 | 25 | In this document we'll show how to work with Evernote's notes, notebooks, and tags using Geeknote and how to use Geeknote sync. 26 | 27 | ## Installation 28 | You can install Geeknote using [Homebrew](http://brew.sh/)/[Linuxbrew](https://github.com/Homebrew/linuxbrew), or from its source. 29 | 30 | ##### Homebrew installation 31 | 32 | ``` sh 33 | brew install --HEAD https://raw.githubusercontent.com/jeffkowalski/geeknote/master/geeknote.rb 34 | ``` 35 | 36 | ##### Downloading and installing from source 37 | 38 | ``` sh 39 | # Install dependencies. (This example for Debian-based systems): 40 | sudo apt-get update; sudo apt-get -y install python-setuptools 41 | pip install wheel 42 | 43 | # Download the repository. 44 | git clone git://github.com/jeffkowalski/geeknote.git 45 | 46 | cd geeknote 47 | 48 | # Installation 49 | 50 | # - if you have only a python2 environment, 51 | python setup.py build 52 | pip install --upgrade . 53 | 54 | # - or, to force python2 in python3 environments 55 | python2 setup.py build 56 | pip2 install --upgrade . 57 | ``` 58 | 59 | ##### Testing 60 | Geeknote has a non-destructive unit test suite with fair coverage. 61 | 62 | Ensure pytest framework is installed 63 | ``` sh 64 | pip install --upgrade pytest 65 | ``` 66 | 67 | Execute the tests 68 | ``` sh 69 | py.test 70 | ``` 71 | 72 | ##### Un-installation 73 | 74 | If originally installed via homebrew, 75 | 76 | ``` sh 77 | brew remove geeknote 78 | ``` 79 | 80 | If originally installed from source, 81 | 82 | ``` sh 83 | pip uninstall geeknote 84 | ``` 85 | 86 | ## Geeknote Settings 87 | ##### Authorizing Geeknote 88 | After installation, Geeknote must be authorized with Evernote prior to use. To authorize your Geeknote in Evernote launch the command *login*: 89 | 90 | ``` sh 91 | geeknote login 92 | ``` 93 | 94 | This will start the authorization process. Geeknote will ask you to enter your credentials just once to generate access token, which will be saved in local database. Re-authorization is not required, if you won't decide to change user. 95 | After authorization you can start to work with Geeknote. 96 | 97 | ##### Logging out and changing users 98 | If you want to change Evernote user you should launch *logout* command: 99 | 100 | ``` sh 101 | geeknote logout 102 | ``` 103 | 104 | Afterward, you can repeat the authorization step. 105 | 106 | ##### (Yìnxiàng Bǐjì notes) 107 | 108 | If you want to use Evernote's separate service in China Yìnxiàng Bǐjì (印象笔记), 109 | you need to set the environment variable `GEEKNOTE_BASE` to `yinxiang`. 110 | 111 | ``` sh 112 | GEEKNOTE_BASE=yinxiang geeknote login 113 | # or 114 | export GEEKNOTE_BASE=yinxiang 115 | geeknote ...commands... 116 | ``` 117 | 118 | Yìnxiàng Bǐjì (印象笔记) is faster in China and it supports Chinese payment methods. 119 | Be aware that Yìnxiàng Bǐjì will not have support for sharing social features 120 | like Twitter or Facebook. Furthermore, since data are stored on servers in China, 121 | Chinese authorities have the right to access their data according to current 122 | regulations. 123 | 124 | For more information, see: 125 | [Evernote Launches Separate Chinese Service](https://blog.evernote.com/blog/2012/05/09/evernote-launches-separate-chinese-service/) 126 | 127 | ## Login with a developer token 128 | 129 | Geeknote requires a Developer token after an unsuccessful OAuth request. 130 | 131 | You can obtain one by following the next simple steps: 132 | 133 | - Create an API key for SANDBOX environment 134 | - Request your API key to be activated on production 135 | - Convert it to a personal token 136 | 137 | To do so, go to [Evernote FAQ](https://dev.evernote.com/support/faq.php#createkey) and refer to 138 | the section "How do I create an API key?". As directed, click on the 139 | "Get an API Key" button at the top of the page, and complete the 140 | revealed form. You'll then receive an e-mail with your key and 141 | secret. 142 | 143 | When you receive your key and secret, activate your key by following the 144 | instructions on the ["How do I copy my API key from Sandbox to www (production)?"](https://dev.evernote.com/support/faq.php#activatekey) section of the FAQ. 145 | Be sure to specify on the form that you're using the key for the "geeknote" application. 146 | 147 | Once your API key activation is processed by Evernote Developer 148 | Support, they will send you an email with further instructions on 149 | obtaining the personal token. 150 | 151 | ##### Examining your settings 152 | 153 | ``` sh 154 | $ geeknote settings 155 | Geeknote 156 | ****************************** 157 | Version: 3.0 158 | App dir: /Users/username/.geeknote 159 | Error log: /Users/username/.geeknote/error.log 160 | Current editor: vim 161 | Markdown2 Extras: None 162 | Note extension: .markdown, .org 163 | ****************************** 164 | Username: username 165 | Id: 11111111 166 | Email: example@gmail.com 167 | ``` 168 | 169 | ##### Setting up the default editor 170 | You can edit notes within console editors in plain text or markdown format. 171 | 172 | You can setup the default editor you want to use. To check which editor is now set up as a default call: 173 | 174 | ``` sh 175 | geeknote settings --editor 176 | ``` 177 | 178 | To change the default editor call: 179 | 180 | ``` sh 181 | geeknote settings --editor vim 182 | ``` 183 | 184 | To use `gvim` you need to prevent forking from the terminal with `-f`: 185 | 186 | ``` sh 187 | geeknote settings --editor 'gvim -f' 188 | ``` 189 | 190 | ###### Example 191 | 192 | ``` sh 193 | $ geeknote settings --editor 194 | Current editor is: nano 195 | $ geeknote settings --editor vim 196 | Editor successfully saved 197 | $ geeknote settings --editor 198 | Current editor is: vim 199 | ``` 200 | 201 | ##### Enabling Markdown2 Extras 202 | 203 | You can enable [Markdown2 Extras](https://github.com/trentm/python-markdown2/wiki/Extras) you want to use while editing notes. To check which settings are currently enabled call: 204 | 205 | ``` sh 206 | geeknote settings --extras 207 | ``` 208 | To change the Markdown2 Extras call: 209 | 210 | ```sh 211 | geeknote settings --extras "tables, footnotes" 212 | ``` 213 | ###### Example 214 | 215 | ``` sh 216 | $ geeknote settings --extras 217 | current markdown2 extras is : ['None'] 218 | $ geeknote settings --extras "tables, footnotes" 219 | Changes saved. 220 | $ geeknote settings --extras 221 | current markdown2 extras is : ['tables', 'footnotes'] 222 | ``` 223 | 224 | ## Working with Notes 225 | ### Notes: Creating notes 226 | The main functionality that we need is creating notes in Evernote. 227 | 228 | ##### Synopsis 229 | 230 | ``` sh 231 | geeknote create --title 232 | [--content <content>] 233 | [--tag <tag>] 234 | [--created <date and time>] 235 | [--resource <attachment filename>] 236 | [--notebook <notebook where to save>] 237 | [--reminder <date and time>] 238 | [--url <url>] 239 | ``` 240 | 241 | ##### Options 242 | 243 | | Option | Argument | Description | 244 | |------------|----------|-------------| 245 | | ‑‑title | title | With this option we specify the title of new note we want to create. | 246 | | ‑‑content | content | Specify the content of new note. The content must not contain double quotes. | 247 | | ‑‑tag | tag | Specify tag that our note will have. May be repeated. | 248 | | ‑‑created | date | Set note creation date and time in either 'yyyy-mm-dd' or 'yyyy-mm-dd HH:MM' format. | 249 | | ‑‑resource | attachment filename, like: document.pdf |Specify file to be attached to the note. May be repeated. | 250 | | ‑‑notebook | notebook where to save | Specify the notebook where new note should be saved. This option is not required. If it isn't given, the note will be saved in default notebook. If notebook doesn't exist Geeknote will create it automatically. | 251 | | ‑‑reminder | date | Set reminder date and time in either 'yyyy-mm-dd' or 'yyyy-mm-dd HH:MM' format. Alternatively use TOMORROW and WEEK for 24 hours and a week ahead respectively, NONE for a reminder without a time. Use DONE to mark a reminder as completed. | 252 | | --urls | url | Set the URL for the note. | 253 | | --raw | | A flag signifying the content is in raw ENML format. | 254 | | --rawmd | | A flag signifying the content is in raw markdown format. | 255 | 256 | ##### Description 257 | This command allows us to create a new note in Evernote. Geeknote has designed for using in console, so we have some restrictions like inability to use double quotes in **--content** option. But there is a method to avoid it - use stdin stream or file synchronization, we show it later in documentation. 258 | 259 | ##### Examples 260 | 261 | Creating a new note with a PDF attachment: 262 | 263 | ``` sh 264 | geeknote create --title "Shopping list" 265 | --content "Don't forget to buy milk, turkey and chips." 266 | --resource shoppinglist.pdf 267 | --notebook "Family" 268 | --tag "shop" --tag "holiday" --tag "important" 269 | ``` 270 | 271 | Creating a new note and editing content in editor (notice the lack of `content` argument): 272 | 273 | ``` sh 274 | geeknote create --title "Meeting with customer" 275 | --notebook "Meetings" 276 | --tag "projectA" --tag "important" --tag "report" 277 | --created "2015-10-23 14:30" 278 | 279 | ``` 280 | 281 | ### Notes: Searching for notes in Evernote 282 | 283 | You can easily search notes in Evernote with Geeknote and output results in the console. 284 | 285 | ##### Synopsis 286 | 287 | ``` sh 288 | geeknote find --search <text to find> 289 | [--tag <tag>] 290 | [--notebook <notebook>] 291 | [--date <date or date range>] 292 | [--count <how many results to show>] 293 | [--exact-entry] 294 | [--content-search] 295 | [--url-only] 296 | [--reminders-only] 297 | [--deleted-only] 298 | [--ignore-completed] 299 | [--with-tags] 300 | [--with-notebook] 301 | [--guid] 302 | ``` 303 | 304 | ##### Description 305 | 306 | Use **find** to search through your Evernote notebooks, with options to search and print more detail. Geeknote remembers the result of the last search. So, you can use the ID number of the note's position for future actions. 307 | For example: 308 | 309 | ``` sh 310 | $ geeknote find --search "Shopping" 311 | 312 | Total found: 2 313 | 1 : 2006-06-02 2009-01-19 Grocery Shopping List 314 | 2 : 2015-02-22 2015-02-24 Gift Shopping List 315 | 316 | $ geeknote show 2 317 | ################### URL ################### 318 | NoteLink: https://www.evernote.com/shard/s1/nl/2079/7aecf253-c0d9-407e-b4e2-54cd5510ead6 319 | WebClientURL: https://www.evernote.com/Home.action?#n=7aecf253-c0d9-407e-b4e2-54cd5510ead6 320 | ################## TITLE ################## 321 | Gift Shopping List 322 | =================== META ================== 323 | Notebook: EverNote 324 | Created: 2015-02-22 325 | Updated: 2012-02-24 326 | |||||||||||||||| REMINDERS |||||||||||||||| 327 | Order: None 328 | Time: None 329 | Done: None 330 | ----------------- CONTENT ----------------- 331 | Tags: shopping 332 | Socks 333 | Silly Putty 334 | Furby 335 | ``` 336 | 337 | That will show you the note "Gift Shopping List". 338 | 339 | ##### Options 340 | 341 | | Option | Argument | Description | 342 | |--------------------|-----------------|-------------| 343 | | ‑‑search | text to find | Set the text to find. You can use "*" like this: *--search "Shop*"* | 344 | | ‑‑tag | tag | Filter by tag. May be repeated. | 345 | | ‑‑notebook | notebook | Filter by notebook. | 346 | | ‑‑date | date or range | Filter by date. You can set a single date in 'yyyy-mm-dd' format or a range with 'yyyy-mm-dd/yyyy-mm-dd' | 347 | | ‑‑count | how many results to show | Limits the number of displayed results. | 348 | | ‑‑content-search | | *find* command searches by note's title. If you want to search by note's content - set this flag. | 349 | | ‑‑exact-entry | | By default Geeknote has a smart search, so it searches fuzzy entries. But if you need exact entry, you can set this flag. | 350 | | ‑‑guid | | Show GUID of the note as substitute for result index. | 351 | | ‑‑ignore-completed | | Include only unfinished reminders. | 352 | | ‑‑reminders-only | | Include only notes with a reminder. | 353 | | ‑‑deleted-only | | Include only notes that have been **deleted/trashed**. | 354 | | ‑‑with-notebook | | Show notebook containing the note. | 355 | | ‑‑with-tags | | Show tags of the note after note title. | 356 | | ‑‑with-url | | Show results as a list of URLs to each note in Evernote's web-client. | 357 | 358 | ##### Examples 359 | 360 | ``` sh 361 | geeknote find --search "How to patch KDE2" --notebook "jokes" --date 2015-10-14/2015-10-28 362 | geeknote find --search "apt-get install apache nginx" --content-search --notebook "manual" 363 | ``` 364 | 365 | ### Notes: Editing notes 366 | 367 | With Geeknote you can edit your notes in Evernote using any editor you like (nano, vi, vim, emacs, etc.) 368 | 369 | ##### Synopsis 370 | 371 | ``` sh 372 | geeknote edit --note <title or GUID of note to edit> 373 | [--title <the new title>] 374 | [--content <new content or "WRITE">] 375 | [--resource <attachment filename>] 376 | [--tag <tag>] 377 | [--created <date and time>] 378 | [--notebook <new notebook>] 379 | [--reminder <date and time>] 380 | [--url <url>] 381 | ``` 382 | 383 | ##### Options 384 | 385 | | Option | Argument | Description | 386 | |------------|----------|-------------| 387 | | ‑‑note | title of note which to edit | Tells Geeknote which note we want to edit. Geeknote searches by that name to locate a note. If Geeknote finds more than one note with such name, it will ask you to make a choice. | 388 | | ‑‑title | a new title | Use this option if you want to rename your note. Just set a new title, and Geeknote will rename the old one. | 389 | | ‑‑content | new content or "WRITE" | Enter the new content of your notes in text, or write instead the option "WRITE". In the first case the old content of the note will be replaced with the new content. In the second case Geeknote will get the current content and open it in Markdown in a text editor. | 390 | | ‑‑resource | attachment filename, like: document.pdf | Specify file to be attached to the note. May be repeated. Will replace existing resources. | 391 | | ‑‑tag | tag | Tag to be assigned to the note. May be repeated. Will replace existing tags. | 392 | | ‑‑created | date | Set note creation date date and time in either 'yyyy-mm-dd' or 'yyyy-mm-dd HH:MM' format. | 393 | | ‑‑notebook | target notebook | With this option you can change the notebook which contains your note. | 394 | | ‑‑reminder | date | Set reminder date and time in either 'yyyy-mm-dd' or 'yyyy-mm-dd HH:MM' format. Alternatively use TOMORROW and WEEK for 24 hours and a week ahead respectively, NONE for a reminder without a time. Use DONE to mark a reminder as completed. Use DELETE to remove reminder from a note. | 395 | | --urls | url | Set the URL for the note. | 396 | | --raw | | A flag signifying the content is in raw ENML format. | 397 | | --rawmd | | A flag signifying the content is in raw markdown format. | 398 | 399 | ##### Examples 400 | 401 | Renaming the note: 402 | 403 | ``` sh 404 | geeknote edit --note "Naughty List" --title "Nice List" 405 | ``` 406 | 407 | Renaming the note and editing content in editor: 408 | 409 | ``` sh 410 | geeknote edit --note "Naughty List" --title "Nice List" --content "WRITE" 411 | ``` 412 | 413 | ### Notes: Showing note content 414 | 415 | You can output any note in console using command *show* either independently or as a subsequent command to *find*. When you use *show* on a search made previously in which there was more than one result, Geeknote will ask you to make a choise. 416 | 417 | ##### Synopsis 418 | 419 | ``` sh 420 | geeknote show <text or GUID to search and show> 421 | ``` 422 | 423 | ##### Examples 424 | 425 | ``` sh 426 | $ geeknote show "Shop*" 427 | 428 | Total found: 2 429 | 1 : Grocery Shopping List 430 | 2 : Gift Shopping List 431 | 0 : -Cancel- 432 | : _ 433 | ``` 434 | 435 | As we mentioned before, *show* can use the results of previous search, so if you have already done the search, just call *show* with number of previous search results. 436 | 437 | ``` sh 438 | $ geeknote find --search "Shop*" 439 | 440 | Total found: 2 441 | 1 : Grocery Shopping List 442 | 2 : Gift Shopping List 443 | 444 | $ geeknote show 2 445 | ``` 446 | 447 | ### Notes: Removing notes 448 | You can remove notes with Geeknotes from Evernote. 449 | 450 | ##### Synopsis 451 | 452 | ``` sh 453 | geeknote remove --note <note name or GUID> 454 | [--force] 455 | ``` 456 | 457 | ##### Options 458 | 459 | | Option | Argument | Description | 460 | |--------------------|-----------------|-------------| 461 | | ‑‑note | note name | Name of the note you want to delete. If Geeknote will find more than one note, it will ask you to make a choice. | 462 | | ‑‑force | | A flag that says that Geeknote shouldn't ask for confirmation to remove note. | 463 | 464 | ##### Examples 465 | 466 | ``` sh 467 | geeknote remove --note "Shopping list" 468 | ``` 469 | 470 | ### Notes: De-duplicating notes 471 | Geeknote can find and remove duplicate notes. 472 | 473 | ##### Synopsis 474 | 475 | ``` sh 476 | geeknote dedup [--notebook <notebook>] 477 | ``` 478 | 479 | ##### Options 480 | 481 | | Option | Argument | Description | 482 | |--------------------|-----------------|-------------| 483 | | ‑‑notebook | notebook | Filter by notebook. | 484 | 485 | ##### Description 486 | 487 | Geeknote can locate notes that have the same title and content, and move duplicate notes to the trash. 488 | For large accounts, this process can take some time and might trigger the API rate limit. 489 | For that reason, it's possible to scope the de-duplication to a notebook at a time. 490 | 491 | ##### Examples 492 | 493 | ``` sh 494 | geeknote dedup --notebook Contacts 495 | ``` 496 | 497 | ## Working with Notebooks 498 | ### Notebooks: show the list of notebooks 499 | 500 | Geeknote can display the list of all notebooks you have in Evernote. 501 | 502 | ##### Synopsis 503 | 504 | ``` sh 505 | geeknote notebook-list [--guid] 506 | ``` 507 | 508 | ##### Options 509 | 510 | | Option | Argument | Description | 511 | |--------------------|-----------------|-------------| 512 | | ‑‑guid | | Show GUID of the notebook as substitute for result index. | 513 | 514 | ### Notebooks: creating a notebook 515 | With Geeknote you can create notebooks in Evernote right in console! 516 | 517 | ##### Synopsis 518 | 519 | ``` sh 520 | geeknote notebook-create --title <notebook title> 521 | ``` 522 | 523 | ##### Options 524 | 525 | | Option | Argument | Description | 526 | |--------------------|-----------------|-------------| 527 | | ‑‑title | notebook title | With this option we specify the title of new note we want to create. | 528 | 529 | ##### Examples 530 | 531 | ``` sh 532 | geeknote notebook-create --title "Sport diets" 533 | ``` 534 | 535 | ### Notebooks: renaming a notebook 536 | 537 | With Geeknote it's possible to rename existing notebooks in Evernote. 538 | 539 | ##### Synopsis 540 | 541 | ``` sh 542 | geeknote notebook-edit --notebook <old name> 543 | --title <new name> 544 | ``` 545 | 546 | ##### Options 547 | 548 | | Option | Argument | Description | 549 | |--------------------|-----------------|-------------| 550 | | ‑‑notebook | old name | Name of existing notebook you want to rename. | 551 | | ‑‑title | new name | New title for notebook | 552 | 553 | ##### Examples 554 | 555 | ``` sh 556 | geeknote notebook-edit --notebook "Sport diets" --title "Hangover" 557 | ``` 558 | 559 | ### Notebooks: removing a notebook 560 | 561 | With Geeknote it's possible to remove existing notebooks in Evernote. 562 | 563 | ##### Synopsis 564 | 565 | ``` sh 566 | geeknote notebook-remove --notebook <notebook> 567 | [--force] 568 | ``` 569 | 570 | ##### Options 571 | 572 | | Option | Argument | Description | 573 | |--------------------|-----------------|-------------| 574 | | ‑‑notebook | notebook | Name of existing notebook you want to delete. | 575 | | ‑‑force | | A flag that says that Geeknote shouldn't ask for confirmation to remove notebook. | 576 | 577 | 578 | ##### Examples 579 | 580 | ``` sh 581 | geeknote notebook-remove --notebook "Sport diets" --force 582 | ``` 583 | 584 | ## Working with Tags 585 | ### Tags: showing the list of tags 586 | 587 | You can get the list of all tags you have in Evernote. 588 | 589 | ##### Synopsis 590 | 591 | ``` sh 592 | geeknote tag-list [--guid] 593 | ``` 594 | 595 | ##### Options 596 | 597 | | Option | Argument | Description | 598 | |--------------------|-----------------|-------------| 599 | | ‑‑guid | | Show GUID of the tag as substitute for result index. | 600 | 601 | ### Tags: creating a new tag 602 | Usually tags are created with publishing new note. But if you need, you can create a new tag with Geeknote. 603 | 604 | ##### Synopsis 605 | 606 | ``` sh 607 | geeknote tag-create --title <tag name to create> 608 | ``` 609 | 610 | ##### Options 611 | 612 | | Option | Argument | Description | 613 | |--------------------|-----------------|-------------| 614 | | ‑‑title | tag name to create | Set the name of tag you want to create. | 615 | 616 | ##### Examples 617 | 618 | ``` sh 619 | geeknote tag-create --title "Hobby" 620 | ``` 621 | 622 | ### Tags: renaming a tag 623 | 624 | You can rename the tag: 625 | 626 | ##### Synopsis 627 | 628 | ``` sh 629 | geeknote tag-edit --tagname <old name> 630 | --title <new name> 631 | ``` 632 | 633 | ##### Options 634 | 635 | | Option | Argument | Description | 636 | |--------------------|-----------------|-------------| 637 | | ‑‑tagname | old name | Name of existing tag you want to rename. | 638 | | ‑‑title | new name | New name for tag. | 639 | 640 | ##### Examples 641 | 642 | ``` sh 643 | geeknote tag-edit --tagname "Hobby" --title "Girls" 644 | ``` 645 | 646 | ## gnsync - synchronization app 647 | 648 | Gnsync is an additional application installed with Geeknote. Gnsync allows synchronization of files in local directories with Evernote. It works with text data and html with picture attachment support. 649 | 650 | ##### Synopsis 651 | 652 | ``` sh 653 | gnsync --path <path to directory which to sync> 654 | [--mask <unix shell-style wildcards to select the files, like *.* or *.txt or *.log>] 655 | [--format <in what format to save the note - plain, markdown, or html>] 656 | [--notebook <notebook, which will be used>] 657 | [--all] 658 | [--logpath <path to logfile>] 659 | [--two-way] 660 | [--download] 661 | ``` 662 | 663 | ##### Options 664 | 665 | | Option | Argument | Description | 666 | |--------------------|-----------------|-------------| 667 | | ‑‑path | directory to sync | The directory you want to sync with Evernote. It should be the directory with text content files. | 668 | | ‑‑mask | unix shell-style wildcards to select the files | You can tell *gnsync* what filetypes to sync. By default *gnsync* tries to open every file in the directory. But you can set the mask like: *.txt, *.log, *.md, *.markdown. | 669 | | ‑‑format | in what format to save the note - plain or markdown | Set the engine which to use while files uploading. *gnsync* supports markdown and plain text formats. By default it uses plain text engine. | 670 | | ‑‑notebook | notebook where to save | You can set the notebook which will be syncronized with local directory. But if you won't set this option, *gnsync* will create new notebook with the name of the directory that you want to sync. | 671 | | ‑‑all | | You can specify to synchronize all notebooks already on the server, into subdirectories of the path. Useful with --download to do a backup of all notes. | 672 | | ‑‑logpath | path to logfile | *gnsync* can log information about syncing and with that option you can set the logfile. | 673 | | ‑‑two-way | | Normally *gnsync* will only upload files. Adding this flag will also make it download any notes not present as files in the notebook directory (after uploading any files not present as notes) | 674 | | ‑‑download-only | | Normally *gnsync* will only upload files. Adding this flag will make it download notes, but not upload any files | 675 | 676 | ##### Description 677 | The application *gnsync* is very useful in system administration, because you can syncronize you local logs, statuses and any other production information with Evernote. 678 | 679 | ##### Examples 680 | 681 | ``` sh 682 | gnsync --path /home/project/xmpp/logs/ 683 | --mask "*.logs" 684 | --logpath /home/user/logs/xmpp2evernote.log 685 | --notebook "XMPP logs" 686 | ``` 687 | 688 | ### Original Contributors 689 | * Vitaliy Rodnenko 690 | * Simon Moiseenko 691 | * Ivan Gureev 692 | * Roman Gladkov 693 | * Greg V 694 | * Ilya Shmygol 695 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | machine: 2 | python: 3 | version: 2.7.11 4 | 5 | dependencies: 6 | pre: 7 | - pip install mock 8 | - pip install pytest 9 | 10 | override: 11 | - pip install . 12 | 13 | test: 14 | pre: 15 | - rm ./venv 16 | override: 17 | - py.test 18 | -------------------------------------------------------------------------------- /completion/bash_completion/_geeknote: -------------------------------------------------------------------------------- 1 | _geeknote_command() 2 | { 3 | COMPREPLY=() 4 | cur="${COMP_WORDS[COMP_CWORD]}" 5 | 6 | SAVE_IFS=$IFS 7 | IFS=" " 8 | args="${COMP_WORDS[*]:1}" 9 | IFS=$SAVE_IFS 10 | 11 | COMPREPLY=( $(compgen -W "`geeknote autocomplete ${args}`" -- ${cur}) ) 12 | 13 | return 0 14 | } 15 | complete -F _geeknote_command geeknote 16 | -------------------------------------------------------------------------------- /completion/zsh_completion/_geeknote: -------------------------------------------------------------------------------- 1 | #compdef geeknote 2 | # --------------- ------------------------------------------------------------ 3 | # Name : _geeknote 4 | # Synopsis : zsh completion for geeknote 5 | # Author : Zhao Cai <caizhaoff@gmail.com> 6 | # Date Created : Thu 11 Oct 2012 01:57:23 PM EDT 7 | # Last Modified : Thu 11 Oct 2012 02:25:47 PM EDT 8 | # Tag : [ shell, zsh, completion, evernote ] 9 | # Copyright : © 2012 by Zhao Cai, 10 | # Released under current GPL license. 11 | # --------------- ------------------------------------------------------------ 12 | local ret 13 | 14 | ret=1 15 | 16 | geeknote_comp=( ${(z)"$(_call_program geeknote geeknote autocomplete $words[2,-1])"} ) 17 | _describe 'geeknote' geeknote_comp && ret=0 18 | 19 | return ret 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /geeknote.rb: -------------------------------------------------------------------------------- 1 | class Geeknote < Formula 2 | include Language::Python::Virtualenv 3 | 4 | desc "Command-line client for Evernote" 5 | homepage 'https://github.com/jeffkowalski/geeknote' 6 | head 'https://github.com/jeffkowalski/geeknote.git' 7 | 8 | depends_on "python@2" 9 | 10 | resource "websocket-client" do 11 | url "https://files.pythonhosted.org/packages/a7/2b/0039154583cb0489c8e18313aa91ccd140ada103289c5c5d31d80fd6d186/websocket_client-0.40.0.tar.gz" 12 | sha256 "40ac14a0c54e14d22809a5c8d553de5a2ae45de3c60105fae53bcb281b3fe6fb" 13 | end 14 | 15 | resource "ipaddress" do 16 | url "https://files.pythonhosted.org/packages/4e/13/774faf38b445d0b3a844b65747175b2e0500164b7c28d78e34987a5bfe06/ipaddress-1.0.18.tar.gz" 17 | sha256 "5d8534c8e185f2d8a1fda1ef73f2c8f4b23264e8e30063feeb9511d492a413e1" 18 | end 19 | 20 | resource "orderedmultidict" do 21 | url "https://files.pythonhosted.org/packages/10/a5/a9596229782ffcb465f288588dff39ccd7f64fc453d64f75f5ef442315a8/orderedmultidict-0.7.11.tar.gz" 22 | sha256 "dc2320ca694d90dca4ecc8b9c5fdf71ca61d6c079d6feb085ef8d41585419a36" 23 | end 24 | 25 | resource "oauth2" do 26 | url "https://files.pythonhosted.org/packages/64/19/8b9066e94088e8d06d649e10319349bfca961e87768a525aba4a2627c986/oauth2-1.9.0.post1.tar.gz" 27 | sha256 "c006a85e7c60107c7cc6da1b184b5c719f6dd7202098196dfa6e55df669b59bf" 28 | end 29 | 30 | resource "httplib2" do 31 | url "https://files.pythonhosted.org/packages/e4/2e/a7e27d2c36076efeb8c0e519758968b20389adf57a9ce3af139891af2696/httplib2-0.10.3.tar.gz" 32 | sha256 "e404d3b7bd86c1bc931906098e7c1305d6a3a6dcef141b8bb1059903abb3ceeb" 33 | end 34 | 35 | resource "evernote" do 36 | url "https://files.pythonhosted.org/packages/3b/8e/dba34913e7dbccd868cdf228c5104f97ad97d4618994f0c5dd456496ae81/evernote-1.25.2.tar.gz" 37 | sha256 "69212c161e2538db13dd34e749125ff970f6c88aaa5f52f6925ffcf883107302" 38 | end 39 | 40 | resource "html2text" do 41 | url "https://files.pythonhosted.org/packages/22/c0/2d02a1fb9027f54796af2c2d38cf3a5b89319125b03734a9964e6db8dfa0/html2text-2016.9.19.tar.gz" 42 | sha256 "554ef5fd6c6cf6e3e4f725a62a3e9ec86a0e4d33cd0928136d1c79dbeb7b2d55" 43 | end 44 | 45 | resource "SQLAlchemy" do 46 | url "https://files.pythonhosted.org/packages/02/69/9473d60abef55445f8e967cfae215da5de29ca21b865c99d2bf02a45ee01/SQLAlchemy-1.1.9.tar.gz" 47 | sha256 "b65cdc73cd348448ef0164f6c77d45a9f27ca575d3c5d71ccc33adf684bc6ef0" 48 | end 49 | 50 | resource "markdown2" do 51 | url "https://files.pythonhosted.org/packages/2f/c0/6da6f0caec68c99dd4bd4661b0ac16a50bdad89b1bbeb5a40686826762dc/markdown2-2.3.3.zip" 52 | sha256 "20a9439a80d93c221080297119d9d31285a18c904489948deae3c7e05c7c5e38" 53 | end 54 | 55 | resource "beautifulsoup4" do 56 | url "https://files.pythonhosted.org/packages/9b/a5/c6fa2d08e6c671103f9508816588e0fb9cec40444e8e72993f3d4c325936/beautifulsoup4-4.5.3.tar.gz" 57 | sha256 "b21ca09366fa596043578fd4188b052b46634d22059e68dd0077d9ee77e08a3e" 58 | end 59 | 60 | resource "thrift" do 61 | url "https://files.pythonhosted.org/packages/a3/ea/84a41e03f1ab14fb314c8bcf1c451090efa14c5cdfb9797d1079f502b54e/thrift-0.10.0.zip" 62 | sha256 "b7f6c09155321169af03f9fb20dc15a4a0c7481e7c334a5ba8f7f0d864633209" 63 | end 64 | 65 | resource "future" do 66 | url "https://files.pythonhosted.org/packages/00/2b/8d082ddfed935f3608cc61140df6dcbf0edea1bc3ab52fb6c29ae3e81e85/future-0.16.0.tar.gz" 67 | sha256 "e39ced1ab767b5936646cedba8bcce582398233d6a627067d4c6a454c90cfedb" 68 | end 69 | 70 | # for proxyenv 71 | resource "docker-py" do 72 | url "https://files.pythonhosted.org/packages/fa/2d/906afc44a833901fc6fed1a89c228e5c88fbfc6bd2f3d2f0497fdfb9c525/docker-py-1.10.6.tar.gz" 73 | sha256 "4c2a75875764d38d67f87bc7d03f7443a3895704efc57962bdf6500b8d4bc415" 74 | end 75 | 76 | resource "htpasswd" do 77 | url "https://files.pythonhosted.org/packages/b9/2f/8b76f8b77125b75c3532966f3291f9e8787268be65fc4c9694887cba9375/htpasswd-2.3.tar.gz" 78 | sha256 "565f0b647a32549c663ccfddd1f501891daaf29242bbc6174bdd448120383e3d" 79 | end 80 | 81 | resource "proxyenv" do 82 | url "https://files.pythonhosted.org/packages/69/98/46baccf9ce353828726e0d302dad201e634fe4b50f6f61891f0721f40789/proxyenv-0.5.1.tar.gz" 83 | sha256 "e73caf8b063b7fbfb93b67e725a71469768262a9dddb4d9dfb79bb1e84cab4b9" 84 | end 85 | 86 | resource "lxml" do 87 | url "https://files.pythonhosted.org/packages/39/e8/a8e0b1fa65dd021d48fe21464f71783655f39a41f218293c1c590d54eb82/lxml-3.7.3.tar.gz" 88 | sha256 "aa502d78a51ee7d127b4824ff96500f0181d3c7826e6ee7b800d068be79361c7" 89 | end 90 | 91 | resource "backports.ssl_match_hostname" do 92 | url "https://files.pythonhosted.org/packages/76/21/2dc61178a2038a5cb35d14b61467c6ac632791ed05131dda72c20e7b9e23/backports.ssl_match_hostname-3.5.0.1.tar.gz" 93 | sha256 "502ad98707319f4a51fa2ca1c677bd659008d27ded9f6380c79e8932e38dcdf2" 94 | end 95 | 96 | resource "docker-pycreds" do 97 | url "https://files.pythonhosted.org/packages/95/2e/3c99b8707a397153bc78870eb140c580628d7897276960da25d8a83c4719/docker-pycreds-0.2.1.tar.gz" 98 | sha256 "93833a2cf280b7d8abbe1b8121530413250c6cd4ffed2c1cf085f335262f7348" 99 | end 100 | 101 | resource "requests" do 102 | url "https://files.pythonhosted.org/packages/16/09/37b69de7c924d318e51ece1c4ceb679bf93be9d05973bb30c35babd596e2/requests-2.13.0.tar.gz" 103 | sha256 "5722cd09762faa01276230270ff16af7acf7c5c45d623868d9ba116f15791ce8" 104 | end 105 | 106 | resource "six" do 107 | url "https://files.pythonhosted.org/packages/b3/b2/238e2590826bfdd113244a40d9d3eb26918bd798fc187e2360a8367068db/six-1.10.0.tar.gz" 108 | sha256 "105f8d68616f8248e24bf0e9372ef04d3cc10104f1980f54d57b2ce73a5ad56a" 109 | end 110 | 111 | def install 112 | virtualenv_install_with_resources 113 | 114 | bash_completion.install "completion/bash_completion/_geeknote" => "geeknote" 115 | zsh_completion.install "completion/zsh_completion/_geeknote" => "_geeknote" 116 | end 117 | 118 | test do 119 | system "py.test" 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /geeknote/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "3.1" 2 | -------------------------------------------------------------------------------- /geeknote/argparser.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from .log import logging 4 | from . import out 5 | 6 | 7 | COMMANDS_DICT = { 8 | # User 9 | "user": { 10 | "help": "Show information about active user.", 11 | "flags": { 12 | "--full": { 13 | "help": "Show full information.", 14 | "value": True, 15 | "default": False, 16 | } 17 | }, 18 | }, 19 | "login": {"help": "Authorize in Evernote."}, 20 | "logout": { 21 | "help": "Logout from Evernote.", 22 | "flags": { 23 | "--force": { 24 | "help": "Don't ask about logging out.", 25 | "value": True, 26 | "default": False, 27 | } 28 | }, 29 | }, 30 | "settings": { 31 | "help": "Show and edit current settings.", 32 | "arguments": { 33 | "--editor": { 34 | "help": "Set the editor, which use to " "edit and create notes.", 35 | "emptyValue": "#GET#", 36 | }, 37 | "--note_ext": { 38 | "help": "Set default note's extension for markdown " 39 | "and raw formats." 40 | "\n Defaults to '.markdown, .org'", 41 | "emptyValue": "#GET#", 42 | }, 43 | "--extras": { 44 | "help": "Set the markdown2 extra syntax, which use " 45 | "to convert markdown text to HTML. " 46 | "\n Please visit " 47 | "http://tinyurl.com/q459lur " 48 | "to get detail.", 49 | "emptyValue": "#GET#", 50 | }, 51 | }, 52 | }, 53 | # Notes 54 | "create": { 55 | "help": "Create note in Evernote.", 56 | "arguments": { 57 | "--title": {"altName": "-t", "help": "The note title.", "required": True}, 58 | "--content": { 59 | "altName": "-c", 60 | "help": "The note content.", 61 | "value": True, 62 | "default": "WRITE", 63 | }, 64 | "--tag": { 65 | "altName": "-tg", 66 | "help": "Tag to be added to the note.", 67 | "repetitive": True, 68 | }, 69 | "--created": { 70 | "altName": "-cr", 71 | "help": "Set local creation time in 'yyyy-mm-dd'" 72 | " or 'yyyy-mm-dd HH:MM' format.", 73 | }, 74 | "--resource": { 75 | "altName": "-rs", 76 | "help": "Add a resource to the note.", 77 | "repetitive": True, 78 | }, 79 | "--notebook": { 80 | "altName": "-nb", 81 | "help": "Set the notebook where to save note.", 82 | }, 83 | "--reminder": { 84 | "altName": "-r", 85 | "help": "Set local reminder date and time in 'yyyy-mm-dd'" 86 | " or 'yyyy-mm-dd HH:MM' format." 87 | "\n Alternatively use TOMORROW " 88 | "and WEEK for 24 hours and a week ahead " 89 | "respectively," 90 | "\n NONE for a reminder " 91 | "without a time. Use DONE to mark a " 92 | "reminder as completed.", 93 | }, 94 | "--url": {"altname": "-u", "help": "Set the URL for the note."}, 95 | }, 96 | "flags": { 97 | "--raw": { 98 | "altName": "-r", 99 | "help": "Edit note with raw ENML", 100 | "value": True, 101 | "default": False, 102 | }, 103 | "--rawmd": { 104 | "altName": "-rm", 105 | "help": "Edit note with raw markdown", 106 | "value": True, 107 | "default": False, 108 | }, 109 | }, 110 | }, 111 | "create-linked": { 112 | "help": "Create Linked note in Evernote", 113 | "firstArg": "--notebook", 114 | "arguments": { 115 | "--title": {"altName": "-t", "help": "The note title.", "required": True}, 116 | "--notebook": { 117 | "alt-name": "-nb", 118 | "help": "Name of the linked notebook in " "which to create this note.", 119 | "required": True, 120 | }, 121 | }, 122 | }, 123 | "find": { 124 | "help": "Search notes in Evernote.", 125 | "firstArg": "--search", 126 | "arguments": { 127 | "--search": {"altName": "-s", "help": "Text to search.", "emptyValue": "*"}, 128 | "--tag": { 129 | "altName": "-tg", 130 | "help": "Tag sought on the notes.", 131 | "repetitive": True, 132 | }, 133 | "--notebook": {"altName": "-nb", "help": "Notebook containing the notes."}, 134 | "--date": { 135 | "altName": "-d", 136 | "help": "Set date in 'yyyy-mm-dd' format or " 137 | "date range 'yyyy-mm-dd/yyyy-mm-dd' format.", 138 | }, 139 | "--count": { 140 | "altName": "-cn", 141 | "help": "How many notes to show in the result list.", 142 | "type": int, 143 | }, 144 | }, 145 | "flags": { 146 | "--content-search": { 147 | "altName": "-cs", 148 | "help": "Search by content, not by title.", 149 | "value": True, 150 | "default": False, 151 | }, 152 | "--exact-entry": { 153 | "altName": "-ee", 154 | "help": "Search for exact entry of the request.", 155 | "value": True, 156 | "default": False, 157 | }, 158 | "--guid": { 159 | "altName": "-id", 160 | "help": "Replace ID with GUID of each note in results.", 161 | "value": True, 162 | "default": False, 163 | }, 164 | "--ignore-completed": { 165 | "altName": "-C", 166 | "help": "Include only unfinished reminders", 167 | "value": True, 168 | "default": False, 169 | }, 170 | "--reminders-only": { 171 | "altName": "-R", 172 | "help": "Include only notes with a reminder.", 173 | "value": True, 174 | "default": False, 175 | }, 176 | "--deleted-only": { 177 | "altName": "-D", 178 | "help": "Include only notes that have been deleted.", 179 | "value": True, 180 | "default": False, 181 | }, 182 | "--with-notebook": { 183 | "altName": "-wn", 184 | "help": "Add notebook of each note in results.", 185 | "value": True, 186 | "default": False, 187 | }, 188 | "--with-tags": { 189 | "altName": "-wt", 190 | "help": "Add tag list of each note in results.", 191 | "value": True, 192 | "default": False, 193 | }, 194 | "--with-url": { 195 | "altName": "-wu", 196 | "help": "Add direct url of each note " 197 | "in results to Evernote web-version.", 198 | "value": True, 199 | "default": False, 200 | }, 201 | }, 202 | }, 203 | "edit": { 204 | "help": "Edit note in Evernote.", 205 | "firstArg": "--note", 206 | "arguments": { 207 | "--note": { 208 | "altName": "-n", 209 | "help": "The name or GUID or ID from the " 210 | "previous search of a note to edit.", 211 | "required": True, 212 | }, 213 | "--title": {"altName": "-t", "help": "Set new title of the note."}, 214 | "--content": {"altName": "-c", "help": "Set new content of the note."}, 215 | "--resource": { 216 | "altName": "-rs", 217 | "help": "Add a resource to the note.", 218 | "repetitive": True, 219 | }, 220 | "--tag": { 221 | "altName": "-tg", 222 | "help": "Set new tag for the note.", 223 | "repetitive": True, 224 | }, 225 | "--created": { 226 | "altName": "-cr", 227 | "help": "Set local creation time in 'yyyy-mm-dd'" 228 | " or 'yyyy-mm-dd HH:MM' format.", 229 | }, 230 | "--notebook": { 231 | "altName": "-nb", 232 | "help": "Assign new notebook for the note.", 233 | }, 234 | "--reminder": { 235 | "altName": "-r", 236 | "help": "Set local reminder date and time in 'yyyy-mm-dd'" 237 | " or 'yyyy-mm-dd HH:MM' format." 238 | "\n Alternatively use TOMORROW " 239 | "and WEEK for 24 hours and a week ahead " 240 | "respectively," 241 | "\n NONE for a reminder " 242 | "without a time. Use DONE to mark a " 243 | "reminder as completed." 244 | "\n Use DELETE to remove " 245 | "reminder from a note.", 246 | }, 247 | "--url": {"altname": "-u", "help": "Set the URL for the note."}, 248 | }, 249 | "flags": { 250 | "--raw": { 251 | "altName": "-r", 252 | "help": "Edit note with raw ENML", 253 | "value": True, 254 | "default": False, 255 | }, 256 | "--rawmd": { 257 | "altName": "-rm", 258 | "help": "Edit note with raw markdown", 259 | "value": True, 260 | "default": False, 261 | }, 262 | }, 263 | }, 264 | "edit-linked": { 265 | "help": "Edit linked note in a shared notebook.", 266 | "firstArg": "--notebook", 267 | "arguments": { 268 | "--notebook": { 269 | "altName": "-nb", 270 | "help": "Name of the linked Notebook in which" "the note resides.", 271 | "required": True, 272 | }, 273 | "--note": { 274 | "altName": "-n", 275 | "help": "Title of the Note you want to edit.", 276 | "required": True, 277 | }, 278 | }, 279 | }, 280 | "show": { 281 | "help": "Output note in the terminal.", 282 | "firstArg": "--note", 283 | "arguments": { 284 | "--note": { 285 | "altName": "-n", 286 | "help": "The name or GUID or ID from the previous " 287 | "search of a note to show.", 288 | "required": True, 289 | } 290 | }, 291 | "flags": { 292 | "--raw": { 293 | "altName": "-w", 294 | "help": "Show the raw note body", 295 | "value": True, 296 | "default": False, 297 | } 298 | }, 299 | }, 300 | "remove": { 301 | "help": "Remove note from Evernote.", 302 | "firstArg": "--note", 303 | "arguments": { 304 | "--note": { 305 | "altName": "-n", 306 | "help": "The name or GUID or ID from the previous " 307 | "search of a note to remove.", 308 | "required": True, 309 | } 310 | }, 311 | "flags": { 312 | "--force": { 313 | "altName": "-f", 314 | "help": "Don't ask about removing.", 315 | "value": True, 316 | "default": False, 317 | } 318 | }, 319 | }, 320 | "dedup": { 321 | "help": "Find and remove duplicate notes in Evernote.", 322 | "arguments": { 323 | "--notebook": { 324 | "altName": "-nb", 325 | "help": "In which notebook search for duplicates.", 326 | } 327 | }, 328 | }, 329 | # Notebooks 330 | "notebook-list": { 331 | "help": "Show the list of existing notebooks in your Evernote.", 332 | "flags": { 333 | "--guid": { 334 | "altName": "-id", 335 | "help": "Replace ID with GUID " "of each notebook in results.", 336 | "value": True, 337 | "default": False, 338 | } 339 | }, 340 | }, 341 | "notebook-create": { 342 | "help": "Create new notebook.", 343 | "arguments": { 344 | "--title": { 345 | "altName": "-t", 346 | "help": "Set the title of new notebook.", 347 | "required": True, 348 | }, 349 | "--stack": {"help": "Specify notebook stack container."}, 350 | }, 351 | }, 352 | "notebook-edit": { 353 | "help": "Edit/rename notebook.", 354 | "firstArg": "--notebook", 355 | "arguments": { 356 | "--notebook": { 357 | "altName": "-nb", 358 | "help": "The name of a notebook to rename.", 359 | "required": True, 360 | }, 361 | "--title": {"altName": "-t", "help": "Set the new name of notebook."}, 362 | }, 363 | }, 364 | "notebook-remove": { 365 | "help": "Remove notebook.", 366 | "firstArg": "--notebook", 367 | "arguments": { 368 | "--notebook": { 369 | "altName": "-nb", 370 | "help": "The name of a notebook to remove.", 371 | "required": True, 372 | } 373 | }, 374 | "flags": { 375 | "--force": { 376 | "help": "Don't ask about removing notebook.", 377 | "value": True, 378 | "default": False, 379 | } 380 | }, 381 | }, 382 | # Tags 383 | "tag-list": { 384 | "help": "Show the list of existing tags in your Evernote.", 385 | "flags": { 386 | "--guid": { 387 | "altName": "-id", 388 | "help": "Replace ID with GUID of each note in results.", 389 | "value": True, 390 | "default": False, 391 | } 392 | }, 393 | }, 394 | "tag-create": { 395 | "help": "Create new tag.", 396 | "arguments": { 397 | "--title": { 398 | "altName": "-t", 399 | "help": "Set the title of new tag.", 400 | "required": True, 401 | } 402 | }, 403 | }, 404 | "tag-edit": { 405 | "help": "Edit/rename tag.", 406 | "firstArg": "--tagname", 407 | "arguments": { 408 | "--tagname": { 409 | "altName": "-tgn", 410 | "help": "The name of a tag to rename.", 411 | "required": True, 412 | }, 413 | "--title": {"altName": "-t", "help": "Set the new name of tag."}, 414 | }, 415 | }, 416 | } 417 | """ 418 | "tag-remove": { 419 | "help": "Remove tag.", 420 | "firstArg": "--tagname", 421 | "arguments": { 422 | "--tagname": {"help": "The name of a tag to remove.", 423 | "required": True}, 424 | }, 425 | "flags": { 426 | "--force": {"help": "Don't ask about removing.", 427 | "value": True, 428 | "default": False}, 429 | } 430 | }, 431 | """ 432 | 433 | 434 | class argparser(object): 435 | 436 | COMMANDS = COMMANDS_DICT 437 | sys_argv = None 438 | 439 | def __init__(self, sys_argv): 440 | self.sys_argv = sys_argv 441 | self.LVL = len(sys_argv) 442 | self.INPUT = sys_argv 443 | 444 | # list of commands 445 | self.CMD_LIST = list(self.COMMANDS.keys()) 446 | # command 447 | self.CMD = None if self.LVL == 0 else self.INPUT[0] 448 | # list of possible arguments of the command line 449 | self.CMD_ARGS = ( 450 | self.COMMANDS[self.CMD]["arguments"] 451 | if self.LVL > 0 452 | and self.CMD in self.COMMANDS 453 | and "arguments" in self.COMMANDS[self.CMD] 454 | else {} 455 | ) 456 | # list of possible flags of the command line 457 | self.CMD_FLAGS = ( 458 | self.COMMANDS[self.CMD]["flags"] 459 | if self.LVL > 0 460 | and self.CMD in self.COMMANDS 461 | and "flags" in self.COMMANDS[self.CMD] 462 | else {} 463 | ) 464 | # list of entered arguments and their values 465 | self.INP = [] if self.LVL <= 1 else self.INPUT[1:] 466 | 467 | logging.debug("CMD_LIST : %s", str(self.CMD_LIST)) 468 | logging.debug("CMD: %s", str(self.CMD)) 469 | logging.debug("CMD_ARGS : %s", str(self.CMD_ARGS)) 470 | logging.debug("CMD_FLAGS : %s", str(self.CMD_FLAGS)) 471 | logging.debug("INP : %s", str(self.INP)) 472 | 473 | def parse(self): 474 | self.INP_DATA = {} 475 | 476 | if self.CMD is None: 477 | out.printAbout() 478 | return False 479 | 480 | if self.CMD == "autocomplete": 481 | # substitute arguments for AutoComplete 482 | # 1 offset to make the argument as 1 is autocomplete 483 | self.__init__(self.sys_argv[1:]) 484 | self.printAutocomplete() 485 | return False 486 | 487 | if self.CMD == "--help": 488 | self.printHelp() 489 | return False 490 | 491 | if self.CMD not in self.COMMANDS: 492 | self.printErrorCommand() 493 | return False 494 | 495 | if "--help" in self.INP: 496 | self.printHelp() 497 | return False 498 | 499 | # prepare data 500 | for arg, params in list(self.CMD_ARGS.items()) + list(self.CMD_FLAGS.items()): 501 | # set values by default 502 | if "default" in params: 503 | self.INP_DATA[arg] = params["default"] 504 | 505 | # replace `altName` entered arguments on full 506 | if "altName" in params and params["altName"] in self.INP: 507 | self.INP[self.INP.index(params["altName"])] = arg 508 | 509 | activeArg = None 510 | ACTIVE_CMD = None 511 | # check and insert first argument by default 512 | if "firstArg" in self.COMMANDS[self.CMD]: 513 | firstArg = self.COMMANDS[self.CMD]["firstArg"] 514 | if len(self.INP) > 0: 515 | # Check that first argument is a default argument 516 | # and another argument. 517 | if self.INP[0] not in (list(self.CMD_ARGS.keys()) + list(self.CMD_FLAGS.keys())): 518 | self.INP = [firstArg] + self.INP 519 | else: 520 | self.INP = [firstArg] 521 | 522 | for item in self.INP: 523 | # check what are waiting the argument 524 | if activeArg is None: 525 | # actions for the argument 526 | if item in self.CMD_ARGS: 527 | activeArg = item 528 | ACTIVE_CMD = self.CMD_ARGS[activeArg] 529 | 530 | # actions for the flag 531 | elif item in self.CMD_FLAGS: 532 | self.INP_DATA[item] = self.CMD_FLAGS[item]["value"] 533 | 534 | # error. parameter is not found 535 | else: 536 | self.printErrorArgument(item) 537 | return False 538 | 539 | else: 540 | activeArgTmp = None 541 | # values it is parameter 542 | if item in self.CMD_ARGS or item in self.CMD_FLAGS: 543 | # active argument is "emptyValue" 544 | if "emptyValue" in ACTIVE_CMD: 545 | activeArgTmp = item # remember the new "active" argument 546 | item = ACTIVE_CMD[ 547 | "emptyValue" 548 | ] # set the active argument to emptyValue 549 | # Error, "active" argument has no values 550 | else: 551 | self.printErrorArgument(activeArg, item) 552 | return False 553 | 554 | if "type" in ACTIVE_CMD: 555 | convType = ACTIVE_CMD["type"] 556 | if convType not in (int, str): 557 | logging.error("Unsupported argument type: %s", convType) 558 | return False 559 | try: 560 | item = convType(item) 561 | except: 562 | self.printErrorArgument(activeArg, item) 563 | return False 564 | 565 | if activeArg in self.INP_DATA: 566 | if "repetitive" in ACTIVE_CMD and (ACTIVE_CMD["repetitive"]): 567 | """ append """ 568 | self.INP_DATA[activeArg] += [item] 569 | else: 570 | """ replace """ 571 | self.INP_DATA[activeArg] = item 572 | else: 573 | """ set """ 574 | if "repetitive" in ACTIVE_CMD and (ACTIVE_CMD["repetitive"]): 575 | self.INP_DATA[activeArg] = [item] 576 | else: 577 | self.INP_DATA[activeArg] = item 578 | 579 | activeArg = ( 580 | activeArgTmp 581 | ) # this is either a new "active" argument or emptyValue. 582 | 583 | # if there are still active arguments 584 | if activeArg is not None: 585 | # if the active argument is emptyValue 586 | if "emptyValue" in ACTIVE_CMD: 587 | self.INP_DATA[activeArg] = ACTIVE_CMD["emptyValue"] 588 | 589 | # An error argument 590 | else: 591 | self.printErrorArgument(activeArg, "") 592 | return False 593 | 594 | # check whether there is a necessary argument request 595 | for arg, params in list(self.CMD_ARGS.items()) + list(self.CMD_FLAGS.items()): 596 | if "required" in params and arg not in self.INP: 597 | self.printErrorReqArgument(arg) 598 | return False 599 | 600 | # trim -- and ->_ 601 | self.INP_DATA = dict( 602 | [key.lstrip("-").replace("-", "_"), val] 603 | for key, val in list(self.INP_DATA.items()) 604 | ) 605 | return self.INP_DATA 606 | 607 | def printAutocomplete(self): 608 | # checking later values 609 | LAST_VAL = self.INP[-1] if self.LVL > 1 else None 610 | PREV_LAST_VAL = self.INP[-2] if self.LVL > 2 else None 611 | ARGS_FLAGS_LIST = list(self.CMD_ARGS.keys()) + list(self.CMD_FLAGS.keys()) 612 | 613 | # print root grid 614 | if self.CMD is None: 615 | self.printGrid(self.CMD_LIST) 616 | 617 | # work with root commands 618 | elif not self.INP: 619 | 620 | # print arguments if a root command is found 621 | if self.CMD in self.CMD_LIST: 622 | self.printGrid(ARGS_FLAGS_LIST) 623 | 624 | # autocomplete for sub-commands 625 | else: 626 | # filter out irrelevant commands 627 | self.printGrid( 628 | [item for item in self.CMD_LIST if item.startswith(self.CMD)] 629 | ) 630 | 631 | # processing arguments 632 | else: 633 | 634 | # filter out arguments that have not been input 635 | if PREV_LAST_VAL in self.CMD_ARGS or LAST_VAL in self.CMD_FLAGS: 636 | self.printGrid( 637 | [item for item in ARGS_FLAGS_LIST if item not in self.INP] 638 | ) 639 | 640 | # autocomplete for part of the command 641 | elif PREV_LAST_VAL not in self.CMD_ARGS: 642 | self.printGrid( 643 | [ 644 | item 645 | for item in ARGS_FLAGS_LIST 646 | if item not in self.INP and item.startswith(LAST_VAL) 647 | ] 648 | ) 649 | 650 | # processing of the arguments 651 | else: 652 | print("") # "Please_input_%s" % INP_ARG.replace('-', '') 653 | 654 | def printGrid(self, list): 655 | out.printLine(" ".join(list)) 656 | 657 | def printErrorCommand(self): 658 | out.printLine('Unexpected command "%s"' % (self.CMD)) 659 | self.printHelp() 660 | 661 | def printErrorReqArgument(self, errorArg): 662 | out.printLine( 663 | 'Not found required argument "%s" ' 664 | 'for command "%s" ' % (errorArg, self.CMD) 665 | ) 666 | self.printHelp() 667 | 668 | def printErrorArgument(self, errorArg, errorVal=None): 669 | if errorVal is None: 670 | out.printLine( 671 | 'Unexpected argument "%s" ' 'for command "%s"' % (errorArg, self.CMD) 672 | ) 673 | else: 674 | out.printLine( 675 | 'Unexpected value "%s" ' 'for argument "%s"' % (errorVal, errorArg) 676 | ) 677 | self.printHelp() 678 | 679 | def printHelp(self): 680 | if self.CMD is None or self.CMD not in self.COMMANDS: 681 | tab = len(max(list(self.COMMANDS.keys()), key=len)) 682 | out.printLine("Available commands:") 683 | for cmd in sorted(self.COMMANDS): 684 | out.printLine( 685 | "%s : %s" % (cmd.rjust(tab, " "), self.COMMANDS[cmd]["help"]) 686 | ) 687 | 688 | else: 689 | 690 | tab = len(max(list(self.CMD_ARGS.keys()) + list(self.CMD_FLAGS.keys()), key=len)) 691 | 692 | out.printLine("Options for: %s" % self.CMD) 693 | out.printLine("Available arguments:") 694 | for arg in self.CMD_ARGS: 695 | out.printLine( 696 | "%s : %s%s" 697 | % ( 698 | arg.rjust(tab, " "), 699 | "[default] " 700 | if "firstArg" in self.COMMANDS[self.CMD] 701 | and self.COMMANDS[self.CMD]["firstArg"] == arg 702 | else "", 703 | self.CMD_ARGS[arg]["help"], 704 | ) 705 | ) 706 | 707 | if self.CMD_FLAGS: 708 | out.printLine("Available flags:") 709 | for flag in self.CMD_FLAGS: 710 | out.printLine( 711 | "%s : %s" % (flag.rjust(tab, " "), self.CMD_FLAGS[flag]["help"]) 712 | ) 713 | -------------------------------------------------------------------------------- /geeknote/config.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | import sys 5 | 6 | # Application path 7 | APP_DIR = os.path.join(os.getenv("HOME") or os.getenv("USERPROFILE"), ".geeknote") 8 | ERROR_LOG = os.path.join(APP_DIR, "error.log") 9 | 10 | ALWAYS_USE_YINXIANG = False # for 印象笔记 (Yìnxiàng bǐjì), set to True 11 | 12 | # !!! DO NOT EDIT !!! >>> 13 | if ( 14 | ALWAYS_USE_YINXIANG 15 | or os.getenv("GEEKNOTE_BASE") == "yinxiang" 16 | or os.path.isfile(os.path.join(APP_DIR, "isyinxiang")) 17 | ): 18 | USER_BASE_URL = "app.yinxiang.com" 19 | else: 20 | USER_BASE_URL = "www.evernote.com" 21 | 22 | USER_STORE_URI = "https://{0}/edam/user".format(USER_BASE_URL) 23 | 24 | CONSUMER_KEY = "skaizer-5314" 25 | CONSUMER_SECRET = "6f4f9183b3120801" 26 | 27 | USER_BASE_URL_SANDBOX = "sandbox.evernote.com" 28 | USER_STORE_URI_SANDBOX = "https://sandbox.evernote.com/edam/user" 29 | CONSUMER_KEY_SANDBOX = "skaizer-1250" 30 | CONSUMER_SECRET_SANDBOX = "ed0fcc0c97c032a5" 31 | # !!! DO NOT EDIT !!! <<< 32 | 33 | # can be one of: UPDATED, CREATED, RELEVANCE, TITLE, UPDATE_SEQUENCE_NUMBER 34 | NOTE_SORT_ORDER = "UPDATED" 35 | 36 | # Evernote config 37 | 38 | try: 39 | IS_IN_TERMINAL = sys.stdin.isatty() 40 | IS_OUT_TERMINAL = sys.stdout.isatty() 41 | except: 42 | IS_IN_TERMINAL = False 43 | IS_OUT_TERMINAL = False 44 | 45 | # Set default system editor 46 | DEF_UNIX_EDITOR = "nano" 47 | DEF_WIN_EDITOR = "notepad.exe" 48 | EDITOR_OPEN = "WRITE" 49 | 50 | REMINDER_NONE = "NONE" 51 | REMINDER_DONE = "DONE" 52 | REMINDER_DELETE = "DELETE" 53 | # Shortcuts have a word and a number of seconds to add to the current time 54 | REMINDER_SHORTCUTS = {"TOMORROW": 86400000, "WEEK": 604800000} 55 | 56 | # Default file extensions for editing markdown and raw ENML, respectively 57 | DEF_NOTE_EXT = [".markdown", ".org"] 58 | # Accepted markdown extensions 59 | MARKDOWN_EXTENSIONS = [".md", ".markdown"] 60 | # Accepted html extensions 61 | HTML_EXTENSIONS = [".html", ".org"] 62 | 63 | DEV_MODE = False 64 | DEBUG = False 65 | 66 | # Url view the note via the web client 67 | NOTE_WEBCLIENT_URL = "https://%service%/Home.action?#n=%s" 68 | # Direct note link https://[service]/shard/[shardId]/nl/[userId]/[noteGuid]/ (see https://dev.evernote.com/doc/articles/note_links.php) 69 | NOTE_LINK = "https://%service%/shard/%s/nl/%s/%s" 70 | 71 | # Date format 72 | DEF_DATE_FORMAT = "%Y-%m-%d" 73 | DEF_DATE_AND_TIME_FORMAT = "%Y-%m-%d %H:%M" 74 | DEF_DATE_RANGE_DELIMITER = "/" 75 | 76 | if DEV_MODE: 77 | USER_STORE_URI = USER_STORE_URI_SANDBOX 78 | CONSUMER_KEY = CONSUMER_KEY_SANDBOX 79 | CONSUMER_SECRET = CONSUMER_SECRET_SANDBOX 80 | USER_BASE_URL = USER_BASE_URL_SANDBOX 81 | APP_DIR = os.path.join( 82 | os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config" 83 | ) 84 | sys.stderr.write("Developer mode: using %s as application directory\n" % APP_DIR) 85 | 86 | ERROR_LOG = os.path.join(APP_DIR, "error.log") 87 | 88 | # validate config 89 | try: 90 | if not os.path.exists(APP_DIR): 91 | os.mkdir(APP_DIR) 92 | except Exception as e: 93 | sys.stdout.write("Cannot create application directory : %s" % APP_DIR) 94 | exit(1) 95 | 96 | if DEV_MODE: 97 | USER_STORE_URI = USER_STORE_URI_SANDBOX 98 | CONSUMER_KEY = CONSUMER_KEY_SANDBOX 99 | CONSUMER_SECRET = CONSUMER_SECRET_SANDBOX 100 | USER_BASE_URL = USER_BASE_URL_SANDBOX 101 | 102 | NOTE_WEBCLIENT_URL = NOTE_WEBCLIENT_URL.replace("%service%", USER_BASE_URL) 103 | NOTE_LINK = NOTE_LINK.replace("%service%", USER_BASE_URL) 104 | -------------------------------------------------------------------------------- /geeknote/editor.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | import sys 5 | import tempfile 6 | from bs4 import BeautifulSoup, NavigableString 7 | import threading 8 | import hashlib 9 | import html2text as html2text 10 | import markdown2 as markdown 11 | from . import tools 12 | from . import out 13 | import re 14 | from . import config 15 | from .storage import Storage 16 | from .log import logging 17 | from xml.sax.saxutils import escape, unescape 18 | 19 | 20 | class EditorThread(threading.Thread): 21 | def __init__(self, editor): 22 | threading.Thread.__init__(self) 23 | self.editor = editor 24 | 25 | def run(self): 26 | self.editor.edit() 27 | 28 | 29 | class Editor(object): 30 | # escape() and unescape() takes care of &, < and >. 31 | 32 | @staticmethod 33 | def getHtmlEscapeTable(): 34 | return {'"': """, "'": "'", "\n": "<br />"} 35 | 36 | @staticmethod 37 | def getHtmlUnescapeTable(): 38 | return dict((v, k) for k, v in list(Editor.getHtmlEscapeTable().items())) 39 | 40 | @staticmethod 41 | def HTMLEscape(text): 42 | return escape(text, Editor.getHtmlEscapeTable()) 43 | 44 | @staticmethod 45 | def HTMLEscapeTag(text): 46 | return escape(text) 47 | 48 | @staticmethod 49 | def HTMLUnescape(text): 50 | return unescape(text, Editor.getHtmlUnescapeTable()) 51 | 52 | @staticmethod 53 | def getImages(contentENML): 54 | """ 55 | Creates a list of image resources to save. 56 | Each has a hash and extension attribute. 57 | """ 58 | soup = BeautifulSoup(contentENML.decode("utf-8"), features='lxml') 59 | imageList = [] 60 | for section in soup.findAll("en-media"): 61 | if "type" in section.attrs and "hash" in section.attrs: 62 | imageType, imageExtension = section["type"].split("/") 63 | if imageType == "image": 64 | imageList.append( 65 | {"hash": section["hash"], "extension": imageExtension} 66 | ) 67 | return imageList 68 | 69 | @staticmethod 70 | def checklistInENMLtoSoup(soup): 71 | """ 72 | Transforms Evernote checklist elements to github `* [ ]` task list style 73 | """ 74 | for section in soup.findAll("en-todo", checked="true"): 75 | section.replace_with("<br />* [x]") 76 | 77 | for section in soup.findAll("en-todo"): 78 | section.replace_with("<br />* [ ]") 79 | 80 | @staticmethod 81 | def ENMLtoText( 82 | contentENML, 83 | format="default", 84 | imageOptions={"saveImages": False}, 85 | imageFilename="", 86 | ): 87 | soup = BeautifulSoup(contentENML, "html.parser") 88 | 89 | if format == "pre": 90 | # 91 | # Expect to find at least one 'pre' section. Otherwise, the note 92 | # was not created using the format='pre' option. In that case, 93 | # revert back the defaults. When found, form the note from the 94 | # first 'pre' section only. The others were added by the user. 95 | # 96 | sections = soup.select("pre") 97 | if len(sections) >= 1: 98 | content = "" 99 | for c in sections[0].contents: 100 | content = "".join((content, c)) 101 | pass 102 | else: 103 | format = "default" 104 | 105 | if format == "default": 106 | # In ENML, each line in paragraph have <div> tag. 107 | for section in soup.find_all("div"): 108 | if not section.br: 109 | section.append(soup.new_tag("br")) 110 | section.unwrap() 111 | 112 | for section in soup.select("li > p"): 113 | section.replace_with(section.contents[0]) 114 | 115 | for section in soup.select("li > br"): 116 | if section.next_sibling: 117 | next_sibling = section.next_sibling.next_sibling 118 | if next_sibling: 119 | if next_sibling.find("li"): 120 | section.extract() 121 | else: 122 | section.extract() 123 | 124 | Editor.checklistInENMLtoSoup(soup) 125 | 126 | # change <en-media> tags to <img> tags 127 | if "saveImages" in imageOptions and imageOptions["saveImages"]: 128 | for section in soup.findAll("en-media"): 129 | if "type" in section.attrs and "hash" in section.attrs: 130 | imageType, imageExtension = section["type"].split("/") 131 | if imageType == "image": 132 | newTag = soup.new_tag("img") 133 | newTag["src"] = "{}-{}.{}".format( 134 | imageOptions["baseFilename"], 135 | section["hash"], 136 | imageExtension, 137 | ) 138 | section.replace_with(newTag) 139 | 140 | # Keep Evernote media elements in html format in markdown so 141 | # they'll stay in place over an edit 142 | for section in soup.find_all("en-media"): 143 | section.replace_with(str(section)) 144 | 145 | content = html2text.html2text(str(soup), "") 146 | 147 | content = re.sub(r" *\n", os.linesep, content) 148 | content = content.replace(chr(160), " ") # no-break space 149 | content = Editor.HTMLUnescape(content) 150 | 151 | return content 152 | 153 | @staticmethod 154 | def wrapENML(contentHTML): 155 | body = ( 156 | '<?xml version="1.0" encoding="UTF-8"?>\n' 157 | '<!DOCTYPE en-note SYSTEM "http://xml.evernote.com/pub/enml2.dtd">\n' 158 | "<en-note>%s</en-note>" % contentHTML 159 | ) 160 | return body 161 | 162 | @staticmethod 163 | def checklistInSoupToENML(soup): 164 | """ 165 | Transforms github style checklists `* [ ]` in the BeautifulSoup tree to ENML. 166 | """ 167 | 168 | checktodo_re = re.compile(r"\[([ x])\]") 169 | 170 | # To be more github compatible, if all elements in a list begin with '[ ]', 171 | # convert them to en-todo evernote elements 172 | for ul in soup.find_all("ul"): 173 | tasks = [] 174 | istodo = True 175 | 176 | for li in ul.find_all("li"): 177 | task = soup.new_tag("div") 178 | todo_tag = soup.new_tag("en-todo") 179 | 180 | reg = checktodo_re.match(li.get_text()) 181 | istodo = istodo and reg 182 | character = reg.group(1) if reg else None 183 | if character == "x": 184 | todo_tag["checked"] = "true" 185 | 186 | task.append(todo_tag) 187 | if reg: 188 | task.append(NavigableString(li.get_text()[3:].strip())) 189 | tasks.append(task) 190 | 191 | if istodo: 192 | for task in tasks[::-1]: 193 | ul.insert_after(task) 194 | ul.extract() 195 | 196 | @staticmethod 197 | def textToENML(content, raise_ex=False, format="markdown", rawmd=False): 198 | """ 199 | Transform formatted text to ENML 200 | """ 201 | 202 | if not isinstance(content, str): 203 | content = "" 204 | try: 205 | # add 2 space before new line in paragraph for creating br tags 206 | content = re.sub(r"([^\r\n])([\r\n])([^\r\n])", r"\1 \n\3", content) 207 | # content = re.sub(r'\r\n', '\n', content) 208 | 209 | if format == "pre": 210 | # For the 'pre' format, simply wrap the content with a 'pre' tag. 211 | # Do not perform any further parsing/mutation. 212 | contentHTML = "".join(("<pre>", content, "</pre>")).encode("utf-8") 213 | elif format == "markdown": 214 | # Markdown format https://daringfireball.net/projects/markdown/basics 215 | extras = None 216 | 217 | if not rawmd: 218 | storage = Storage() 219 | extras = storage.getUserprop("markdown2_extras") 220 | content = Editor.HTMLEscapeTag(content) 221 | 222 | contentHTML = markdown.markdown(content, extras=extras) 223 | 224 | soup = BeautifulSoup(contentHTML, "html.parser") 225 | Editor.checklistInSoupToENML(soup) 226 | contentHTML = str(soup) 227 | elif format == "html": 228 | # Html to ENML http://dev.evernote.com/doc/articles/enml.php 229 | soup = BeautifulSoup(content, "html.parser") 230 | ATTR_2_REMOVE = [ 231 | "id", 232 | "class", 233 | # "on*", 234 | "accesskey", 235 | "data", 236 | "dynsrc", 237 | "tabindex", 238 | ] 239 | 240 | for tag in soup.findAll(): 241 | if hasattr(tag, "attrs"): 242 | for k in list(tag.attrs.keys()): 243 | if k in ATTR_2_REMOVE or k.find("on") == 0: 244 | tag.attrs.pop(k, None) 245 | contentHTML = str(soup) 246 | else: 247 | # Plain text format 248 | contentHTML = Editor.HTMLEscape(content) 249 | 250 | tmpstr = "" 251 | for l in contentHTML.split("\n"): 252 | if l == "": 253 | tmpstr = tmpstr + "<div><br/></div>" 254 | else: 255 | tmpstr = tmpstr + "<div>" + l + "</div>" 256 | 257 | contentHTML = tmpstr.encode("utf-8") 258 | contentHTML = contentHTML.replace( 259 | "[x]", '<en-todo checked="true"></en-todo>' 260 | ) 261 | contentHTML = contentHTML.replace("[ ]", "<en-todo></en-todo>") 262 | 263 | return Editor.wrapENML(contentHTML) 264 | 265 | except: 266 | import traceback 267 | 268 | traceback.print_exc() 269 | if raise_ex: 270 | raise Exception("Error while parsing text to html.") 271 | logging.error("Error while parsing text to html.") 272 | out.failureMessage("Error while parsing text to html.") 273 | return tools.exitErr() 274 | 275 | def __init__(self, editor, content, noteExtension, raw=False): 276 | if not isinstance(content, str): 277 | raise Exception( 278 | "Note content must be an instance " 279 | "of string, '%s' given." % type(content) 280 | ) 281 | 282 | if not noteExtension: 283 | noteExtension = config.DEF_NOTE_EXT 284 | (tempfileHandler, tempfileName) = tempfile.mkstemp(suffix=noteExtension) 285 | content = content if raw else self.ENMLtoText(content) 286 | os.write(tempfileHandler, content.encode('utf-8')) 287 | os.close(tempfileHandler) 288 | 289 | self.content = content 290 | self.tempfile = tempfileName 291 | self.editor = editor 292 | 293 | def getTempfileChecksum(self): 294 | with open(self.tempfile, "rb") as fileHandler: 295 | checksum = hashlib.md5() 296 | while True: 297 | data = fileHandler.read(8192) 298 | if not data: 299 | break 300 | checksum.update(data) 301 | 302 | return checksum.hexdigest() 303 | 304 | def edit(self): 305 | """ 306 | Call the system editor, that types as a default in the system. 307 | Editing goes in markdown format, and then the markdown 308 | converts into HTML, before uploading to Evernote. 309 | """ 310 | 311 | # Try to find default editor in the system. 312 | editor = self.editor 313 | if not editor: 314 | editor = os.environ.get("editor") 315 | 316 | if not editor: 317 | editor = os.environ.get("EDITOR") 318 | 319 | if not editor: 320 | # If default editor is not finded, then use nano as a default. 321 | if sys.platform == "win32": 322 | editor = config.DEF_WIN_EDITOR 323 | else: 324 | editor = config.DEF_UNIX_EDITOR 325 | 326 | # Make a system call to open file for editing. 327 | logging.debug("launch system editor: %s %s" % (editor, self.tempfile)) 328 | 329 | out.preloader.stop() 330 | os.system(editor + " " + self.tempfile) 331 | out.preloader.launch() 332 | newContent = open(self.tempfile, "r").read() 333 | 334 | return newContent 335 | 336 | def deleteTempfile(self): 337 | os.remove(self.tempfile) 338 | -------------------------------------------------------------------------------- /geeknote/gclient.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | From old version API 5 | """ 6 | 7 | from evernote.edam.error.ttypes import EDAMSystemException, EDAMUserException 8 | import evernote.edam.userstore.UserStore as UserStore 9 | import thrift.protocol.TBinaryProtocol as TBinaryProtocol 10 | from thrift.Thrift import TType, TMessageType 11 | from thrift.transport import TTransport 12 | 13 | try: 14 | from thrift.protocol import fastbinary 15 | except: 16 | fastbinary = None 17 | 18 | 19 | class getNoteStoreUrl_args(object): 20 | """ 21 | Attributes: 22 | - authenticationToken 23 | """ 24 | 25 | thrift_spec = (None, (1, TType.STRING, "authenticationToken", None, None)) 26 | 27 | def __init__(self, authenticationToken=None): 28 | self.authenticationToken = authenticationToken 29 | 30 | def read(self, iprot): 31 | if ( 32 | iprot.__class__ == TBinaryProtocol.TBinaryProtocolAccelerated 33 | and isinstance(iprot.trans, TTransport.CReadableTransport) 34 | and self.thrift_spec is not None 35 | and fastbinary is not None 36 | ): 37 | fastbinary.decode_binary( 38 | self, iprot.trans, (self.__class__, self.thrift_spec) 39 | ) 40 | return 41 | iprot.readStructBegin() 42 | while True: 43 | (fname, ftype, fid) = iprot.readFieldBegin() 44 | if ftype == TType.STOP: 45 | break 46 | if fid == 1: 47 | if ftype == TType.STRING: 48 | self.authenticationToken = iprot.readString() 49 | else: 50 | iprot.skip(ftype) 51 | else: 52 | iprot.skip(ftype) 53 | iprot.readFieldEnd() 54 | iprot.readStructEnd() 55 | 56 | def write(self, oprot): 57 | if ( 58 | oprot.__class__ == TBinaryProtocol.TBinaryProtocolAccelerated 59 | and self.thrift_spec is not None 60 | and fastbinary is not None 61 | ): 62 | oprot.trans.write( 63 | fastbinary.encode_binary(self, (self.__class__, self.thrift_spec)) 64 | ) 65 | return 66 | oprot.writeStructBegin("getNoteStoreUrl_args") 67 | if self.authenticationToken is not None: 68 | oprot.writeFieldBegin("authenticationToken", TType.STRING, 1) 69 | oprot.writeString(self.authenticationToken) 70 | oprot.writeFieldEnd() 71 | oprot.writeFieldStop() 72 | oprot.writeStructEnd() 73 | 74 | def validate(self): 75 | return 76 | 77 | def __repr__(self): 78 | L = ["%s=%r" % (key, value) for key, value in self.__dict__.items()] 79 | return "%s(%s)" % (self.__class__.__name__, ", ".join(L)) 80 | 81 | def __eq__(self, other): 82 | return isinstance(other, self.__class__) and self.__dict__ == other.__dict__ 83 | 84 | def __ne__(self, other): 85 | return not (self == other) 86 | 87 | 88 | class getNoteStoreUrl_result(object): 89 | """ 90 | Attributes: 91 | - success 92 | - userException 93 | - systemException 94 | """ 95 | 96 | thrift_spec = ( 97 | (0, TType.STRING, "success", None, None), 98 | ( 99 | 1, 100 | TType.STRUCT, 101 | "userException", 102 | (EDAMUserException, EDAMUserException.thrift_spec), 103 | None, 104 | ), 105 | ( 106 | 2, 107 | TType.STRUCT, 108 | "systemException", 109 | (EDAMSystemException, EDAMSystemException.thrift_spec), 110 | None, 111 | ), 112 | ) 113 | 114 | def __init__(self, success=None, userException=None, systemException=None): 115 | self.success = success 116 | self.userException = userException 117 | self.systemException = systemException 118 | 119 | def read(self, iprot): 120 | if ( 121 | iprot.__class__ == TBinaryProtocol.TBinaryProtocolAccelerated 122 | and isinstance(iprot.trans, TTransport.CReadableTransport) 123 | and self.thrift_spec is not None 124 | and fastbinary is not None 125 | ): 126 | fastbinary.decode_binary( 127 | self, iprot.trans, (self.__class__, self.thrift_spec) 128 | ) 129 | return 130 | iprot.readStructBegin() 131 | while True: 132 | (fname, ftype, fid) = iprot.readFieldBegin() 133 | if ftype == TType.STOP: 134 | break 135 | if fid == 0: 136 | if ftype == TType.STRING: 137 | self.success = iprot.readString() 138 | else: 139 | iprot.skip(ftype) 140 | elif fid == 1: 141 | if ftype == TType.STRUCT: 142 | self.userException = EDAMUserException() 143 | self.userException.read(iprot) 144 | else: 145 | iprot.skip(ftype) 146 | elif fid == 2: 147 | if ftype == TType.STRUCT: 148 | self.systemException = EDAMSystemException() 149 | self.systemException.read(iprot) 150 | else: 151 | iprot.skip(ftype) 152 | else: 153 | iprot.skip(ftype) 154 | iprot.readFieldEnd() 155 | iprot.readStructEnd() 156 | 157 | def write(self, oprot): 158 | if oprot.__class__ == TBinaryProtocol.TBinaryProtocolAccelerated: 159 | if self.thrift_spec is not None and fastbinary is not None: 160 | oprot.trans.write( 161 | fastbinary.encode_binary(self, (self.__class__, self.thrift_spec)) 162 | ) 163 | return 164 | oprot.writeStructBegin("getNoteStoreUrl_result") 165 | if self.success is not None: 166 | oprot.writeFieldBegin("success", TType.STRING, 0) 167 | oprot.writeString(self.success) 168 | oprot.writeFieldEnd() 169 | if self.userException is not None: 170 | oprot.writeFieldBegin("userException", TType.STRUCT, 1) 171 | self.userException.write(oprot) 172 | oprot.writeFieldEnd() 173 | if self.systemException is not None: 174 | oprot.writeFieldBegin("systemException", TType.STRUCT, 2) 175 | self.systemException.write(oprot) 176 | oprot.writeFieldEnd() 177 | oprot.writeFieldStop() 178 | oprot.writeStructEnd() 179 | 180 | def validate(self): 181 | return 182 | 183 | def __repr__(self): 184 | L = ["%s=%r" % (key, value) for key, value in self.__dict__.items()] 185 | return "%s(%s)" % (self.__class__.__name__, ", ".join(L)) 186 | 187 | def __eq__(self, other): 188 | return isinstance(other, self.__class__) and self.__dict__ == other.__dict__ 189 | 190 | def __ne__(self, other): 191 | return not (self == other) 192 | 193 | 194 | class CustomClient(UserStore.Client): 195 | """ 196 | Getting from old version API 197 | """ 198 | 199 | def getNoteStoreUrl(self, authenticationToken): 200 | """ 201 | Returns the URL that should be used to talk to the NoteStore for the 202 | account represented by the provided authenticationToken. 203 | This method isn't needed by most clients, who can retrieve the correct 204 | NoteStore URL from the AuthenticationResult returned from the authenticate 205 | or refreshAuthentication calls. This method is typically only needed 206 | to look up the correct URL for a long-lived session token (e.g. for an 207 | OAuth web service). 208 | 209 | Parameters: 210 | - authenticationToken 211 | """ 212 | self.send_getNoteStoreUrl(authenticationToken) 213 | return self.recv_getNoteStoreUrl() 214 | 215 | def send_getNoteStoreUrl(self, authenticationToken): 216 | self._oprot.writeMessageBegin("getNoteStoreUrl", TMessageType.CALL, self._seqid) 217 | args = getNoteStoreUrl_args() 218 | args.authenticationToken = authenticationToken 219 | args.write(self._oprot) 220 | self._oprot.writeMessageEnd() 221 | self._oprot.trans.flush() 222 | 223 | def recv_getNoteStoreUrl(self,): 224 | (fname, mtype, rseqid) = self._iprot.readMessageBegin() 225 | if mtype == TMessageType.EXCEPTION: 226 | x = UserStore.TApplicationException() 227 | x.read(self._iprot) 228 | self._iprot.readMessageEnd() 229 | raise x 230 | result = getNoteStoreUrl_result() 231 | result.read(self._iprot) 232 | self._iprot.readMessageEnd() 233 | if result.success is not None: 234 | return result.success 235 | if result.userException is not None: 236 | raise result.userException 237 | if result.systemException is not None: 238 | raise result.systemException 239 | raise UserStore.TApplicationException( 240 | UserStore.TApplicationException.MISSING_RESULT, 241 | "getNoteStoreUrl failed: unknown result", 242 | ) 243 | 244 | 245 | GUserStore = UserStore 246 | GUserStore.Client = CustomClient 247 | -------------------------------------------------------------------------------- /geeknote/gnsync.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | import codecs 5 | import os 6 | import argparse 7 | import binascii 8 | import glob 9 | import logging 10 | import re 11 | import hashlib 12 | import binascii 13 | import mimetypes 14 | 15 | import evernote.edam.type.ttypes as Types 16 | from evernote.edam.limits.constants import EDAM_USER_NOTES_MAX 17 | from bs4 import BeautifulSoup 18 | 19 | from . import config 20 | from .geeknote import GeekNote 21 | from .storage import Storage 22 | from .editor import Editor 23 | from . import tools 24 | 25 | # for prototyping... 26 | # refactor should move code depending on these modules elsewhere 27 | import thrift.protocol.TBinaryProtocol as TBinaryProtocol 28 | import thrift.transport.THttpClient as THttpClient 29 | import urllib.parse 30 | import evernote.edam.notestore.NoteStore as NoteStore 31 | 32 | # set default logger (write log to file) 33 | def_logpath = os.path.join(config.APP_DIR, "gnsync.log") 34 | formatter = logging.Formatter("%(asctime)-15s : %(message)s") 35 | handler = logging.FileHandler(def_logpath) 36 | handler.setFormatter(formatter) 37 | 38 | logger = logging.getLogger(__name__) 39 | logger.setLevel(logging.DEBUG) 40 | logger.addHandler(handler) 41 | 42 | # http://en.wikipedia.org/wiki/Unicode_control_characters 43 | CONTROL_CHARS_RE = re.compile("[\x00-\x08\x0e-\x1f\x7f-\x9f]") 44 | 45 | 46 | def remove_control_characters(s): 47 | return CONTROL_CHARS_RE.sub("", s) 48 | 49 | 50 | def log(func): 51 | def wrapper(*args, **kwargs): 52 | try: 53 | return func(*args, **kwargs) 54 | except Exception as e: 55 | logger.exception("%s", str(e)) 56 | 57 | return wrapper 58 | 59 | 60 | @log 61 | def reset_logpath(logpath): 62 | """ 63 | Reset logpath to path from command line 64 | """ 65 | global logger 66 | 67 | if not logpath: 68 | return 69 | 70 | # remove temporary log file if it's empty 71 | if os.path.isfile(def_logpath): 72 | if os.path.getsize(def_logpath) == 0: 73 | os.remove(def_logpath) 74 | 75 | # save previous handlers 76 | handlers = logger.handlers 77 | 78 | # remove old handlers 79 | for handler in handlers: 80 | logger.removeHandler(handler) 81 | 82 | # try to set new file handler 83 | handler = logging.FileHandler(logpath) 84 | handler.setFormatter(formatter) 85 | logger.addHandler(handler) 86 | 87 | 88 | def all_notebooks(sleep_on_ratelimit=False): 89 | geeknote = GeekNote(sleepOnRateLimit=sleep_on_ratelimit) 90 | return [notebook.name for notebook in geeknote.findNotebooks()] 91 | 92 | 93 | def all_linked_notebooks(): 94 | geeknote = GeekNote() 95 | return geeknote.findLinkedNotebooks() 96 | 97 | 98 | class GNSync: 99 | notebook_name = None 100 | path = None 101 | mask = None 102 | twoway = None 103 | download_only = None 104 | nodownsync = None 105 | 106 | notebook_guid = None 107 | all_set = False 108 | sleep_on_ratelimit = False 109 | 110 | @log 111 | def __init__( 112 | self, 113 | notebook_name, 114 | path, 115 | mask, 116 | format, 117 | twoway=False, 118 | download_only=False, 119 | nodownsync=False, 120 | sleep_on_ratelimit=False, 121 | imageOptions={"saveImages": False, "imagesInSubdir": False}, 122 | ): 123 | # check auth 124 | if not Storage().getUserToken(): 125 | raise Exception("Auth error. There is not any oAuthToken.") 126 | 127 | # set path 128 | if not path: 129 | raise Exception("Path to sync directories does not select.") 130 | 131 | if not os.path.exists(path): 132 | raise Exception("Path to sync directories does not exist. %s" % path) 133 | 134 | self.path = path 135 | 136 | # set mask 137 | if not mask: 138 | mask = "*.*" 139 | 140 | self.mask = mask 141 | 142 | # set format 143 | if not format: 144 | format = "plain" 145 | 146 | self.format = format 147 | 148 | if format == "markdown": 149 | self.extension = ".md" 150 | elif format == "html": 151 | self.extension = ".html" 152 | else: 153 | self.extension = ".txt" 154 | 155 | self.twoway = twoway 156 | self.download_only = download_only 157 | self.nodownsync = nodownsync 158 | 159 | logger.info("Sync Start") 160 | 161 | # set notebook 162 | self.notebook_guid, self.notebook_name = self._get_notebook(notebook_name, path) 163 | 164 | # set image options 165 | self.imageOptions = imageOptions 166 | 167 | # all is Ok 168 | self.all_set = True 169 | 170 | self.sleep_on_ratelimit = sleep_on_ratelimit 171 | 172 | @log 173 | def sync(self): 174 | """ 175 | Synchronize files to notes 176 | TODO: add two way sync with meta support 177 | TODO: add specific notebook support 178 | """ 179 | if not self.all_set: 180 | return 181 | 182 | files = self._get_files() 183 | notes = self._get_notes() 184 | 185 | if not self.download_only: 186 | for f in files: 187 | has_note = False 188 | meta = self._parse_meta(self._get_file_content(f["path"])) 189 | title = f["name"] if "title" not in meta else meta["title"].strip() 190 | tags = ( 191 | None 192 | if "tags" not in meta 193 | else meta["tags"].replace("[", "").replace("]", "").split(",") 194 | ) 195 | tags = None if not tags else [x.strip() for x in tags] 196 | meta["tags"] = tags 197 | meta["title"] = title 198 | note = None 199 | 200 | if self.format == "html": 201 | meta["mtime"] = f["mtime"] 202 | note = self._html2note(meta) 203 | 204 | for n in notes: 205 | if title == n.title: 206 | has_note = True 207 | if f["mtime"] > n.updated: 208 | if self.format == "html": 209 | gn = GeekNote(sleepOnRateLimit=self.sleep_on_ratelimit) 210 | note.guid = n.guid 211 | gn.getNoteStore().updateNote(gn.authToken, note) 212 | logger.info('Note "{0}" was updated'.format(note.title)) 213 | else: 214 | self._update_note(f, n, title, meta["content"], tags) 215 | break 216 | 217 | if not has_note: 218 | if self.format == "html": 219 | gn = GeekNote(sleepOnRateLimit=self.sleep_on_ratelimit) 220 | gn.getNoteStore().createNote(gn.authToken, note) 221 | logger.info('Note "{0}" was created'.format(note.title)) 222 | else: 223 | self._create_note(f, title, meta["content"], tags) 224 | 225 | if self.twoway or self.download_only: 226 | for n in notes: 227 | has_file = False 228 | for f in files: 229 | if f["name"] == n.title: 230 | has_file = True 231 | if f["mtime"] < n.updated: 232 | self._update_file(f, n) 233 | break 234 | 235 | if not self.nodownsync: 236 | if not has_file: 237 | self._create_file(n) 238 | 239 | logger.info("Sync Complete") 240 | 241 | @log 242 | def _parse_meta(self, content): 243 | """ 244 | Parse jekyll metadata of note, eg: 245 | --- 246 | layout: post 247 | title: draw uml with emacs 248 | tags: [uml, emacs] 249 | categories: [dev] 250 | --- 251 | and substitute meta from content. 252 | 253 | Caution: meta data will only work in one way 254 | mode! And I will never use two way mode, so 255 | two way sync will need your additional work! 256 | """ 257 | metaBlock = re.compile("---(.*?)---", re.DOTALL) 258 | metaInfo = re.compile("(\w+):\s*?(.*)") 259 | block = metaBlock.search(content) 260 | if block is not None: 261 | info = metaInfo.findall(block.group(0)) 262 | ret = dict(info) 263 | ret["content"] = metaBlock.sub("", content) 264 | return ret 265 | else: 266 | return {"content": content} 267 | 268 | @log 269 | def _html2note(self, meta): 270 | """ 271 | parse html to note 272 | TODO: check if evernote need upload media evertime when update 273 | """ 274 | note = Types.Note() 275 | note.title = meta["title"].strip() if "title" in meta else None 276 | note.tagNames = meta["tags"] 277 | note.created = meta["mtime"] 278 | note.resources = [] 279 | soup = BeautifulSoup(meta["content"], "html.parser") 280 | for tag in soup.findAll("img"): # image support is enough 281 | if "src" in tag.attrs and len(tag.attrs["src"]) > 0: 282 | img = None 283 | with open(tag.attrs["src"], "rb") as f: 284 | img = f.read() 285 | md5 = hashlib.md5() 286 | md5.update(img) 287 | hash = md5.digest() 288 | hexHash = binascii.hexlify(hash) 289 | mime = mimetypes.guess_type(tag["src"])[0] 290 | 291 | data = Types.Data() 292 | data.size = len(img) 293 | data.bodyHash = hash 294 | data.body = img 295 | 296 | resource = Types.Resource() 297 | resource.mime = mime 298 | resource.data = data 299 | 300 | tag.name = "en-media" 301 | tag.attrs["type"] = mime 302 | tag.attrs["hash"] = hexHash 303 | tag.attrs.pop("src", None) 304 | 305 | note.resources.append(resource) 306 | note.notebookGuid = self.notebook_guid 307 | note.content = str(soup) 308 | return note 309 | 310 | @log 311 | def _update_note(self, file_note, note, title=None, content=None, tags=None): 312 | """ 313 | Updates note from file 314 | """ 315 | # content = self._get_file_content(file_note['path']) if content is None else content 316 | try: 317 | tags = tags or note.tagNames 318 | except AttributeError: 319 | tags = None 320 | 321 | result = GeekNote(sleepOnRateLimit=self.sleep_on_ratelimit).updateNote( 322 | guid=note.guid, 323 | title=title or note.title, 324 | content=content or self._get_file_content(file_note["path"]), 325 | tags=tags, 326 | notebook=self.notebook_guid, 327 | ) 328 | 329 | if result: 330 | logger.info('Note "{0}" was updated'.format(note.title)) 331 | else: 332 | raise Exception('Note "{0}" was not updated'.format(note.title)) 333 | 334 | return result 335 | 336 | @log 337 | def _update_file(self, file_note, note): 338 | """ 339 | Updates file from note 340 | """ 341 | GeekNote(sleepOnRateLimit=self.sleep_on_ratelimit).loadNoteContent(note) 342 | content = Editor.ENMLtoText(note.content) 343 | open(file_note["path"], "w").write(content) 344 | updated_seconds = note.updated / 1000.0 345 | os.utime(file_note["path"], (updated_seconds, updated_seconds)) 346 | 347 | @log 348 | def _create_note(self, file_note, title=None, content=None, tags=None): 349 | """ 350 | Creates note from file 351 | """ 352 | 353 | content = content or self._get_file_content(file_note["path"]) 354 | 355 | if content is None: 356 | return 357 | 358 | result = GeekNote(sleepOnRateLimit=self.sleep_on_ratelimit).createNote( 359 | title=title or file_note["name"], 360 | content=content, 361 | notebook=self.notebook_guid, 362 | tags=tags or None, 363 | created=file_note["mtime"], 364 | ) 365 | 366 | if result: 367 | logger.info('Note "{0}" was created'.format(title or file_note["name"])) 368 | else: 369 | raise Exception( 370 | 'Note "{0}" was not' " created".format(title or file_note["name"]) 371 | ) 372 | 373 | return result 374 | 375 | @log 376 | def _create_file(self, note): 377 | """ 378 | Creates file from note 379 | """ 380 | GeekNote(sleepOnRateLimit=self.sleep_on_ratelimit).loadNoteContent(note) 381 | 382 | escaped_title = re.sub(os.sep, "-", note.title) 383 | 384 | # Save images 385 | if "saveImages" in self.imageOptions and self.imageOptions["saveImages"]: 386 | imageList = Editor.getImages(note.content) 387 | if imageList: 388 | if ( 389 | "imagesInSubdir" in self.imageOptions 390 | and self.imageOptions["imagesInSubdir"] 391 | ): 392 | try: 393 | os.mkdir(os.path.join(self.path, escaped_title + "_images")) 394 | except OSError: 395 | # Folder already exists 396 | pass 397 | imagePath = os.path.join( 398 | self.path, escaped_title + "_images", escaped_title 399 | ) 400 | self.imageOptions["baseFilename"] = ( 401 | escaped_title + "_images/" + escaped_title 402 | ) 403 | else: 404 | imagePath = os.path.join(self.path, escaped_title) 405 | self.imageOptions["baseFilename"] = escaped_title 406 | for imageInfo in imageList: 407 | filename = "{}-{}.{}".format( 408 | imagePath, imageInfo["hash"], imageInfo["extension"] 409 | ) 410 | logger.info("Saving image to {}".format(filename)) 411 | binaryHash = binascii.unhexlify(imageInfo["hash"]) 412 | if not GeekNote(sleepOnRateLimit=self.sleep_on_ratelimit).saveMedia( 413 | note.guid, binaryHash, filename 414 | ): 415 | logger.warning("Failed to save image {}".format(filename)) 416 | 417 | content = Editor.ENMLtoText(note.content, self.imageOptions) 418 | path = os.path.join(self.path, escaped_title + self.extension) 419 | open(path, "w").write(content) 420 | updated_seconds = note.updated / 1000.0 421 | os.utime(path, (updated_seconds, updated_seconds)) 422 | return True 423 | 424 | @log 425 | def _get_file_content(self, path): 426 | """ 427 | Get file content. 428 | """ 429 | with codecs.open(path, "r", encoding="utf-8") as f: 430 | content = f.read() 431 | 432 | # strip unprintable characters 433 | content = content.encode("ascii", errors="xmlcharrefreplace") 434 | content = Editor.textToENML(content=content, raise_ex=True, format=self.format) 435 | 436 | if content is None: 437 | logger.warning("File {0}. Content must be " "an UTF-8 encode.".format(path)) 438 | return None 439 | 440 | return content 441 | 442 | @log 443 | def _get_notebook(self, notebook_name, path): 444 | """ 445 | Get notebook guid and name. 446 | Takes default notebook if notebook's name does not select. 447 | """ 448 | notebooks = GeekNote(sleepOnRateLimit=self.sleep_on_ratelimit).findNotebooks() 449 | 450 | if not notebook_name: 451 | notebook_name = os.path.basename(os.path.realpath(path)) 452 | 453 | notebook = [item for item in notebooks if item.name == notebook_name] 454 | guid = None 455 | if notebook: 456 | guid = notebook[0].guid 457 | 458 | if not guid: 459 | notebook = GeekNote( 460 | sleepOnRateLimit=self.sleep_on_ratelimit 461 | ).createNotebook(notebook_name) 462 | 463 | if notebook: 464 | logger.info('Notebook "{0}" was' " created".format(notebook_name)) 465 | else: 466 | raise Exception( 467 | 'Notebook "{0}" was' " not created".format(notebook_name) 468 | ) 469 | 470 | guid = notebook.guid 471 | 472 | return (guid, notebook_name) 473 | 474 | @log 475 | def _get_files(self): 476 | """ 477 | Get files by self.mask from self.path dir. 478 | """ 479 | 480 | file_paths = glob.glob(os.path.join(self.path, self.mask)) 481 | 482 | files = [] 483 | for f in file_paths: 484 | if os.path.isfile(f): 485 | file_name = os.path.basename(f) 486 | file_name = os.path.splitext(file_name)[0] 487 | 488 | mtime = int(os.path.getmtime(f) * 1000) 489 | 490 | files.append({"path": f, "name": file_name, "mtime": mtime}) 491 | 492 | return files 493 | 494 | @log 495 | def _get_notes(self): 496 | """ 497 | Get notes from evernote. 498 | """ 499 | keywords = 'notebook:"{0}"'.format( 500 | tools.strip(self.notebook_name.encode("utf-8")) 501 | ) 502 | return ( 503 | GeekNote(sleepOnRateLimit=self.sleep_on_ratelimit) 504 | .findNotes(keywords, EDAM_USER_NOTES_MAX) 505 | .notes 506 | ) 507 | 508 | 509 | def main(): 510 | try: 511 | parser = argparse.ArgumentParser() 512 | parser.add_argument( 513 | "--path", "-p", action="store", help="Path to synchronize directory" 514 | ) 515 | parser.add_argument( 516 | "--mask", 517 | "-m", 518 | action="store", 519 | help='Mask of files to synchronize. Default is "*.*"', 520 | ) 521 | parser.add_argument( 522 | "--format", 523 | "-f", 524 | action="store", 525 | default="plain", 526 | choices=["plain", "markdown", "html"], 527 | help='The format of the file contents. Default is "plain". Valid values are "plain" "html" and "markdown"', 528 | ) 529 | parser.add_argument( 530 | "--notebook", 531 | "-n", 532 | action="store", 533 | help="Notebook name for synchronize. Default is default notebook unless all is selected", 534 | ) 535 | parser.add_argument( 536 | "--all", 537 | "-a", 538 | action="store_true", 539 | help="Synchronize all notebooks", 540 | default=False, 541 | ) 542 | parser.add_argument( 543 | "--all-linked", action="store_true", help="Get all linked notebooks" 544 | ) 545 | parser.add_argument( 546 | "--logpath", 547 | "-l", 548 | action="store", 549 | help="Path to log file. Default is GeekNoteSync in home dir", 550 | ) 551 | parser.add_argument( 552 | "--two-way", 553 | "-t", 554 | action="store_true", 555 | help="Two-way sync (also download from evernote)", 556 | default=False, 557 | ) 558 | parser.add_argument( 559 | "--download-only", 560 | action="store_true", 561 | help="Only download from evernote; no upload", 562 | default=False, 563 | ) 564 | parser.add_argument( 565 | "--nodownsync", 566 | "-d", 567 | action="store", 568 | help="Sync from Evernote only if the file is already local.", 569 | ) 570 | parser.add_argument( 571 | "--save-images", action="store_true", help="save images along with text" 572 | ) 573 | parser.add_argument( 574 | "--sleep-on-ratelimit", 575 | action="store_true", 576 | help="sleep on being ratelimited", 577 | ) 578 | parser.add_argument( 579 | "--images-in-subdir", 580 | action="store_true", 581 | help="save images in a subdirectory (instead of same directory as file)", 582 | ) 583 | 584 | args = parser.parse_args() 585 | 586 | path = args.path if args.path else "." 587 | mask = args.mask if args.mask else None 588 | format = args.format if args.format else None 589 | notebook = args.notebook if args.notebook else None 590 | logpath = args.logpath if args.logpath else None 591 | twoway = args.two_way 592 | download_only = args.download_only 593 | nodownsync = True if args.nodownsync else False 594 | 595 | # image options 596 | imageOptions = {} 597 | imageOptions["saveImages"] = args.save_images 598 | imageOptions["imagesInSubdir"] = args.images_in_subdir 599 | 600 | reset_logpath(logpath) 601 | 602 | geeknote = GeekNote() 603 | 604 | if args.all_linked: 605 | my_map = {} 606 | for notebook in all_linked_notebooks(): 607 | print("Syncing notebook: " + notebook.shareName) 608 | notebook_url = urllib.parse.urlparse(notebook.noteStoreUrl) 609 | sharedNoteStoreClient = THttpClient.THttpClient(notebook.noteStoreUrl) 610 | sharedNoteStoreProtocol = TBinaryProtocol.TBinaryProtocol( 611 | sharedNoteStoreClient 612 | ) 613 | sharedNoteStore = NoteStore.Client(sharedNoteStoreProtocol) 614 | 615 | sharedAuthResult = sharedNoteStore.authenticateToSharedNotebook( 616 | notebook.shareKey, geeknote.authToken 617 | ) 618 | sharedAuthToken = sharedAuthResult.authenticationToken 619 | sharedNotebook = sharedNoteStore.getSharedNotebookByAuth( 620 | sharedAuthToken 621 | ) 622 | 623 | my_filter = NoteStore.NoteFilter( 624 | notebookGuid=sharedNotebook.notebookGuid 625 | ) 626 | 627 | noteList = sharedNoteStore.findNotes(sharedAuthToken, my_filter, 0, 10) 628 | 629 | print("Found " + str(noteList.totalNotes) + " shared notes.") 630 | 631 | print(noteList.notes) 632 | 633 | filename = notebook.shareName + "-" + noteList.notes[0].title + ".html" 634 | 635 | filename = filename.replace(" ", "-").replace("/", "-") 636 | 637 | content = sharedNoteStore.getNoteContent( 638 | sharedAuthToken, noteList.notes[0].guid 639 | ) 640 | 641 | with open(filename, "w") as f: 642 | f.write(content) 643 | return 644 | 645 | if args.all: 646 | for notebook in all_notebooks(sleep_on_ratelimit=args.sleep_on_ratelimit): 647 | logger.info("Syncing notebook %s", notebook) 648 | escaped_notebook_path = re.sub(os.sep, "-", notebook) 649 | notebook_path = os.path.join(path, escaped_notebook_path) 650 | if not os.path.exists(notebook_path): 651 | os.mkdir(notebook_path) 652 | GNS = GNSync( 653 | notebook, 654 | notebook_path, 655 | mask, 656 | format, 657 | twoway, 658 | download_only, 659 | nodownsync, 660 | sleep_on_ratelimit=args.sleep_on_ratelimit, 661 | imageOptions=imageOptions, 662 | ) 663 | GNS.sync() 664 | else: 665 | GNS = GNSync( 666 | notebook, 667 | path, 668 | mask, 669 | format, 670 | twoway, 671 | download_only, 672 | nodownsync, 673 | sleep_on_ratelimit=args.sleep_on_ratelimit, 674 | imageOptions=imageOptions, 675 | ) 676 | GNS.sync() 677 | 678 | except (KeyboardInterrupt, SystemExit, tools.ExitException): 679 | pass 680 | 681 | except Exception as e: 682 | logger.error(str(e)) 683 | 684 | 685 | if __name__ == "__main__": 686 | main() 687 | -------------------------------------------------------------------------------- /geeknote/log.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import logging 4 | from . import config 5 | 6 | if config.DEBUG: 7 | FORMAT = "%(filename)s %(funcName)s %(lineno)d : %(message)s" 8 | logging.basicConfig(format=FORMAT, level=logging.DEBUG) 9 | else: 10 | FORMAT = "%(asctime)-15s %(module)s %(funcName)s %(lineno)d : %(message)s" 11 | logging.basicConfig(format=FORMAT, filename=config.ERROR_LOG) 12 | -------------------------------------------------------------------------------- /geeknote/oauth.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import http.client 4 | import time 5 | import http.cookies 6 | import uuid 7 | import re 8 | import base64 9 | from urllib.request import getproxies 10 | from urllib.parse import urlencode, unquote 11 | from urllib.parse import urlparse 12 | from lxml import html 13 | 14 | from . import out 15 | from . import tools 16 | from . import config 17 | from .log import logging 18 | 19 | 20 | class OAuthError(Exception): 21 | """Generic OAuth exception""" 22 | 23 | 24 | class GeekNoteAuth(object): 25 | 26 | consumerKey = config.CONSUMER_KEY 27 | consumerSecret = config.CONSUMER_SECRET 28 | 29 | url = { 30 | "base": config.USER_BASE_URL, 31 | "oauth": "/OAuth.action?oauth_token=%s", 32 | "access": "/OAuth.action", 33 | "token": "/oauth", 34 | "login": "/Login.action", 35 | "tfa": "/OTCAuth.action", 36 | } 37 | 38 | cookies = {} 39 | 40 | postData = { 41 | "login": { 42 | "login": "Sign in", 43 | "username": "", 44 | "password": "", 45 | "targetUrl": None, 46 | }, 47 | "access": { 48 | "authorize": "Authorize", 49 | "oauth_token": None, 50 | "oauth_callback": None, 51 | "embed": "false", 52 | "expireMillis": "31536000000", 53 | }, 54 | "tfa": {"code": "", "login": "Sign in"}, 55 | } 56 | 57 | username = None 58 | password = None 59 | tmpOAuthToken = None 60 | verifierToken = None 61 | OAuthToken = None 62 | incorrectLogin = 0 63 | incorrectCode = 0 64 | code = None 65 | 66 | def __init__(self): 67 | try: 68 | proxy = getproxies()["https"] 69 | except KeyError: 70 | proxy = None 71 | if proxy is None: 72 | self._proxy = None 73 | else: 74 | # This assumes that the proxy is given in URL form. 75 | # A little simpler as _parse_proxy in urllib2.py 76 | self._proxy = urlparse(proxy) 77 | 78 | if proxy is None or not self._proxy.username: 79 | self._proxy_auth = None 80 | else: 81 | user_pass = "%s:%s" % ( 82 | urlparse.unquote(self._proxy.username), 83 | urlparse.unquote(self._proxy.password), 84 | ) 85 | self._proxy_auth = { 86 | "Proxy-Authorization": "Basic " + base64.b64encode(user_pass).strip() 87 | } 88 | 89 | def getTokenRequestData(self, **kwargs): 90 | params = { 91 | "oauth_consumer_key": self.consumerKey, 92 | "oauth_signature": self.consumerSecret + "%26", 93 | "oauth_signature_method": "PLAINTEXT", 94 | "oauth_timestamp": str(int(time.time())), 95 | "oauth_nonce": uuid.uuid4().hex, 96 | } 97 | 98 | if kwargs: 99 | params = dict(list(params.items()) + list(kwargs.items())) 100 | 101 | return params 102 | 103 | def loadPage(self, url, uri=None, method="GET", params="", additionalParams=""): 104 | if not url: 105 | logging.error("Request URL undefined") 106 | tools.exitErr() 107 | 108 | if not url.startswith("http"): 109 | url = "https://" + url 110 | urlData = urlparse(url) 111 | if not uri: 112 | url = "%s://%s"%(urlData.scheme, urlData.netloc) 113 | uri = urlData.path + "?" + urlData.query 114 | 115 | # prepare params, append to uri 116 | if params: 117 | params = urlencode(params) + additionalParams 118 | if method == "GET": 119 | uri += ("?" if uri.find("?") == -1 else "&") + params 120 | params = "" 121 | 122 | # insert local cookies in request 123 | headers = { 124 | "Cookie": "; ".join( 125 | [key + "=" + self.cookies[key] for key in list(self.cookies.keys())] 126 | ) 127 | } 128 | 129 | if method == "POST": 130 | headers["Content-type"] = "application/x-www-form-urlencoded" 131 | 132 | if self._proxy is None: 133 | host = urlData.hostname 134 | port = urlData.port 135 | real_host = real_port = None 136 | else: 137 | host = self._proxy.hostname 138 | port = self._proxy.port 139 | real_host = urlData.hostname 140 | real_port = urlData.port 141 | 142 | logging.debug( 143 | "Request URL: %s:/%s > %s # %s", 144 | url, 145 | uri, 146 | unquote(params), 147 | headers["Cookie"], 148 | ) 149 | 150 | conn = http.client.HTTPSConnection(host, port) 151 | 152 | if real_host is not None: 153 | conn.set_tunnel(real_host, real_port, headers=self._proxy_auth) 154 | if config.DEBUG: 155 | conn.set_debuglevel(1) 156 | 157 | conn.request(method, url + uri, params, headers) 158 | response = conn.getresponse() 159 | data = response.read() 160 | conn.close() 161 | 162 | logging.debug("Response : %s > %s", response.status, response.getheaders()) 163 | result = tools.Struct( 164 | status=response.status, 165 | location=response.getheader("location", None), 166 | data=data, 167 | ) 168 | 169 | # update local cookies 170 | sk = http.cookies.SimpleCookie(response.getheader("Set-Cookie", "")) 171 | for key in sk: 172 | self.cookies[key] = sk[key].value 173 | # delete cookies whose content is "deleteme" 174 | for key in list(self.cookies.keys()): 175 | if self.cookies[key] == "deleteme": 176 | del self.cookies[key] 177 | 178 | return result 179 | 180 | def parseResponse(self, data): 181 | data = unquote(data) 182 | return dict(item.split("=", 1) for item in data.split("?")[-1].split("&")) 183 | 184 | def getToken(self): 185 | # use developer token if set, otherwise authorize as usual 186 | import os 187 | 188 | token = os.environ.get("EVERNOTE_DEV_TOKEN") 189 | if token: 190 | return token 191 | else: 192 | out.preloader.setMessage("Authorize...") 193 | self.getTmpOAuthToken() 194 | 195 | self.login() 196 | 197 | out.preloader.setMessage("Allow Access...") 198 | self.allowAccess() 199 | 200 | out.preloader.setMessage("Getting Token...") 201 | self.getOAuthToken() 202 | 203 | # out.preloader.stop() 204 | return self.OAuthToken 205 | 206 | def getTmpOAuthToken(self): 207 | response = self.loadPage( 208 | self.url["base"], 209 | self.url["token"], 210 | "GET", 211 | self.getTokenRequestData(oauth_callback="https://" + self.url["base"]), 212 | ) 213 | 214 | if response.status != 200: 215 | logging.error( 216 | "Unexpected response status on get " "temporary oauth_token 200 != %s", 217 | response.status, 218 | ) 219 | raise OAuthError("OAuth token request failed") 220 | 221 | responseData = self.parseResponse(response.data) 222 | if "oauth_token" not in responseData: 223 | logging.error("OAuth temporary not found") 224 | raise OAuthError("OAuth token request failed") 225 | 226 | self.tmpOAuthToken = responseData["oauth_token"] 227 | 228 | logging.debug("Temporary OAuth token : %s", self.tmpOAuthToken) 229 | 230 | def handleTwoFactor(self): 231 | self.code = out.GetUserAuthCode() 232 | self.postData["tfa"]["code"] = self.code 233 | response = self.loadPage( 234 | self.url["base"], 235 | self.url["tfa"] + ";jsessionid=" + self.cookies["JSESSIONID"], 236 | "POST", 237 | self.postData["tfa"], 238 | ) 239 | if not response.location and response.status == 200: 240 | if self.incorrectCode < 3: 241 | out.preloader.stop() 242 | out.printLine("Sorry, incorrect two factor code") 243 | out.preloader.setMessage("Authorize...") 244 | self.incorrectCode += 1 245 | return self.handleTwoFactor() 246 | else: 247 | logging.error("Incorrect two factor code") 248 | 249 | if not response.location: 250 | logging.error("Target URL was not found in the response on login") 251 | tools.exitErr() 252 | 253 | def login(self): 254 | response = self.loadPage( 255 | self.url["base"], 256 | self.url["login"], 257 | "GET", 258 | {"oauth_token": self.tmpOAuthToken}, 259 | ) 260 | 261 | # parse hpts and hptsh from page content 262 | hpts = re.search('.*\("hpts"\)\.value.*?"(.*?)"', response.data) 263 | hptsh = re.search('.*\("hptsh"\)\.value.*?"(.*?)"', response.data) 264 | 265 | if response.status != 200: 266 | logging.error( 267 | "Unexpected response status " "on login 200 != %s", response.status 268 | ) 269 | tools.exitErr() 270 | 271 | if "JSESSIONID" not in self.cookies: 272 | logging.error("Not found value JSESSIONID in the response cookies") 273 | tools.exitErr() 274 | 275 | # get login/password 276 | self.username, self.password = out.GetUserCredentials() 277 | 278 | self.postData["login"]["username"] = self.username 279 | self.postData["login"]["password"] = self.password 280 | self.postData["login"]["targetUrl"] = self.url["oauth"] % self.tmpOAuthToken 281 | self.postData["login"]["hpts"] = hpts and hpts.group(1) or "" 282 | self.postData["login"]["hptsh"] = hptsh and hptsh.group(1) or "" 283 | response = self.loadPage( 284 | self.url["base"], 285 | self.url["login"] + ";jsessionid=" + self.cookies["JSESSIONID"], 286 | "POST", 287 | self.postData["login"], 288 | ) 289 | 290 | if not response.location and response.status == 200: 291 | if self.incorrectLogin < 3: 292 | out.preloader.stop() 293 | out.printLine("Sorry, incorrect login or password") 294 | out.preloader.setMessage("Authorize...") 295 | self.incorrectLogin += 1 296 | return self.login() 297 | else: 298 | logging.error("Incorrect login or password") 299 | 300 | if not response.location: 301 | logging.error("Target URL was not found in the response on login") 302 | tools.exitErr() 303 | 304 | if response.status == 302: 305 | # the user has enabled two factor auth 306 | return self.handleTwoFactor() 307 | 308 | logging.debug("Success authorize, redirect to access page") 309 | 310 | # self.allowAccess(response.location) 311 | 312 | def allowAccess(self): 313 | response = self.loadPage( 314 | self.url["base"], 315 | self.url["access"], 316 | "GET", 317 | {"oauth_token": self.tmpOAuthToken}, 318 | ) 319 | 320 | logging.debug(response.data) 321 | tree = html.fromstring(response.data) 322 | token = ( 323 | "&" 324 | + urlencode( 325 | { 326 | "csrfBusterToken": tree.xpath( 327 | "//input[@name='csrfBusterToken']/@value" 328 | )[0] 329 | } 330 | ) 331 | + "&" 332 | + urlencode( 333 | { 334 | "csrfBusterToken": tree.xpath( 335 | "//input[@name='csrfBusterToken']/@value" 336 | )[1] 337 | } 338 | ) 339 | ) 340 | sourcePage = tree.xpath("//input[@name='_sourcePage']/@value")[0] 341 | fp = tree.xpath("//input[@name='__fp']/@value")[0] 342 | targetUrl = tree.xpath("//input[@name='targetUrl']/@value")[0] 343 | logging.debug(token) 344 | 345 | if response.status != 200: 346 | logging.error( 347 | "Unexpected response status " "on login 200 != %s", response.status 348 | ) 349 | tools.exitErr() 350 | 351 | if "JSESSIONID" not in self.cookies: 352 | logging.error("Not found value JSESSIONID in the response cookies") 353 | tools.exitErr() 354 | 355 | access = self.postData["access"] 356 | access["oauth_token"] = self.tmpOAuthToken 357 | access["oauth_callback"] = "" 358 | access["embed"] = "false" 359 | access["suggestedNotebookName"] = "Geeknote" 360 | access["supportLinkedSandbox"] = "" 361 | access["analyticsLoginOrigin"] = "Other" 362 | access["clipperFlow"] = "false" 363 | access["showSwitchService"] = "true" 364 | access["_sourcePage"] = sourcePage 365 | access["__fp"] = fp 366 | access["targetUrl"] = targetUrl 367 | 368 | response = self.loadPage( 369 | self.url["base"], self.url["access"], "POST", access, token 370 | ) 371 | 372 | if response.status != 302: 373 | logging.error( 374 | "Unexpected response status on allowing " "access 302 != %s", 375 | response.status, 376 | ) 377 | logging.error(response.data) 378 | tools.exitErr() 379 | 380 | responseData = self.parseResponse(response.location) 381 | if "oauth_verifier" not in responseData: 382 | logging.error("OAuth verifier not found") 383 | tools.exitErr() 384 | 385 | self.verifierToken = responseData["oauth_verifier"] 386 | 387 | logging.debug("OAuth verifier token take") 388 | 389 | # self.getOAuthToken(verifier) 390 | 391 | def getOAuthToken(self): 392 | response = self.loadPage( 393 | self.url["base"], 394 | self.url["token"], 395 | "GET", 396 | self.getTokenRequestData( 397 | oauth_token=self.tmpOAuthToken, oauth_verifier=self.verifierToken 398 | ), 399 | ) 400 | 401 | if response.status != 200: 402 | logging.error( 403 | "Unexpected response status on " "getting oauth token 200 != %s", 404 | response.status, 405 | ) 406 | tools.exitErr() 407 | 408 | responseData = self.parseResponse(response.data) 409 | if "oauth_token" not in responseData: 410 | logging.error("OAuth token not found") 411 | tools.exitErr() 412 | 413 | logging.debug("OAuth token take : %s", responseData["oauth_token"]) 414 | self.OAuthToken = responseData["oauth_token"] 415 | -------------------------------------------------------------------------------- /geeknote/out.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import getpass 4 | import threading 5 | import _thread 6 | import time 7 | import datetime 8 | import sys 9 | import os.path 10 | 11 | from .__init__ import __version__ 12 | from . import tools 13 | from . import config 14 | 15 | 16 | def preloaderPause(fn, *args, **kwargs): 17 | def wrapped(*args, **kwargs): 18 | if not preloader.isLaunch: 19 | return fn(*args, **kwargs) 20 | 21 | preloader.stop() 22 | result = fn(*args, **kwargs) 23 | preloader.launch() 24 | 25 | return result 26 | 27 | return wrapped 28 | 29 | 30 | def preloaderStop(fn, *args, **kwargs): 31 | def wrapped(*args, **kwargs): 32 | if not preloader.isLaunch: 33 | return fn(*args, **kwargs) 34 | 35 | preloader.stop() 36 | result = fn(*args, **kwargs) 37 | return result 38 | 39 | return wrapped 40 | 41 | 42 | class preloader(object): 43 | progress = ("> ", ">> ", ">>>", " >>", " >", " ") 44 | clearLine = "\r" + " " * 40 + "\r" 45 | message = None 46 | isLaunch = False 47 | counter = 0 48 | 49 | @staticmethod 50 | def setMessage(message, needLaunch=True): 51 | preloader.message = message 52 | if not preloader.isLaunch and needLaunch: 53 | preloader.launch() 54 | 55 | @staticmethod 56 | def launch(): 57 | if not config.IS_OUT_TERMINAL: 58 | return 59 | preloader.counter = 0 60 | preloader.isLaunch = True 61 | _thread.start_new_thread(preloader.draw, ()) 62 | 63 | @staticmethod 64 | def stop(): 65 | if not config.IS_OUT_TERMINAL: 66 | return 67 | preloader.counter = -1 68 | printLine(preloader.clearLine, "") 69 | preloader.isLaunch = False 70 | 71 | @staticmethod 72 | def exit(code=0): 73 | preloader.stop() 74 | 75 | if threading.current_thread().__class__.__name__ == "_MainThread": 76 | sys.exit(code) 77 | else: 78 | _thread.exit() 79 | 80 | @staticmethod 81 | def draw(): 82 | try: 83 | if not preloader.isLaunch: 84 | return 85 | 86 | while preloader.counter >= 0: 87 | printLine(preloader.clearLine, "") 88 | preloader.counter += 1 89 | printLine( 90 | "%s : %s" 91 | % ( 92 | preloader.progress[preloader.counter % len(preloader.progress)], 93 | preloader.message, 94 | ), 95 | "", 96 | ) 97 | 98 | time.sleep(0.3) 99 | except: 100 | pass 101 | 102 | 103 | def _getCredentialsFromFile(): 104 | # Get evernote credentials from file APP_DIR/credentials 105 | # This is used only for sandbox mode (DEV_MODE=True) for security reasons 106 | if config.DEV_MODE: 107 | creds = os.path.join(config.APP_DIR, "credentials") 108 | if os.path.exists(creds): 109 | credentials = None 110 | # execfile doesn't work reliably for assignments, see python docs 111 | with open(creds, "r") as f: 112 | # this sets "credentials" if correctly formatted 113 | exec(f.read()) 114 | try: 115 | return credentials.split(":") 116 | except: 117 | sys.stderr.write( 118 | """Error reading credentials from %s. 119 | Format should be: 120 | credentials="<username>:<password>:<two-factor auth code>" 121 | 122 | """ 123 | % creds 124 | ) 125 | return None 126 | 127 | 128 | @preloaderPause 129 | def GetUserCredentials(): 130 | """Prompts the user for a username and password.""" 131 | creds = _getCredentialsFromFile() 132 | if creds is not None: 133 | return creds[:2] 134 | 135 | try: 136 | login = None 137 | password = None 138 | if login is None: 139 | login = rawInput("Login: ") 140 | 141 | if password is None: 142 | password = rawInput("Password: ", True) 143 | except (KeyboardInterrupt, SystemExit) as e: 144 | if e.message: 145 | tools.exit(e.message) 146 | else: 147 | tools.exit 148 | 149 | return (login, password) 150 | 151 | 152 | @preloaderPause 153 | def GetUserAuthCode(): 154 | """Prompts the user for a two factor auth code.""" 155 | creds = _getCredentialsFromFile() 156 | if creds is not None: 157 | return creds[2] 158 | 159 | try: 160 | code = None 161 | if code is None: 162 | code = rawInput("Two-Factor Authentication Code: ") 163 | except (KeyboardInterrupt, SystemExit) as e: 164 | if e.message: 165 | tools.exit(e.message) 166 | else: 167 | tools.exit 168 | 169 | return code 170 | 171 | 172 | @preloaderStop 173 | def SearchResult(listItems, request, **kwargs): 174 | """Print search results.""" 175 | printLine("Search request: %s" % request) 176 | printList(listItems, **kwargs) 177 | 178 | 179 | @preloaderStop 180 | def SelectSearchResult(listItems, **kwargs): 181 | """Select a search result.""" 182 | return printList(listItems, showSelector=True, **kwargs) 183 | 184 | 185 | @preloaderStop 186 | def confirm(message): 187 | printLine(message) 188 | try: 189 | while True: 190 | answer = rawInput("Yes/No: ") 191 | if answer.lower() in ["yes", "ye", "y"]: 192 | return True 193 | if answer.lower() in ["no", "n"]: 194 | return False 195 | failureMessage('Incorrect answer "%s", ' "please try again:\n" % answer) 196 | except (KeyboardInterrupt, SystemExit) as e: 197 | if e.message: 198 | tools.exit(e.message) 199 | else: 200 | tools.exit 201 | 202 | 203 | @preloaderStop 204 | def showNote(note, id, shardId): 205 | separator("#", "URL") 206 | printLine("NoteLink: " + (config.NOTE_LINK % (shardId, id, note.guid))) 207 | printLine("WebClientURL: " + (config.NOTE_WEBCLIENT_URL % note.guid)) 208 | separator("#", "TITLE") 209 | printLine(note.title) 210 | separator("=", "META") 211 | printLine("Notebook: %s" % note.notebookName) 212 | printLine("Created: %s" % printDate(note.created)) 213 | printLine("Updated: %s" % printDate(note.updated)) 214 | for key, value in list(note.attributes.__dict__.items()): 215 | if value and key not in ("reminderOrder", "reminderTime", "reminderDoneTime"): 216 | printLine("%s: %s" % (key, value)) 217 | separator("|", "REMINDERS") 218 | printLine("Order: %s" % str(note.attributes.reminderOrder)) 219 | printLine("Time: %s" % printDate(note.attributes.reminderTime)) 220 | printLine("Done: %s" % printDate(note.attributes.reminderDoneTime)) 221 | separator("-", "CONTENT") 222 | if note.tagNames: 223 | printLine("Tags: %s" % ", ".join(note.tagNames)) 224 | 225 | from .editor import Editor 226 | 227 | printLine(Editor.ENMLtoText(note.content)) 228 | 229 | 230 | @preloaderStop 231 | def showNoteRaw(note): 232 | from .editor import Editor 233 | 234 | printLine(Editor.ENMLtoText(note.content, "pre")) 235 | 236 | 237 | @preloaderStop 238 | def showUser(user, fullInfo): 239 | separator("#", "USER INFO") 240 | colWidth = 17 241 | printLine("%s: %s" % ("Username".ljust(colWidth, " "), user.username)) 242 | printLine("%s: %s" % ("Name".ljust(colWidth, " "), user.name)) 243 | printLine("%s: %s" % ("Email".ljust(colWidth, " "), user.email)) 244 | 245 | if fullInfo: 246 | printLine( 247 | "%s: %.2f MB" 248 | % ( 249 | "Upload limit".ljust(colWidth, " "), 250 | (int(user.accounting.uploadLimit) / 1024 / 1024), 251 | ) 252 | ) 253 | printLine( 254 | "%s: %s" 255 | % ( 256 | "Upload limit end".ljust(colWidth, " "), 257 | printDate(user.accounting.uploadLimitEnd), 258 | ) 259 | ) 260 | printLine("%s: %s" % ("Timezone".ljust(colWidth, " "), user.timezone)) 261 | 262 | 263 | @preloaderStop 264 | def successMessage(message): 265 | """ Displaying a message. """ 266 | printLine(message, "\n") 267 | 268 | 269 | @preloaderStop 270 | def failureMessage(message): 271 | """ Displaying a message.""" 272 | printLine(message, "\n", sys.stderr) 273 | 274 | 275 | def separator(symbol="", title=""): 276 | size = 40 277 | if title: 278 | sw = int((size - len(title) + 2) / 2) 279 | printLine( 280 | "%s %s %s" % (symbol * sw, title, symbol * (sw - (len(title) + 1) % 2)) 281 | ) 282 | 283 | else: 284 | printLine(symbol * size + "\n") 285 | 286 | 287 | @preloaderStop 288 | def printList( 289 | listItems, 290 | title="", 291 | showSelector=False, 292 | showByStep=0, 293 | showUrl=False, 294 | showTags=False, 295 | showNotebook=False, 296 | showGUID=False, 297 | ): 298 | 299 | if title: 300 | separator("=", title) 301 | 302 | total = len(listItems) 303 | printLine("Found %d item%s" % (total, ("s" if total != 1 else ""))) 304 | for key, item in enumerate(listItems): 305 | key += 1 306 | 307 | printLine( 308 | "%s : %s%s%s%s%s%s" 309 | % ( 310 | item.guid 311 | if showGUID and hasattr(item, "guid") 312 | else str(key).rjust(3, " "), 313 | printDate(item.created).ljust(11, " ") 314 | if hasattr(item, "created") 315 | else "", 316 | printDate(item.updated).ljust(11, " ") 317 | if hasattr(item, "updated") 318 | else "", 319 | item.notebookName.ljust(18, " ") 320 | if showNotebook and hasattr(item, "notebookName") 321 | else "", 322 | item.title 323 | if hasattr(item, "title") 324 | else item.name 325 | if hasattr(item, "name") 326 | else item.shareName, 327 | "".join([" #" + s for s in item.tagGuids]) 328 | if showTags and hasattr(item, "tagGuids") and item.tagGuids 329 | else "", 330 | " " + (">>> " + config.NOTE_WEBCLIENT_URL % item.guid) 331 | if showUrl 332 | else "", 333 | ) 334 | ) 335 | 336 | if showByStep != 0 and key % showByStep == 0 and key < total: 337 | printLine("-- More --", "\r") 338 | tools.getch() 339 | printLine(" " * 12, "\r") 340 | 341 | if showSelector: 342 | printLine(" 0 : -Cancel-") 343 | try: 344 | while True: 345 | num = rawInput(": ") 346 | if tools.checkIsInt(num) and 1 <= int(num) <= total: 347 | return listItems[int(num) - 1] 348 | if num == "0" or num == "q": 349 | exit(1) 350 | failureMessage('Incorrect number "%s", ' "please try again:\n" % num) 351 | except (KeyboardInterrupt, SystemExit) as e: 352 | if e.message: 353 | tools.exit(e.message) 354 | else: 355 | tools.exit 356 | 357 | 358 | def rawInput(message, isPass=False): 359 | if isPass: 360 | data = getpass.getpass(message) 361 | else: 362 | data = input(message) 363 | return tools.stdinEncode(data) 364 | 365 | 366 | # return a timezone-localized formatted representation of a UTC timestamp 367 | def printDate(timestamp): 368 | if timestamp is None: 369 | return "None" 370 | else: 371 | return datetime.datetime.fromtimestamp(timestamp / 1000).strftime( 372 | config.DEF_DATE_FORMAT 373 | ) 374 | 375 | 376 | def printLine(line, endLine="\n", out=None): 377 | # "out = sys.stdout" makes it hard to mock 378 | if out is None: 379 | out = sys.stdout 380 | 381 | try: 382 | line = line.decode() 383 | except (UnicodeDecodeError, AttributeError): 384 | pass 385 | 386 | message = line + endLine 387 | message = tools.stdoutEncode(message) 388 | try: 389 | out.write(message) 390 | except: 391 | pass 392 | out.flush() 393 | 394 | 395 | def printAbout(): 396 | printLine("Version: %s" % __version__) 397 | printLine("Geeknote - a command line client for Evernote.") 398 | printLine("Use geeknote --help to read documentation.") 399 | -------------------------------------------------------------------------------- /geeknote/storage.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | import datetime 5 | import pickle 6 | 7 | from sqlalchemy import * 8 | from sqlalchemy.orm import * 9 | from sqlalchemy.ext.declarative import declarative_base 10 | from sqlalchemy.orm import sessionmaker 11 | 12 | import logging 13 | from . import config 14 | 15 | db_path = os.path.join(config.APP_DIR, "database.db") 16 | engine = create_engine("sqlite:///" + db_path) 17 | Base = declarative_base() 18 | 19 | 20 | class Userprop(Base): 21 | __tablename__ = "user_props" 22 | 23 | id = Column(Integer, primary_key=True) 24 | key = Column(String(255)) 25 | value = Column(PickleType()) 26 | 27 | def __init__(self, key, value): 28 | self.key = key 29 | self.value = value 30 | 31 | def __repr__(self): 32 | return "<Userprop('{0}','{1})>".format(self.key, self.value) 33 | 34 | 35 | class Setting(Base): 36 | __tablename__ = "settings" 37 | 38 | id = Column(Integer, primary_key=True) 39 | key = Column(String(255)) 40 | value = Column(String(1000)) 41 | 42 | def __init__(self, key, value): 43 | self.key = key 44 | self.value = value 45 | 46 | def __repr__(self): 47 | return "<Setting('{0}','{1})>".format(self.key, self.value) 48 | 49 | 50 | class Notebook(Base): 51 | __tablename__ = "notebooks" 52 | 53 | id = Column(Integer, primary_key=True) 54 | name = Column(String(255)) 55 | guid = Column(String(1000)) 56 | timestamp = Column(DateTime(), nullable=False) 57 | 58 | def __init__(self, guid, name): 59 | self.guid = guid 60 | self.name = name 61 | self.timestamp = datetime.datetime.now() 62 | 63 | def __repr__(self): 64 | return "<Notebook('{0}')>".format(self.name) 65 | 66 | 67 | class Tag(Base): 68 | __tablename__ = "tags" 69 | 70 | id = Column(Integer, primary_key=True) 71 | tag = Column(String(255)) 72 | guid = Column(String(1000)) 73 | timestamp = Column(DateTime(), nullable=False) 74 | 75 | def __init__(self, guid, tag): 76 | self.guid = guid 77 | self.tag = tag 78 | self.timestamp = datetime.datetime.now() 79 | 80 | def __repr__(self): 81 | return "<Tag('{0}')>".format(self.tag) 82 | 83 | 84 | class Note(Base): 85 | __tablename__ = "notes" 86 | 87 | id = Column(Integer, primary_key=True) 88 | guid = Column(String(1000)) 89 | obj = Column(PickleType()) 90 | timestamp = Column(DateTime(), nullable=False) 91 | 92 | def __init__(self, guid, obj): 93 | self.guid = guid 94 | self.obj = obj 95 | self.timestamp = datetime.datetime.now() 96 | 97 | def __repr__(self): 98 | return "<Note('{0}')>".format(self.timestamp) 99 | 100 | 101 | class Search(Base): 102 | __tablename__ = "search" 103 | 104 | id = Column(Integer, primary_key=True) 105 | search_obj = Column(PickleType()) 106 | timestamp = Column(DateTime(), nullable=False) 107 | 108 | def __init__(self, search_obj): 109 | self.search_obj = search_obj 110 | self.timestamp = datetime.datetime.now() 111 | 112 | def __repr__(self): 113 | return "<Search('{0}')>".format(self.timestamp) 114 | 115 | 116 | class Storage(object): 117 | """ 118 | Class for using database 119 | """ 120 | 121 | session = None 122 | 123 | def __init__(self): 124 | logging.debug("Storage engine : %s", engine) 125 | Base.metadata.create_all(engine) 126 | Session = sessionmaker(bind=engine) 127 | self.session = Session() 128 | 129 | def logging(func): 130 | def wrapper(*args, **kwargs): 131 | try: 132 | return func(*args, **kwargs) 133 | except Exception as e: 134 | logging.error("%s : %s", func.__name__, str(e)) 135 | return False 136 | 137 | return wrapper 138 | 139 | @logging 140 | def createUser(self, oAuthToken, info_obj): 141 | """ 142 | Create a user 143 | oAuthToken must be not empty string 144 | info_obj must be not empty string 145 | Previous user and user's properties will be removed 146 | returns True if all done 147 | """ 148 | if not oAuthToken: 149 | raise Exception("Empty oAuth token") 150 | 151 | if not info_obj: 152 | raise Exception("Empty user info") 153 | 154 | for item in self.session.query(Userprop).all(): 155 | self.session.delete(item) 156 | 157 | self.setUserprop("oAuthToken", oAuthToken) 158 | self.setUserprop("info", info_obj) 159 | 160 | return True 161 | 162 | @logging 163 | def removeUser(self): 164 | """ 165 | Remove user 166 | returns True if all done 167 | """ 168 | for item in self.session.query(Userprop).all(): 169 | self.session.delete(item) 170 | self.session.commit() 171 | return True 172 | 173 | @logging 174 | def getUserToken(self): 175 | """ 176 | Get user's oAuth token 177 | returns oAuth token if it exists 178 | returns None if there is no oAuth token yet 179 | """ 180 | return self.getUserprop("oAuthToken") 181 | 182 | @logging 183 | def getUserInfo(self): 184 | """ 185 | Get user's "info" property value 186 | returns info property value if it exists 187 | returns None if there is no info property 188 | """ 189 | return self.getUserprop("info") 190 | 191 | @logging 192 | def getUserprops(self): 193 | """ 194 | Get all user properties 195 | returns list of dict if all done 196 | returns [] if there are not any user properties yet 197 | """ 198 | props = self.session.query(Userprop).all() 199 | return [{item.key: pickle.loads(item.value)} for item in props] 200 | 201 | @logging 202 | def getUserprop(self, key): 203 | """ 204 | Get user property by key 205 | returns property's value if it the property exists 206 | returns None if the property doesn't exist 207 | """ 208 | instance = self.session.query(Userprop).filter_by(key=key).first() 209 | if instance: 210 | return pickle.loads(instance.value) 211 | else: 212 | return None 213 | 214 | @logging 215 | def setUserprop(self, key, value): 216 | """ 217 | Set a single user property 218 | User's property must have key and value 219 | returns True if all done 220 | """ 221 | value = pickle.dumps(value) 222 | 223 | instance = self.session.query(Userprop).filter_by(key=key).first() 224 | if instance: 225 | instance.value = value 226 | else: 227 | instance = Userprop(key, value) 228 | self.session.add(instance) 229 | 230 | self.session.commit() 231 | return True 232 | 233 | @logging 234 | def delUserprop(self, key): 235 | """ 236 | Delete a single user property 237 | User property must have key 238 | returns True if all done 239 | returns False if property didn't exist 240 | """ 241 | instance = self.session.query(Userprop).filter_by(key=key).first() 242 | if instance: 243 | self.session.delete(instance) 244 | return True 245 | return False 246 | 247 | @logging 248 | def setSettings(self, settings): 249 | """ 250 | Set multiple settings 251 | Settings must be an instance dict 252 | returns True if all done 253 | """ 254 | if not isinstance(settings, dict): 255 | raise Exception("Wrong settings") 256 | 257 | for key in list(settings.keys()): 258 | if not settings[key]: 259 | raise Exception("Wrong setting's item") 260 | 261 | instance = self.session.query(Setting).filter_by(key=key).first() 262 | if instance: 263 | instance.value = pickle.dumps(settings[key]) 264 | else: 265 | instance = Setting(key, pickle.dumps(settings[key])) 266 | self.session.add(instance) 267 | 268 | self.session.commit() 269 | return True 270 | 271 | @logging 272 | def getSettings(self): 273 | """ 274 | Get all settings 275 | returns list of dict if all done 276 | returns {} there are not any settings yet 277 | """ 278 | settings = self.session.query(Setting).all() 279 | result = {} 280 | for item in settings: 281 | result[item.key] = pickle.loads(item.value) 282 | return result 283 | 284 | @logging 285 | def setSetting(self, key, value): 286 | """ 287 | Set single setting 288 | Setting must have key and value 289 | returns True if all done 290 | """ 291 | instance = self.session.query(Setting).filter_by(key=key).first() 292 | if instance: 293 | instance.value = value 294 | else: 295 | instance = Setting(key, value) 296 | self.session.add(instance) 297 | 298 | self.session.commit() 299 | return True 300 | 301 | @logging 302 | def getSetting(self, key): 303 | """ 304 | Get a setting by key 305 | returns setting's value if setting exists 306 | returns None if setting doesn't exist 307 | """ 308 | instance = self.session.query(Setting).filter_by(key=key).first() 309 | if instance: 310 | return instance.value 311 | else: 312 | return None 313 | 314 | @logging 315 | def setTags(self, tags): 316 | """ 317 | Set tags 318 | Tags must be an instance of dict 319 | Previous tags items will be removed 320 | returns True if all done 321 | """ 322 | if not isinstance(tags, dict): 323 | raise Exception("Wrong tags") 324 | 325 | for item in self.session.query(Tag).all(): 326 | self.session.delete(item) 327 | 328 | for key in list(tags.keys()): 329 | if not tags[key]: 330 | raise Exception("Wrong tag's item") 331 | 332 | instance = Tag(key, tags[key]) 333 | self.session.add(instance) 334 | 335 | self.session.commit() 336 | return True 337 | 338 | @logging 339 | def getTags(self): 340 | """ 341 | Get all tags 342 | returns list of dicts of tags if all done 343 | returns {} if there are not any tags yet 344 | """ 345 | tags = self.session.query(Tag).all() 346 | result = {} 347 | for item in tags: 348 | result[item.guid] = item.tag 349 | return result 350 | 351 | @logging 352 | def setNotebooks(self, notebooks): 353 | """ 354 | Set notebooks 355 | Notebooks must be an instance of dict 356 | Previous notebooks items will be removed 357 | returns True if all done 358 | """ 359 | if not isinstance(notebooks, dict): 360 | raise Exception("Wrong notebooks") 361 | 362 | for item in self.session.query(Notebook).all(): 363 | self.session.delete(item) 364 | 365 | for key in list(notebooks.keys()): 366 | if not notebooks[key]: 367 | raise Exception("Wrong notebook's item") 368 | 369 | instance = Notebook(key, notebooks[key]) 370 | self.session.add(instance) 371 | 372 | self.session.commit() 373 | return True 374 | 375 | @logging 376 | def getNotebooks(self): 377 | """ 378 | Get all notebooks 379 | returns list of notebooks if all done 380 | returns {} there are not any notebooks yet 381 | """ 382 | notebooks = self.session.query(Notebook).all() 383 | result = {} 384 | for item in notebooks: 385 | result[item.guid] = item.name 386 | return result 387 | 388 | @logging 389 | def setNote(self, obj): 390 | """ 391 | Remembers a note, indexed by GUID 392 | Note can be retrieved by GUID later 393 | returns True 394 | """ 395 | for item in self.session.query(Note).filter(Note.guid == obj.guid).all(): 396 | self.session.delete(item) 397 | 398 | note = pickle.dumps(obj) 399 | instance = Note(obj.guid, note) 400 | self.session.add(instance) 401 | 402 | self.session.commit() 403 | return True 404 | 405 | @logging 406 | def getNote(self, guid): 407 | """ 408 | Get note by GUID 409 | returns note if found by GUID 410 | returns None if note is not found 411 | """ 412 | note = self.session.query(Note).filter(Note.guid == guid).first() 413 | if note: 414 | return pickle.loads(note.obj) 415 | else: 416 | return None 417 | 418 | @logging 419 | def setSearch(self, search_obj): 420 | """ 421 | Remembers a search 422 | Previous searching items will be removed 423 | return True if all done 424 | """ 425 | for item in self.session.query(Search).all(): 426 | self.session.delete(item) 427 | 428 | search = pickle.dumps(search_obj) 429 | instance = Search(search) 430 | self.session.add(instance) 431 | 432 | self.session.commit() 433 | return True 434 | 435 | @logging 436 | def getSearch(self): 437 | """ 438 | Get last search 439 | return last search if it exists 440 | return None there are not any searches yet 441 | """ 442 | search = self.session.query(Search).first() 443 | if search: 444 | return pickle.loads(search.search_obj) 445 | else: 446 | return None 447 | -------------------------------------------------------------------------------- /geeknote/tools.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from . import out 4 | import sys 5 | import time 6 | 7 | 8 | def checkIsInt(value): 9 | try: 10 | int(value) 11 | return True 12 | except ValueError: 13 | return False 14 | 15 | 16 | def getch(): 17 | """ 18 | Pause program until any keyboard key is pressed 19 | (Gets a character from the console without echo) 20 | """ 21 | try: 22 | import msvcrt 23 | 24 | return msvcrt.getch() 25 | 26 | except ImportError: 27 | import sys 28 | import tty 29 | import termios 30 | 31 | fd = sys.stdin.fileno() 32 | old_settings = termios.tcgetattr(fd) 33 | try: 34 | tty.setraw(sys.stdin.fileno()) 35 | ch = sys.stdin.read(1) 36 | finally: 37 | termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) 38 | return ch 39 | 40 | 41 | def strip(data): 42 | if not data: 43 | return data 44 | 45 | if isinstance(data, dict): 46 | items = iter(data.items()) 47 | return dict([[key.strip(" \t\n\r\"'"), val] for key, val in items]) 48 | 49 | if isinstance(data, list): 50 | return [val.strip(" \t\n\r\"'") for val in data] 51 | 52 | if isinstance(data, str): 53 | return data.strip(" \t\n\r\"'") 54 | 55 | raise Exception("Unexpected args type: " "%s. Expect list or dict" % type(data)) 56 | 57 | 58 | class ExitException(Exception): 59 | pass 60 | 61 | 62 | def _exit(message, code): 63 | out.preloader.exit(code) 64 | time.sleep(0.33) 65 | raise ExitException(message) 66 | 67 | 68 | def exit(message="exit", code=0): 69 | _exit(message, code) 70 | 71 | 72 | def exitErr(message="exit", code=1): 73 | _exit(message, code) 74 | 75 | 76 | class Struct: 77 | def __init__(self, **entries): 78 | self.__dict__.update(entries) 79 | 80 | 81 | def decodeArgs(args): 82 | return [stdinEncode(val) for val in args] 83 | 84 | 85 | def stdoutEncode(data): 86 | # this is not for logging output, it is for output from geeknote queries to evernote 87 | if isinstance(sys.stdout.encoding, str) and sys.stdout.encoding != 'utf-8': 88 | return data.encode('utf-8').decode(sys.stdout.encoding) 89 | else: 90 | return data 91 | 92 | 93 | def stdinEncode(data): 94 | if isinstance(sys.stdin.encoding, str) and sys.stdin.encoding.lower() != 'utf-8': 95 | return data.encode(sys.stdin.encoding).decode('utf-8') 96 | else: 97 | return data 98 | -------------------------------------------------------------------------------- /proxy_support.md: -------------------------------------------------------------------------------- 1 | HTTP proxy support for geeknote 2 | =============================== 3 | 4 | I recommend to make this work with virtualenv, to avoid overwriting system files. 5 | The important part is to install in the order **thrift, then evernote, then geeknote**. This will make sure that path search order is correct for thrift. 6 | 7 | ``` 8 | # Download thrift and geeknote 9 | git clone https://github.com/apache/thrift.git 10 | git clone https://github.com/mwilck/geeknote.git 11 | 12 | # create and enter a virtual environment 13 | virtualenv /var/tmp/geeknote 14 | . /var/tmp/geeknote/bin/activate 15 | 16 | # Apply proxy-support patches for thrift 17 | cd thrift 18 | 19 | ## If the patches don't apply, you may need to check out the state that I wrote the patches for: 20 | ## git checkout -b proxy e363a34e63 21 | curl https://issues.apache.org/jira/secure/attachment/12801233/0001-python-THttpClient-Add-support-for-system-proxy-sett.patch | git am 22 | curl https://issues.apache.org/jira/secure/attachment/12801234/0002-Python-THttpClient-Support-proxy-authorization.patch | git am 23 | 24 | # Install thrift from the patched tree 25 | (cd lib/py; python setup.py install) 26 | cd .. 27 | 28 | # Install evernote 29 | pip install evernote 30 | 31 | # Install geeknote 32 | cd geeknote 33 | python setup.py install 34 | ``` 35 | 36 | Now `geeknote login`, `geeknote find`, etc. should work behind a proxy if the `http_proxy` environment variable is correctly set. You can now generate a script to activate the virtual environment: 37 | 38 | ``` 39 | cat >~/bin/geeknote <<\EOF 40 | #! /bin/bash 41 | . /var/tmp/geeknote/bin/activate 42 | exec geeknote "$@" 43 | EOF 44 | chmod a+x ~/bin/geeknote 45 | ``` -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | evernote3 2 | html2text 3 | sqlalchemy 4 | markdown2 5 | thrift 6 | beautifulsoup4 7 | proxyenv 8 | lxml 9 | 10 | # Testing requirements 11 | pytest 12 | pytest-runner 13 | mock 14 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [aliases] 2 | test=pytest 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | import sys 5 | import os 6 | import shutil 7 | import codecs 8 | from setuptools import setup 9 | from setuptools.command.install import install 10 | import traceback 11 | 12 | 13 | def read(fname): 14 | return codecs.open(os.path.join(os.path.dirname(__file__), fname)).read() 15 | 16 | 17 | class full_install(install): 18 | 19 | user_options = install.user_options + [ 20 | ( 21 | "bash-completion-dir=", 22 | None, 23 | "(Linux only) Set bash completion directory (default: /etc/bash_completion.d)", 24 | ), 25 | ( 26 | "zsh-completion-dir=", 27 | None, 28 | "(Linux only) Set zsh completion directory (default: /usr/local/share/zsh/site-functions)", 29 | ), 30 | ] 31 | 32 | def initialize_options(self): 33 | install.initialize_options(self) 34 | self.bash_completion_dir = "/etc/bash_completion.d" 35 | self.zsh_completion_dir = "/usr/local/share/zsh/site-functions" 36 | 37 | def run(self): 38 | if sys.platform.startswith("linux"): 39 | self.install_autocomplete() 40 | install.run(self) 41 | 42 | def install_autocomplete(self): 43 | def copy_autocomplete(src, dst): 44 | try: 45 | if os.path.exists(dst): 46 | shutil.copy(src, dst) 47 | print("copying %s -> %s" % (src, dst)) 48 | except IOError: 49 | print( 50 | "cannot copy autocomplete script %s to %s, got root ?" % (src, dst) 51 | ) 52 | print(traceback.format_exc()) 53 | 54 | print("installing autocomplete") 55 | copy_autocomplete( 56 | "completion/bash_completion/_geeknote", self.bash_completion_dir 57 | ) 58 | copy_autocomplete( 59 | "completion/zsh_completion/_geeknote", self.zsh_completion_dir 60 | ) 61 | 62 | 63 | with open("geeknote/__init__.py") as f: 64 | exec (f.read()) # read __version__ 65 | 66 | setup( 67 | name="geeknote", 68 | version=__version__, 69 | license="GPL", 70 | author="Jeff Kowalski", 71 | # author_email='', 72 | description="Geeknote - is a command line client for Evernote, " 73 | "that can be used on Linux, FreeBSD and OS X.", 74 | long_description=read("README.md"), 75 | url="http://github.com/jeffkowalski/geeknote", 76 | packages=["geeknote"], 77 | classifiers=[ 78 | "Development Status :: 5 - Production/Stable", 79 | "Intended Audience :: End Users/Desktop", 80 | "License :: OSI Approved :: GNU General Public License (GPL)", 81 | "Environment :: Console", 82 | "Natural Language :: English", 83 | "Operating System :: OS Independent", 84 | "Programming Language :: Python", 85 | "Topic :: Utilities", 86 | ], 87 | install_requires=[ 88 | "evernote3", 89 | "html2text", 90 | "sqlalchemy", 91 | "markdown2", 92 | "beautifulsoup4", 93 | "thrift", 94 | "proxyenv", 95 | "lxml", 96 | ], 97 | setup_requires=["pytest-runner"], 98 | tests_require=["mock", "pytest"], 99 | entry_points={ 100 | "console_scripts": [ 101 | "geeknote = geeknote.geeknote:main", 102 | "gnsync = geeknote.gnsync:main", 103 | ] 104 | }, 105 | cmdclass={"install": full_install}, 106 | platforms="Any", 107 | zip_safe=True, 108 | keywords="Evernote, console", 109 | ) 110 | -------------------------------------------------------------------------------- /tests/fixtures/Test Note with non-ascii.xml: -------------------------------------------------------------------------------- 1 | <?xml version="1.0" encoding="UTF-8"?> 2 | <!DOCTYPE en-note SYSTEM "http://xml.evernote.com/pub/enml2.dtd"> 3 | <en-note><div>This is a test note written in the Evernote online editor. it includes some non-ascii characters:<br clear="none"/></div><div><code>—</code> <code>–</code> <code>−</code> € Œ “” ™ œ ž © µ ¶ å õ ý þ ß Ü<br clear="none"/></div></en-note> -------------------------------------------------------------------------------- /tests/helpers.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | from mock import Mock 3 | 4 | 5 | # See https://stackoverflow.com/a/16976500/1299271 6 | class AnyStringWith(str): 7 | """ 8 | Matches with Mock function string arguments that contain a provided 9 | substring. 10 | """ 11 | def __eq__(self, other): 12 | return self in other 13 | 14 | 15 | # See https://stackoverflow.com/a/54838760/1299271 16 | def assert_not_called_with(self, *args, **kwargs): 17 | """ 18 | Asserts that a Mock function was never called with specified arguments. 19 | """ 20 | try: 21 | self.assert_called_with(*args, **kwargs) 22 | except AssertionError: 23 | return 24 | raise AssertionError('Expected %s to not have been called.' % self._format_mock_call_signature(args, kwargs)) 25 | 26 | 27 | Mock.assert_not_called_with = assert_not_called_with 28 | -------------------------------------------------------------------------------- /tests/pseudoedit.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import re 3 | import unittest 4 | 5 | 6 | class pseudoTest(unittest.TestCase): 7 | def test_dummy(self): 8 | self.assertTrue(1) 9 | 10 | 11 | def do_edit(filename, filter=None): 12 | with open(filename, "r") as f: 13 | lines = f.readlines() 14 | with open(filename, "w") as f: 15 | for line in lines: 16 | if filter is not None and not filter.match(line): 17 | f.write(line) 18 | 19 | if __name__ == "__main__": 20 | # delete all lines containing the word "delete" 21 | filter = re.compile(r".*\bdelete\b") 22 | for name in sys.argv[1:]: 23 | do_edit(name, filter=filter) 24 | -------------------------------------------------------------------------------- /tests/test_argparser.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from geeknote.argparser import * 4 | from io import StringIO 5 | import sys 6 | import unittest 7 | 8 | 9 | class testArgparser(unittest.TestCase): 10 | 11 | def setUp(self): 12 | sys.stdout = StringIO() # set fake stdout 13 | 14 | # добавляем тестовые данные 15 | COMMANDS_DICT['testing'] = { 16 | "help": "Create note", 17 | "firstArg": "--test_req_arg", 18 | "arguments": { 19 | "--test_req_arg": {"altName": "-tra", 20 | "help": "Set note title", 21 | "required": True}, 22 | "--test_arg": {"altName": "-ta", 23 | "help": "Add tag to note", 24 | "emptyValue": None}, 25 | "--test_arg2": {"altName": "-ta2", "help": "Add tag to note"}, 26 | }, 27 | "flags": { 28 | "--test_flag": {"altName": "-tf", 29 | "help": "Add tag to note", 30 | "value": True, 31 | "default": False}, 32 | } 33 | } 34 | 35 | def testEmptyCommand(self): 36 | parser = argparser([]) 37 | self.assertFalse(parser.parse(), False) 38 | 39 | def testErrorCommands(self): 40 | parser = argparser(["testing_err", ]) 41 | self.assertFalse(parser.parse(), False) 42 | 43 | def testErrorArg(self): 44 | parser = argparser(["testing", "test_def_val", "--test_arg_err"]) 45 | self.assertEqual(parser.parse(), False) 46 | 47 | def testErrorNoArg(self): 48 | parser = argparser(["testing"]) 49 | self.assertEqual(parser.parse(), False) 50 | 51 | def testErrorReq(self): 52 | parser = argparser(["testing", "--test_arg", "test_val"]) 53 | self.assertEqual(parser.parse(), False) 54 | 55 | def testErrorVal(self): 56 | parser = argparser(["testing", "--test_req_arg", '--test_arg']) 57 | self.assertEqual(parser.parse(), False) 58 | 59 | def testErrorFlag(self): 60 | parser = argparser(["testing", "--test_flag", 'test_val']) 61 | self.assertEqual(parser.parse(), False) 62 | 63 | def testSuccessCommand1(self): 64 | parser = argparser(["testing", "--test_req_arg", "test_req_val", 65 | "--test_flag", "--test_arg", "test_val"]) 66 | self.assertEqual(parser.parse(), {"test_req_arg": "test_req_val", 67 | "test_flag": True, 68 | "test_arg": "test_val"}) 69 | 70 | def testSuccessCommand2(self): 71 | parser = argparser(["testing", "test_req_val", "--test_flag", 72 | "--test_arg", "test_val"]) 73 | self.assertEqual(parser.parse(), {"test_req_arg": "test_req_val", 74 | "test_flag": True, 75 | "test_arg": "test_val"}) 76 | 77 | def testSuccessCommand3(self): 78 | parser = argparser(["testing", "test_def_val"]) 79 | self.assertEqual(parser.parse(), {"test_req_arg": "test_def_val", 80 | "test_flag": False}) 81 | 82 | def testSuccessCommand4(self): 83 | parser = argparser(["testing", "test_def_val", "--test_arg"]) 84 | self.assertEqual(parser.parse(), {"test_req_arg": "test_def_val", 85 | "test_arg": None, 86 | "test_flag": False}) 87 | 88 | def testSuccessCommand5(self): 89 | parser = argparser(["testing", "test_def_val", "--test_arg", 90 | "--test_arg2", "test_arg2_val"]) 91 | self.assertEqual(parser.parse(), {"test_req_arg": "test_def_val", 92 | "test_arg": None, 93 | "test_arg2": "test_arg2_val", 94 | "test_flag": False}) 95 | 96 | def testSuccessShortAttr(self): 97 | parser = argparser(["testing", "test_def_val", "-ta", 98 | "-ta2", "test_arg2_val"]) 99 | self.assertEqual(parser.parse(), {"test_req_arg": "test_def_val", 100 | "test_arg": None, 101 | "test_arg2": "test_arg2_val", 102 | "test_flag": False}) 103 | 104 | def testSuccessShortAttr2(self): 105 | parser = argparser(["testing", "-tra", "test_def_val", "-tf"]) 106 | self.assertEqual(parser.parse(), {"test_req_arg": "test_def_val", 107 | "test_flag": True}) 108 | -------------------------------------------------------------------------------- /tests/test_editor.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from geeknote.editor import Editor 4 | import unittest 5 | 6 | 7 | class testEditor(unittest.TestCase): 8 | 9 | def setUp(self): 10 | self.MD_TEXT = """# Header 1 11 | 12 | ## Header 2 13 | 14 | Line 1 15 | 16 | _Line 2_ 17 | 18 | **Line 3** 19 | 20 | """ 21 | self.HTML_TEXT = """<h1>Header 1</h1> 22 | <h2>Header 2</h2> 23 | <p>Line 1</p> 24 | <p><em>Line 2</em></p> 25 | <p><strong>Line 3</strong></p> 26 | """ 27 | 28 | def test_TextToENML(self): 29 | self.assertEqual(Editor.textToENML(self.MD_TEXT), 30 | Editor.wrapENML(self.HTML_TEXT)) 31 | 32 | def test_ENMLToText(self): 33 | wrapped = Editor.wrapENML(self.HTML_TEXT) 34 | self.assertEqual(Editor.ENMLtoText(wrapped), self.MD_TEXT) 35 | 36 | def test_TODO(self): 37 | MD_TODO = "\n* [ ]item 1\n\n* [x]item 2\n\n* [ ]item 3\n\n" 38 | HTML_TODO = "<div><en-todo></en-todo>item 1</div><div><en-todo checked=\"true\"></en-todo>item 2</div><div><en-todo></en-todo>item 3</div>\n" 39 | self.assertEqual(Editor.textToENML(MD_TODO), 40 | Editor.wrapENML(HTML_TODO)) 41 | 42 | wrapped = Editor.wrapENML(HTML_TODO) 43 | text = Editor.ENMLtoText(wrapped) 44 | self.assertEqual(text, MD_TODO) 45 | 46 | def test_htmlEscape(self): 47 | wrapped = Editor.textToENML(content="<what ever>", format="markdown") 48 | self.assertEqual(wrapped, """<?xml version="1.0" encoding="UTF-8"?> 49 | <!DOCTYPE en-note SYSTEM "http://xml.evernote.com/pub/enml2.dtd"> 50 | <en-note><p><what ever></p> 51 | </en-note>""") 52 | 53 | def test_not_checklist(self): 54 | wrapped = Editor.textToENML(content=" Item head[0]; ", format="markdown") 55 | self.assertEqual(wrapped, """<?xml version="1.0" encoding="UTF-8"?> 56 | <!DOCTYPE en-note SYSTEM "http://xml.evernote.com/pub/enml2.dtd"> 57 | <en-note><p>Item head[0]; </p> 58 | </en-note>""") 59 | 60 | def test_wrapENML_success(self): 61 | text = "test" 62 | result = '''<?xml version="1.0" encoding="UTF-8"?> 63 | <!DOCTYPE en-note SYSTEM "http://xml.evernote.com/pub/enml2.dtd"> 64 | <en-note>test</en-note>''' 65 | self.assertEqual(Editor.wrapENML(text), result) 66 | 67 | def test_wrapENML_without_argument_fail(self): 68 | self.assertRaises(TypeError, Editor.wrapENML) 69 | -------------------------------------------------------------------------------- /tests/test_gclient.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import unittest 4 | 5 | from geeknote.gclient import CustomClient 6 | from geeknote.geeknote import UserStore 7 | 8 | 9 | class testGclient(unittest.TestCase): 10 | 11 | def test_patched_client(self): 12 | self.assertEqual(UserStore.Client, CustomClient) 13 | 14 | def test_patched_client_contain_methods(self): 15 | METHODS = dir(UserStore.Client) 16 | self.assertIn('getNoteStoreUrl', METHODS) 17 | self.assertIn('send_getNoteStoreUrl', METHODS) 18 | self.assertIn('recv_getNoteStoreUrl', METHODS) 19 | -------------------------------------------------------------------------------- /tests/test_geeknote.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import sys 4 | import time 5 | import unittest 6 | from io import StringIO 7 | from geeknote.geeknote import * 8 | from geeknote import tools 9 | from geeknote.editor import Editor 10 | from geeknote.storage import Storage 11 | 12 | 13 | class GeekNoteOver(GeekNote): 14 | def __init__(self): 15 | pass 16 | 17 | def loadNoteContent(self, note): 18 | if "content" not in note.__dict__: 19 | note.content = "note content" 20 | 21 | def updateNote(self, guid=None, **inputData): 22 | # HACK for testing: this assumes that the guid represents a "note" itself 23 | # see check_editWithEditorInThread below 24 | guid.content = inputData["content"] 25 | 26 | 27 | class NotesOver(Notes): 28 | def connectToEvernote(self): 29 | self.evernote = GeekNoteOver() 30 | 31 | 32 | class testNotes(unittest.TestCase): 33 | 34 | @classmethod 35 | def setUpClass(cls): 36 | # Use our trivial "pseudoedit" program as editor to avoid user interaction 37 | cls.storage = Storage() 38 | cls.saved_editor = cls.storage.getUserprop('editor') 39 | cls.storage.setUserprop('editor', sys.executable + " " + 40 | os.path.join(os.path.dirname(os.path.abspath(__file__)), "pseudoedit.py")) 41 | 42 | @classmethod 43 | def tearDownClass(cls): 44 | if cls.saved_editor: 45 | cls.storage.setUserprop('editor', cls.saved_editor) 46 | 47 | def setUp(self): 48 | self.notes = NotesOver() 49 | self.testNote = tools.Struct(title="note title") 50 | # set the timezone for the date tests to work 51 | # this is particularly important on Travis CI, where 52 | # the timezone may not be the same as our dev machine 53 | os.environ['TZ'] = "PST-0800" 54 | time.tzset() 55 | 56 | def test_parseInput1(self): 57 | testData = self.notes._parseInput("title", "test body", ["tag1"], None, None, ["res 1", "res 2"]) 58 | self.assertTrue(isinstance(testData, dict)) 59 | if not isinstance(testData, dict): 60 | return 61 | 62 | self.assertEqual(testData['title'], "title") 63 | self.assertEqual(testData['content'], Editor.textToENML("test body")) 64 | self.assertEqual(testData["tags"], ["tag1"]) 65 | self.assertEqual(testData["resources"], ["res 1", "res 2"]) 66 | 67 | def test_parseInput2(self): 68 | testData = self.notes._parseInput("title", "WRITE", ["tag1", "tag2"], None, None, self.testNote) 69 | self.assertTrue(isinstance(testData, dict)) 70 | if not isinstance(testData, dict): 71 | return 72 | 73 | self.assertEqual(testData['title'], "title") 74 | self.assertEqual( 75 | testData['content'], 76 | "WRITE" 77 | ) 78 | self.assertEqual(testData["tags"], ["tag1", "tag2"]) 79 | 80 | # this doesn't seem to work in Python 3 - need help 81 | def check_editWithEditorInThread(self, txt, expected): 82 | testNote = tools.Struct(title="note title", 83 | content=txt) 84 | # hack to make updateNote work - see above 85 | testNote.guid = testNote 86 | testData = self.notes._parseInput("title", 87 | txt, 88 | ["tag1", "tag2"], 89 | None, None, testNote) 90 | result = self.notes._editWithEditorInThread(testData, testNote) 91 | self.assertEqual(Editor.ENMLtoText(testNote.content), expected) 92 | 93 | # def test_editWithEditorInThread(self): 94 | # txt = "Please do not change this file" 95 | # self.check_editWithEditorInThread(txt, txt + '\n') 96 | 97 | def test_editWithEditorInThread2(self): 98 | txt = "Please delete this line, save, and quit the editor" 99 | self.check_editWithEditorInThread(txt, "\n\n") 100 | 101 | def test_createSearchRequest1(self): 102 | testRequest = self.notes._createSearchRequest( 103 | search="test text", 104 | tags=["tag1"], 105 | notebook="test notebook", 106 | date="2000-01-01", 107 | exact_entry=True, 108 | content_search=True 109 | ) 110 | response = 'notebook:"test notebook" tag:tag1' \ 111 | ' created:19991231T000000Z "test text"' 112 | self.assertEqual(testRequest, response) 113 | 114 | def test_createSearchRequest2(self): 115 | testRequest = self.notes._createSearchRequest( 116 | search="test text", 117 | tags=["tag1", "tag2"], 118 | notebook="notebook1", 119 | date="1999-12-31/2000-12-31", 120 | exact_entry=False, 121 | content_search=False 122 | ) 123 | response = 'notebook:notebook1 tag:tag1' \ 124 | ' tag:tag2 created:19991230T000000Z -created:20001231T000000Z' \ 125 | ' intitle:test text' 126 | self.assertEqual(testRequest, response) 127 | 128 | def testError_createSearchRequest1(self): 129 | # set fake stdout and stderr 130 | self.stdout, sys.stdout = sys.stdout, StringIO() 131 | self.stderr, sys.stderr = sys.stderr, StringIO() 132 | sys.exit = lambda code: code 133 | with self.assertRaises(tools.ExitException): 134 | self.notes._createSearchRequest(search="test text", 135 | date="12-31-1999") 136 | -------------------------------------------------------------------------------- /tests/test_gnsync.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | from mock import patch, ANY, Mock 3 | import os 4 | import unittest 5 | import shutil 6 | from helpers import AnyStringWith 7 | from geeknote.gnsync import remove_control_characters, GNSync 8 | 9 | 10 | class testGnsync(unittest.TestCase): 11 | def setUp(self): 12 | self.test_dir = '/tmp/test_gnsync' 13 | if not os.path.exists(self.test_dir): 14 | os.makedirs(self.test_dir) 15 | 16 | self.given_eng = '\0This is an english\1 sentence. Is it ok?' 17 | self.expected_eng = 'This is an english sentence. Is it ok?' 18 | self.given_kor = '한국\2어입니\3다. 잘 되나요?' 19 | self.expected_kor = '한국어입니다. 잘 되나요?' 20 | self.given_chn = '中\4国的输入。我令\5您着迷?' 21 | self.expected_chn = '中国的输入。我令您着迷?' 22 | self.given_combined = self.expected_combined = """# 제목 23 | 24 | ## 제 1 장 25 | 26 | _한국어 입력입니다. 잘 되나요?_ 27 | 28 | ## 第 2 章 29 | 30 | *中国的输入。我令您着迷?* 31 | 32 | ## Chapter 3 33 | 34 | - May the force be with you! 35 | 36 | """ 37 | 38 | def tearDown(self): 39 | if os.path.exists(self.test_dir): 40 | shutil.rmtree(self.test_dir) 41 | 42 | def test_strip_eng(self): 43 | self.assertEqual(remove_control_characters(self.given_eng), 44 | self.expected_eng) 45 | 46 | def test_strip_kor(self): 47 | self.assertEqual(remove_control_characters(self.given_kor), 48 | self.expected_kor) 49 | 50 | def test_strip_chn(self): 51 | self.assertEqual(remove_control_characters(self.given_chn), 52 | self.expected_chn) 53 | 54 | def test_strip_nochange(self): 55 | self.assertEqual(remove_control_characters(self.given_combined), 56 | self.expected_combined) 57 | 58 | @patch('geeknote.gnsync.logger', autospec=True) 59 | @patch('geeknote.gnsync.GeekNote', autospec=True) 60 | @patch('geeknote.gnsync.Storage', autospec=True) 61 | def test_create_file_with_non_ascii_chars(self, mock_storage, mock_geeknote, mock_logger): 62 | 63 | # Mock GeekNote#loadNoteContent to provide some content with non-ascii 64 | def mock_note_load(note): 65 | with open('tests/fixtures/Test Note with non-ascii.xml', 'r') as f: 66 | note.content = f.read() 67 | note.notebookName = 'testNotebook' 68 | mock_geeknote.return_value.loadNoteContent.side_effect = mock_note_load 69 | 70 | # Mock Storage().getUserToken() so the GNSync constructor doesn't throw 71 | # an exception 72 | mock_storage.return_value.getUserToken.return_value = True 73 | 74 | subject = GNSync('test_notebook', self.test_dir, '*.*', 75 | 'plain', download_only=True) 76 | 77 | mock_note = Mock() 78 | mock_note.guid = '123abc' 79 | mock_note.title = 'Test Note' 80 | 81 | subject._create_file(mock_note) 82 | 83 | # Verify that a file could be created with non-ascii content 84 | mock_logger.exception.assert_not_called_with( 85 | ANY, 86 | AnyStringWith("codec can't decode byte") 87 | ) 88 | 89 | with open(self.test_dir + "/Test Note.txt", 'r', encoding='utf-8') as f: 90 | self.assertIn("œ ž © µ ¶ å õ ý þ ß Ü", f.read()) 91 | -------------------------------------------------------------------------------- /tests/test_out.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys 4 | import os 5 | import time 6 | import unittest 7 | from io import StringIO 8 | from geeknote import __version__ 9 | from geeknote.config import USER_BASE_URL 10 | from geeknote.out import printDate, printLine, printAbout,\ 11 | separator, failureMessage, successMessage, showUser, showNote, \ 12 | printList, SearchResult 13 | from geeknote import out 14 | 15 | 16 | class AccountingStub(object): 17 | uploadLimit = 100 18 | uploadLimitEnd = 1095292800000 19 | 20 | 21 | class UserStub(object): 22 | username = 'testusername' 23 | name = 'testname' 24 | email = 'testemail' 25 | id = 111 26 | shardId = 222 27 | accounting = AccountingStub() 28 | timezone = None 29 | 30 | 31 | class AttributesStub(object): 32 | reminderOrder = None 33 | reminderTime = None 34 | reminderDoneTime = None 35 | 36 | 37 | class NoteStub(object): 38 | title = 'testnote' 39 | created = 1095292800000 40 | updated = 1095292800000 41 | content = '##note content' 42 | tagNames = ['tag1', 'tag2', 'tag3'] 43 | notebookName = 'geeknotes' 44 | guid = 12345 45 | attributes = AttributesStub() 46 | 47 | 48 | class outTestsWithHackedStdout(unittest.TestCase): 49 | 50 | def setUp(self): 51 | # set fake stdout and stderr 52 | self.stdout, sys.stdout = sys.stdout, StringIO() 53 | self.stderr, sys.stderr = sys.stderr, StringIO() 54 | # set the timezone for the date tests to work 55 | # this is particularly important on Travis CI, where 56 | # the timezone may not be the same as our dev machine 57 | os.environ['TZ'] = "PST-0800" 58 | time.tzset() 59 | 60 | def tearDown(self): 61 | sys.stdout = self.stdout 62 | sys.stderr = self.stderr 63 | 64 | def test_print_line(self): 65 | printLine('test') 66 | sys.stdout.seek(0) 67 | self.assertEqual(sys.stdout.read(), 'test\n') 68 | 69 | def test_print_line_other_endline_success(self): 70 | printLine('test', endLine='\n\r') 71 | sys.stdout.seek(0) 72 | self.assertEqual(sys.stdout.read(), 'test\n\r') 73 | 74 | def test_print_about_success(self): 75 | about = '''Version: %s 76 | Geeknote - a command line client for Evernote. 77 | Use geeknote --help to read documentation.\n''' % __version__ 78 | printAbout() 79 | sys.stdout.seek(0) 80 | self.assertEqual(sys.stdout.read(), about) 81 | 82 | def test_separator_with_title_success(self): 83 | line = '------------------- test ------------------\n' 84 | separator(symbol='-', title='test') 85 | sys.stdout.seek(0) 86 | self.assertEqual(sys.stdout.read(), line) 87 | 88 | def test_separator_without_title_success(self): 89 | line = '----------------------------------------\n\n' 90 | separator(symbol='-') 91 | sys.stdout.seek(0) 92 | self.assertEqual(sys.stdout.read(), line) 93 | 94 | def test_separator_empty_args_success(self): 95 | separator() 96 | sys.stdout.seek(0) 97 | self.assertEqual(sys.stdout.read(), '\n\n') 98 | 99 | def test_failure_message_success(self): 100 | sav = sys.stderr 101 | buf = StringIO() 102 | sys.stderr = buf 103 | failureMessage('fail') 104 | sys.stderr = sav 105 | buf.seek(0) 106 | self.assertEqual(buf.read(), 'fail\n') 107 | 108 | def test_success_message_success(self): 109 | successMessage('success') 110 | sys.stdout.seek(0) 111 | self.assertEqual(sys.stdout.read(), 'success\n') 112 | 113 | def test_show_user_without_fullinfo_success(self): 114 | showUser(UserStub(), {}) 115 | info = '''################ USER INFO ################ 116 | Username : testusername 117 | Name : testname 118 | Email : testemail\n''' 119 | sys.stdout.seek(0) 120 | self.assertEqual(sys.stdout.read(), info) 121 | 122 | def test_show_user_with_fullinfo_success(self): 123 | showUser(UserStub(), True) 124 | info = '''################ USER INFO ################ 125 | Username : testusername 126 | Name : testname 127 | Email : testemail 128 | Upload limit : 0.00 MB 129 | Upload limit end : 2004-09-17 130 | Timezone : None\n''' 131 | sys.stdout.seek(0) 132 | self.assertEqual(sys.stdout.read(), info) 133 | 134 | def test_show_note_success(self): 135 | note = '''################### URL ################### 136 | NoteLink: https://www.evernote.com/shard/222/nl/111/12345 137 | WebClientURL: https://www.evernote.com/Home.action?#n=12345 138 | ################## TITLE ################## 139 | testnote 140 | =================== META ================== 141 | Notebook: geeknotes 142 | Created: 2004-09-17 143 | Updated: 2004-09-17 144 | |||||||||||||||| REMINDERS |||||||||||||||| 145 | Order: None 146 | Time: None 147 | Done: None 148 | ----------------- CONTENT ----------------- 149 | Tags: tag1, tag2, tag3 150 | ##note content\n\n\n''' 151 | showNote(NoteStub(), UserStub().id, UserStub().shardId) 152 | sys.stdout.seek(0) 153 | self.assertEqual(sys.stdout.read(), note) 154 | 155 | def test_print_list_without_title_success(self): 156 | notes_list = '''Found 2 items 157 | 1 : 2004-09-17 2004-09-17 testnote 158 | 2 : 2004-09-17 2004-09-17 testnote\n''' 159 | printList([NoteStub() for _ in range(2)]) 160 | sys.stdout.seek(0) 161 | self.assertEqual(sys.stdout.read(), notes_list) 162 | 163 | def test_print_list_with_title_success(self): 164 | notes_list = '''=================== test ================== 165 | Found 2 items 166 | 1 : 2004-09-17 2004-09-17 testnote 167 | 2 : 2004-09-17 2004-09-17 testnote\n''' 168 | printList([NoteStub() for _ in range(2)], title='test') 169 | sys.stdout.seek(0) 170 | self.assertEqual(sys.stdout.read(), notes_list) 171 | 172 | def test_print_list_with_urls_success(self): 173 | notes_list = '''=================== test ================== 174 | Found 2 items 175 | 1 : 2004-09-17 2004-09-17 testnote >>> https://{url}/Home.action?#n=12345 176 | 2 : 2004-09-17 2004-09-17 testnote >>> https://{url}/Home.action?#n=12345 177 | '''.format(url=USER_BASE_URL) 178 | printList([NoteStub() for _ in range(2)], title='test', showUrl=True) 179 | sys.stdout.seek(0) 180 | self.assertEqual(sys.stdout.read(), notes_list) 181 | 182 | def test_print_list_with_selector_success(self): 183 | out.rawInput = lambda x: 2 184 | notes_list = '''=================== test ================== 185 | Found 2 items 186 | 1 : 2004-09-17 2004-09-17 testnote 187 | 2 : 2004-09-17 2004-09-17 testnote 188 | 0 : -Cancel-\n''' 189 | out.printList([NoteStub() for _ in range(2)], title='test', showSelector=True) 190 | sys.stdout.seek(0) 191 | self.assertEqual(sys.stdout.read(), notes_list) 192 | 193 | def test_search_result_success(self): 194 | result = '''Search request: test 195 | Found 2 items 196 | 1 : 2004-09-17 2004-09-17 testnote 197 | 2 : 2004-09-17 2004-09-17 testnote\n''' 198 | SearchResult([NoteStub() for _ in range(2)], 'test') 199 | sys.stdout.seek(0) 200 | self.assertEqual(sys.stdout.read(), result) 201 | 202 | def test_print_date(self): 203 | self.assertEqual(printDate(1095292800000), '2004-09-17') 204 | -------------------------------------------------------------------------------- /tests/test_sandbox.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import unittest 3 | from geeknote import config 4 | from geeknote.geeknote import User, Notebooks, Notes, GeekNote, GeekNoteConnector 5 | from geeknote.storage import Storage 6 | from geeknote.oauth import GeekNoteAuth 7 | from random import SystemRandom 8 | from string import hexdigits 9 | if config.DEV_MODE: 10 | from proxyenv.proxyenv import ProxyFactory 11 | 12 | 13 | # see https://docs.python.org/2.7/library/unittest.html §25.3.6 14 | # http://thecodeship.com/patterns/guide-to-python-function-decorators/ 15 | # (decorator with empty argument list) 16 | def skipUnlessDevMode(): 17 | if config.DEV_MODE: 18 | return lambda x: x 19 | else: 20 | return unittest.skip("Test only active with DEV_MODE=True") 21 | 22 | 23 | class TestSandbox(unittest.TestCase): 24 | 25 | @classmethod 26 | @skipUnlessDevMode() 27 | def setUpClass(cls): 28 | storage = Storage() 29 | 30 | # start out with empty auth token. Save current token to restore it later. 31 | cls.token = storage.getUserToken() 32 | cls.info = storage.getUserInfo() 33 | storage.removeUser() 34 | 35 | # Force reconnection and re-authorization because it's part of our test suite 36 | GeekNoteAuth.cookies = {} 37 | GeekNoteConnector.evernote = None 38 | GeekNote.skipInitConnection = False 39 | cls.storage = Storage() 40 | cls.notes = set() 41 | cls.nbs = set() 42 | cls.notebook = ("Geeknote test %s please delete" % 43 | "".join(SystemRandom().choice(hexdigits) for x in range(12))) 44 | cls.Notes = Notes() 45 | cls.Notebooks = Notebooks() 46 | cls.Geeknote = cls.Notebooks.getEvernote() 47 | 48 | @classmethod 49 | def tearDownClass(cls): 50 | if cls.token: 51 | cls.storage.createUser(cls.token, cls.info) 52 | 53 | def setUp(self): 54 | self.user = User() 55 | self.tag = "geeknote_unittest_1" 56 | 57 | @skipUnlessDevMode() 58 | def test01_userLogin(self): 59 | # This is an implicit test. The GeekNote() call in setUp() will perform 60 | # an automatic login. 61 | self.assertTrue(self.Geeknote.checkAuth()) 62 | 63 | @skipUnlessDevMode() 64 | def test10_createNotebook(self): 65 | self.assertTrue(self.Notebooks.create(self.notebook)) 66 | 67 | @skipUnlessDevMode() 68 | def test15_findNotebook(self): 69 | all = self.Geeknote.findNotebooks() 70 | nb = [nb for nb in all if nb.name == self.notebook] 71 | self.assertTrue(len(nb) == 1) 72 | self.nbs.add(nb[0].guid) 73 | 74 | @skipUnlessDevMode() 75 | def test30_createNote(self): 76 | self.Notes.create("note title 01", 77 | content="""\ 78 | # Sample note 01 79 | This is the note text. 80 | """, 81 | notebook=self.notebook, 82 | tags=self.tag) 83 | 84 | @skipUnlessDevMode() 85 | def test31_findNote(self): 86 | self.Notes.find(notebooks=self.notebook, tags=self.tag) 87 | result = self.storage.getSearch() 88 | self.assertTrue(len(result.notes) == 1) 89 | self.notes.add(result.notes[0].guid) 90 | 91 | @skipUnlessDevMode() 92 | def test90_removeNotes(self): 93 | while self.notes: 94 | self.assertTrue(self.Geeknote.removeNote(self.notes.pop())) 95 | 96 | # EXPECTED FAILURE 97 | # "This function is generally not available to third party applications" 98 | # https://dev.evernote.com/doc/reference/NoteStore.html#Fn_NoteStore_expungeNotebook 99 | @skipUnlessDevMode() 100 | def test95_removeNotebooks(self): 101 | while self.nbs: 102 | # self.assertTrue(self.Geeknote.removeNotebook(self.nbs.pop())) 103 | self.assertRaises(SystemExit, self.Geeknote.removeNotebook, self.nbs.pop()) 104 | 105 | @skipUnlessDevMode() 106 | def test99_userLogout(self): 107 | self.user.logout(force=True) 108 | self.assertFalse(self.Geeknote.checkAuth()) 109 | 110 | 111 | class TestSandboxWithProxy(TestSandbox): 112 | 113 | @classmethod 114 | @skipUnlessDevMode() 115 | def setUpClass(cls): 116 | cls.proxy = ProxyFactory()() 117 | cls.proxy.start() 118 | cls.proxy.wait() 119 | cls.proxy.enter_environment() 120 | super(TestSandboxWithProxy, cls).setUpClass() 121 | 122 | @classmethod 123 | def tearDownClass(cls): 124 | super(TestSandboxWithProxy, cls).tearDownClass() 125 | cls.proxy.leave_environment() 126 | try: 127 | cls.proxy.stop() 128 | except: 129 | pass 130 | -------------------------------------------------------------------------------- /tests/test_storage.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import unittest 4 | from sqlalchemy.engine import create_engine 5 | from sqlalchemy.orm.session import sessionmaker 6 | from geeknote import storage 7 | import pickle 8 | 9 | 10 | def hacked_init(self): 11 | '''Hack for testing''' 12 | engine = create_engine('sqlite:///:memory:', echo=False) 13 | storage.Base.metadata.create_all(engine) 14 | Session = sessionmaker(bind=engine) 15 | self.session = Session() 16 | 17 | 18 | class storageTest(unittest.TestCase): 19 | def setUp(self): 20 | stor = storage.Storage 21 | stor.__init__ = hacked_init 22 | self.storage = stor() 23 | self.otoken = 'testoauthtoken' 24 | self.userinfo = {'email': 'test@mail.com'} 25 | self.tags = {'tag': 1, 'tag2': 2, 'tag3': 'lol'} 26 | self.notebooks = {'notebook': 'mylaptop'} 27 | self.storage.createUser(self.otoken, 28 | self.userinfo) 29 | 30 | def test_create_user_without_token_fail(self): 31 | self.assertFalse(self.storage.createUser(None, self.userinfo)) 32 | 33 | def test_create_user_without_info_fail(self): 34 | self.assertFalse(self.storage.createUser(self.otoken, None)) 35 | 36 | def test_remove_user_success(self): 37 | self.assertTrue(self.storage.removeUser()) 38 | 39 | def test_get_user_token_success(self): 40 | self.assertEqual(self.storage.getUserToken(), self.otoken) 41 | 42 | def test_get_user_info_success(self): 43 | self.assertEqual(self.storage.getUserInfo(), self.userinfo) 44 | 45 | def test_get_user_props_success(self): 46 | props = [{'oAuthToken': 'testoauthtoken'}, 47 | {'info': {'email': 'test@mail.com'}}] 48 | self.assertEqual(self.storage.getUserprops(), props) 49 | 50 | def test_get_user_props_exists_success(self): 51 | self.assertEqual(self.storage.getUserprop('info'), 52 | self.userinfo) 53 | 54 | def test_get_user_prop_not_exists(self): 55 | self.assertFalse(self.storage.getUserprop('some_prop')) 56 | 57 | def test_set_new_user_prop(self): 58 | self.assertFalse(self.storage.getUserprop('kkey')) 59 | self.assertTrue(self.storage.setUserprop('some_key', 'some_value')) 60 | self.assertEqual(self.storage.getUserprop('some_key'), 'some_value') 61 | 62 | def test_set_exists_user_prop(self): 63 | newmail = {'email': 'new_email@mail.com'} 64 | self.assertEqual(self.storage.getUserprop('info'), self.userinfo) 65 | self.assertTrue(self.storage.setUserprop('info', newmail), newmail) 66 | self.assertEqual(self.storage.getUserprop('info'), newmail) 67 | 68 | def test_get_empty_settings(self): 69 | self.assertEqual(self.storage.getSettings(), {}) 70 | 71 | def test_set_settings_success(self): 72 | self.storage.setSettings({'editor': 'vim'}) 73 | self.assertEqual(self.storage.getSettings(), 74 | {'editor': "vim"}) 75 | 76 | def test_set_setting_error_type_fail(self): 77 | self.assertFalse(self.storage.setSettings('editor')) 78 | 79 | def test_set_setting_none_value_fail(self): 80 | self.assertFalse(self.storage.setSettings({'key': None})) 81 | 82 | def test_update_settings_fail(self): 83 | self.storage.setSettings({'editor': 'vim'}) 84 | self.assertTrue(self.storage.setSettings({'editor': 'nano'})) 85 | self.assertEqual(self.storage.getSettings(), 86 | {'editor': 'nano'}) 87 | 88 | def test_get_setting_exist_success(self): 89 | self.storage.setSettings({'editor': 'vim'}) 90 | editor = self.storage.getSetting('editor') 91 | self.assertEqual(pickle.loads(editor), 'vim') 92 | 93 | def test_set_setting_true(self): 94 | editor = 'nano' 95 | self.assertTrue(self.storage.setSetting('editor', editor)) 96 | self.assertEqual(self.storage.getSetting('editor'), editor) 97 | 98 | def test_get_setting_not_exist_fail(self): 99 | self.assertFalse(self.storage.getSetting('editor')) 100 | 101 | def test_set_tags_success(self): 102 | self.assertTrue(self.storage.setTags(self.tags)) 103 | 104 | def test_set_tags_error_type_fail(self): 105 | self.assertFalse(self.storage.setTags('tag')) 106 | 107 | def test_set_tags_none_value_fail(self): 108 | self.assertFalse(self.storage.setTags({'tag': None})) 109 | 110 | def test_get_tags_success(self): 111 | tags = {'tag': '1', 'tag2': '2', 'tag3': 'lol'} 112 | self.assertTrue(self.storage.setTags(self.tags)) 113 | self.assertEqual(self.storage.getTags(), tags) 114 | 115 | def test_replace_tags_success(self): 116 | tags = {'tag': '1', 'tag2': '2', 'tag3': '3'} 117 | self.assertTrue(self.storage.setTags(self.tags)) 118 | self.tags['tag3'] = 3 119 | self.assertTrue(self.storage.setTags(self.tags)) 120 | self.assertEqual(self.storage.getTags(), tags) 121 | 122 | def test_set_notebooks_success(self): 123 | self.assertEqual(self.storage.getNotebooks(), {}) 124 | self.storage.setNotebooks(self.notebooks) 125 | self.assertEqual(self.storage.getNotebooks(), self.notebooks) 126 | 127 | def test_replace_notebooks_success(self): 128 | newnotebooks = {'notebook': 'android'} 129 | self.storage.setNotebooks(self.notebooks) 130 | self.storage.setNotebooks(newnotebooks) 131 | self.assertEqual(self.storage.getNotebooks(), newnotebooks) 132 | 133 | def test_get_empty_search_success(self): 134 | self.assertFalse(self.storage.getSearch()) 135 | 136 | def test_get_search_exists_success(self): 137 | query = 'my query' 138 | self.assertTrue(self.storage.setSearch(query)) 139 | self.assertEqual(self.storage.getSearch(), query) 140 | 141 | def test_set_notebooks_error_type_fail(self): 142 | self.assertFalse(self.storage.setNotebooks('book')) 143 | 144 | def test_set_notebooks_none_value_fail(self): 145 | self.assertFalse(self.storage.setNotebooks({'book': None})) 146 | 147 | def test_set_search_true(self): 148 | self.assertTrue(self.storage.setSearch('my query')) 149 | 150 | 151 | class modelsTest(unittest.TestCase): 152 | def test_rept_userprop(self): 153 | userprop = storage.Userprop(key='test', 154 | value='value') 155 | self.assertEqual(userprop.__repr__(), 156 | "<Userprop('test','value)>") 157 | 158 | def test_repr_setting(self): 159 | setting = storage.Setting(key='test', 160 | value='value') 161 | self.assertEqual(setting.__repr__(), 162 | "<Setting('test','value)>") 163 | 164 | def test_repr_notebook(self): 165 | notebook = storage.Notebook(name='notebook', 166 | guid='testguid') 167 | self.assertEqual(notebook.__repr__(), 168 | "<Notebook('notebook')>") 169 | 170 | def test_repr_tag(self): 171 | tag = storage.Tag(tag='testtag', 172 | guid='testguid') 173 | self.assertEqual(tag.__repr__(), "<Tag('testtag')>") 174 | 175 | def test_repr_search(self): 176 | search = storage.Search(search_obj='query') 177 | self.assertEqual(search.__repr__(), 178 | "<Search('%s')>" % search.timestamp) 179 | -------------------------------------------------------------------------------- /tests/test_tools.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import unittest 4 | from geeknote.tools import checkIsInt, strip, decodeArgs,\ 5 | stdinEncode, stdoutEncode, Struct 6 | 7 | 8 | class testTools(unittest.TestCase): 9 | 10 | def test_check_is_int_success(self): 11 | self.assertTrue(checkIsInt(1)) 12 | 13 | def test_check_is_int_float_success(self): 14 | self.assertTrue(checkIsInt(1.1)) 15 | 16 | def test_check_is_int_false(self): 17 | self.assertTrue(checkIsInt('1')) 18 | 19 | def test_strip_none_data_success(self): 20 | self.assertFalse(strip(None)) 21 | 22 | def test_strip_dict_data_success(self): 23 | data = {'key \t\n\r\"\'': 'test'} 24 | self.assertEqual(strip(data), {'key': 'test'}) 25 | 26 | def test_strip_list_data_success(self): 27 | data = ['key \t\n\r\"\'', 'value \t\n\r\"\''] 28 | self.assertEqual(strip(data), ['key', 'value']) 29 | 30 | def test_strip_str_data_success(self): 31 | data = 'text text text \t\n\r\"\'' 32 | self.assertEqual(strip(data), 'text text text') 33 | 34 | def test_strip_int_data_false(self): 35 | self.assertRaises(Exception, strip, 1) 36 | 37 | def test_struct_success(self): 38 | struct = Struct(key='value') 39 | self.assertEqual(struct.__dict__, {'key': 'value'}) 40 | 41 | # stdinEncode need mock for stdin to test if different encoding is used 42 | # def test_decode_args_success(self): 43 | # result = [1, '2', 'test', '\xc2\xae', 44 | # '\xd1\x82\xd0\xb5\xd1\x81\xd1\x82'] 45 | # self.assertEqual(decodeArgs([1, '2', 'test', '®', 'тест']), result) 46 | 47 | # def test_stdinEncode_success(self): 48 | # self.assertEqual(stdinEncode('тест'), 'тест') 49 | # self.assertEqual(stdinEncode('test'), 'test') 50 | # self.assertEqual(stdinEncode('®'), '®') 51 | # self.assertEqual(stdinEncode(1), 1) 52 | 53 | # def test_stdoutEncode_success(self): 54 | # self.assertEqual(stdoutEncode('тест'), 'тест') 55 | # self.assertEqual(stdoutEncode('test'), 'test') 56 | # self.assertEqual(stdoutEncode('®'), '®') 57 | # self.assertEqual(stdoutEncode(1), 1) 58 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py27 3 | 4 | [pep8] 5 | ignore = E501, E241 6 | max-line-length = 256 7 | 8 | [flake8] 9 | ignore = E501, E241 10 | max-line-length = 256 11 | 12 | [testenv] 13 | deps = 14 | pytest 15 | mock 16 | commands=py.test 17 | --------------------------------------------------------------------------------