├── .gitignore ├── LICENSE.txt ├── Pipfile ├── Pipfile.lock ├── README.md ├── noteboard ├── __init__.py ├── __main__.py ├── __version__.py ├── storage.py └── utils.py ├── screenshot.png └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Tony Chan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | 8 | [packages] 9 | colorama = "*" 10 | 11 | [requires] 12 | python_version = "3.7" 13 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "659ed730999891b17df9f2ab6205680e0346995ba2c519ee5f72687aa3d9e587" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.7" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "colorama": { 20 | "hashes": [ 21 | "sha256:05eed71e2e327246ad6b38c540c4a3117230b19679b875190486ddd2d721422d", 22 | "sha256:f8ac84de7840f5b9c4e3347b3c1eaa50f7e49c2b07596221daec5edaabbd7c48" 23 | ], 24 | "index": "pypi", 25 | "version": "==0.4.1" 26 | } 27 | }, 28 | "develop": {} 29 | } 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

noteboard

2 | 3 |

A taskbook clone written in Python.

4 | 5 |

6 | 7 |

8 | 9 | 10 | 11 |

12 | 13 | 14 | **noteboard** is a mini command-line tool which lets you manage and store your notes & tasks in a tidy and fancy way, right inside your terminal. 15 | 16 | ## Table of Contents 17 | - [Features](#features) 18 | - [Behind the Board](#behind-the-board) 19 | - [Installation](#installation) 20 | - [Source](#source) 21 | - [PyPI](#pypi) 22 | - [Dependencies](#dependencies) 23 | - [Usage](#usage) 24 | - [View board](#view-board) 25 | - [Add item](#add-item) 26 | - [Remove item](#remove-item) 27 | - [Clear board](#clear-board) 28 | - [Tick / Mark / Star item](#tick--mark--star-item) 29 | - [Edit item](#edit-item) 30 | - [Tag item](#tag-item) 31 | - [Assign due date to item](#assign-due-date-to-item) 32 | - [Move item](#move-item) 33 | - [Rename bard](#rename-board) 34 | - [Run item as command](#run-item-as-command) 35 | - [Undo previous actions](#undo-previous-actions) 36 | - [Import board from external JSON file](#import-board-from-external-json-file) 37 | - [Export board data as JSON file](#export-board-data-as-json-file) 38 | - [See historical changes](#see-historical-changes) 39 | - [Configurations](#configurations) 40 | - [Cautions](#cautions) 41 | - [Credit](#credit) 42 | 43 | ## Features 44 | 45 | * Fancy interface ✨ 46 | * Simple & Easy to use 🚀 47 | * Fast as lightning ⚡️ 48 | * Manage notes & tasks in multiple boards 🗒 49 | * Run item as command inside terminal (subprocess) 💨 50 | * Tag item with color and text 🏷 51 | * Import boards from external JSON files & Export boards as JSON files 52 | * Undo multiple actions / changes 53 | * Keep historical states 🕥 54 | * `Gzip` compressed storage 📚 55 | * Configurable through `~/.noteboard.json` 56 | 57 | ## Behind the Board 58 | 59 | The main storage is powered by `shelve`, a Python standard library, which provides a lightweight & persistent file-based database system. 60 | Whereas the "history" system (the one which allows you to undo previous actions), is backed by a `json` file. 61 | 62 | Notably, the storage and the buffer are compressed to `gzip` when it is not being accessed. 63 | This greatly reduces the sizes of the files by more than 50%. 64 | 65 | ## Installation 66 | 67 | Make sure you have Python 3.6 (or higher) installed in your machine. 68 | 69 | **NOTE:** You should remove all the data stored in `` (default: `~/.noteboard/`) every time you install a new version to avoid conflicts. 70 | 71 | ### Source 72 | 73 | ```shell 74 | $ git clone https://github.com/tnychn/noteboard.git 75 | $ cd noteboard 76 | $ python3 setup.py install 77 | ``` 78 | 79 | ### PyPI 80 | 81 | `$ pip3 install noteboard` 82 | 83 | ### Dependencies 84 | 85 | [colorama](https://github.com/tartley/colorama) 86 | 87 | ## Usage 88 | 89 | ```text 90 | Actions: 91 | add [+] Add an item to a board 92 | remove [-] Remove items 93 | clear [x] Clear all items on a/all boards 94 | tick [✓] Tick/Untick an item 95 | mark [!] Mark/Unmark an item 96 | star [*] Star/Unstar an item 97 | edit [~] Edit the text of an item 98 | tag [#] Tag an item with text 99 | due [:] Assign a due date to an item 100 | run [>] Run an item as command 101 | move [&] Move an item to another board 102 | rename [~] Rename the name of the board 103 | undo [^] Undo the last action 104 | import [I] Import and load boards from JSON file 105 | export [E] Export boards as a JSON file 106 | history [.] Prints out the historical changes 107 | 108 | Options: 109 | -h, --help show this help message and exit 110 | --version show program's version number and exit 111 | -d, --date show boards with the added date of every item 112 | -s, --sort show boards with items on each board sorted alphabetically 113 | -t, --timeline show boards in timeline view, ignore the -d/--date option 114 | ``` 115 | 116 | --- 117 | 118 | ### View board 119 | 120 | `$ board` 121 | 122 | * `-d/--date` : show boards with the last modified date of each item in the format of ` `. e.g. `Fri 25 Jan 2019` 123 | * `-s/--sort` : show boards with items on each board sorted alphabetically by the text of the items 124 | * `-t, --timeline` : show boards in timeline view, ignore the `-d/--date` option 125 | 126 | **NOTE**: If `-d/--date` is specified, items of each board will be sorted by their dates from the most recent to the oldest ones. 127 | 128 | --- 129 | 130 | ### Add item 131 | 132 | `$ board add [ ...]` 133 | 134 | * `-b/--board ` : add the item to this board 135 | 136 | If no board `name` is specified, the item will be added to the default board. 137 | 138 | Board will be automatically initialized if one does not exist. 139 | 140 | --- 141 | 142 | ### Remove item 143 | 144 | `$ board remove [ ...]` 145 | 146 | --- 147 | 148 | ### Clear board 149 | 150 | Remove all items in the board. 151 | 152 | `$ board clear [ [ ...]]` 153 | 154 | If no board `name` is specified, all boards will be cleared. 155 | 156 | --- 157 | 158 | ### Tick / Mark / Star item 159 | 160 | `$ board {tick, mark, star} [ ...]` 161 | 162 | Run this command again on the same item to untick/unmark/unstar the item. 163 | 164 | --- 165 | 166 | ### Edit item 167 | 168 | `$ board edit ` 169 | 170 | --- 171 | 172 | ### Tag item 173 | 174 | `$ board tag [ ...]` 175 | 176 | * `-t/--text ` : tag the item with this text 177 | 178 | If no `text` is given, existing tag of this item will be removed. 179 | 180 | Color of the tag text is specified in [configurations](#configurations). 181 | 182 | --- 183 | 184 | ### Assign due date to item 185 | 186 | `$ board due [ ...]` 187 | 188 | * `-d/--date` : due date of the item in the format of `[ ...]` (`d` for day and `w` for week) e.g. `1w4d` for 1 week 4 days (11 days) 189 | 190 | If no `date` is given, existing due date of this item will be removed. 191 | 192 | --- 193 | 194 | ### Move item 195 | 196 | `$ board move [ ...]` 197 | 198 | * `-b/--board ` : move the item to this board 199 | 200 | If board does not exist, one will be created. 201 | 202 | --- 203 | 204 | ### Rename board 205 | 206 | `$ board rename ` 207 | 208 | --- 209 | 210 | ### Run item as command 211 | 212 | `$ board run ` 213 | 214 | This will spawn a subprocess to execute the command. 215 | 216 | **NOTE**: Some commands may not work properly in subprocess, such as pipes. 217 | 218 | --- 219 | 220 | ### Undo previous actions 221 | 222 | `$ board undo` 223 | 224 | #### Actions that cannot be undone: 225 | 226 | * run 227 | * undo 228 | * export 229 | * history 230 | 231 | --- 232 | 233 | ### Import board from external JSON file 234 | 235 | `$ board import ` 236 | 237 | **NOTE:** This will overwrite all the current data of boards. 238 | 239 | The JSON file must be in a valid structure according to the following. 240 | 241 | ```json 242 | { 243 | "Board Name": [ 244 | { 245 | "id": 1, 246 | "text": "item text", 247 | "time": "", 248 | "date": "", 249 | "due": "", 250 | "tick": false, 251 | "mark": false, 252 | "star": false, 253 | "tag": "" 254 | } 255 | ] 256 | } 257 | ``` 258 | 259 | --- 260 | 261 | ### Export board data as JSON file 262 | 263 | `$ board export` 264 | 265 | * `-d/--dest ` : destination path of the exported file (directory) 266 | 267 | The exported JSON file is named `board.json`. 268 | 269 | --- 270 | 271 | ### See historical changes 272 | 273 | `$ board history` 274 | 275 | --- 276 | 277 | ## Configurations 278 | 279 | **Path:** *~/.noteboard.json* 280 | 281 | ```json 282 | { 283 | "StoragePath": "~/.noteboard/", 284 | "DefaultBoardName": "Board", 285 | "Tags": { 286 | "default": "BLUE" 287 | } 288 | } 289 | ``` 290 | * `StoragePath` : path to the custom storage path (where the data and log file are stored) 291 | * `DefaultBoardName` : default board name, is used when no board is specified when adding item 292 | * `Tags` : colors preset of tags 293 | * `default` : **[required]** this color is used if no corresponding color of the tag text is found in config 294 | * `` : specify your custom tag colors by adding `: ` to `Tags` attribute of the config 295 | 296 | **NOTE:** `color` must be upper cased and a valid attribute of `colorama.Fore`. E.g. `LIGHTBLUE_EX` for light blue and `CYAN` for cyan. 297 | 298 | ## Cautions 299 | 300 | Some terminal emulators may not support dimmed (`Style.DIM`) & underlined (`\033[4m`) text. 301 | 302 | The program also uses symbols such as `⭑` and `✔` which also may not be displayed properly if a wrong encodings is set. 303 | 304 | ## Credit 305 | 306 | This project is inspired by [@Klaus Sinani](https://github.com/klaussinani)'s [taskbook](https://github.com/klaussinani/taskbook). 307 | 308 | --- 309 | 310 |
311 | ~ crafted with ♥︎ by tnychn ~ 312 |
313 | MIT © 2019 Tony Chan 314 |
315 | -------------------------------------------------------------------------------- /noteboard/__init__.py: -------------------------------------------------------------------------------- 1 | # Prepare directory paths 2 | import os 3 | 4 | from .utils import init_config, load_config, setup_logger 5 | 6 | 7 | CONFIG_PATH = os.path.join(os.path.expanduser("~"), ".noteboard.json") 8 | 9 | if not os.path.isfile(CONFIG_PATH): 10 | init_config(CONFIG_PATH) 11 | 12 | 13 | DIR_PATH = os.path.join(os.path.expanduser("~"), ".noteboard/") 14 | config = load_config(CONFIG_PATH) 15 | 16 | path = config.get("StoragePath") or DIR_PATH 17 | path = os.path.expanduser(path) 18 | if not os.path.isdir(path): 19 | os.mkdir(path) 20 | 21 | LOG_PATH = os.path.join(path, "noteboard.log") 22 | HISTORY_PATH = os.path.join(path, "history.json.gz") 23 | STORAGE_PATH = os.path.join(path, "storage") 24 | STORAGE_GZ_PATH = os.path.join(path, "storage.gz") 25 | 26 | DEFAULT_BOARD = (config.get("DefaultBoardName") or "Board").strip() 27 | TAGS = config.get("Tags", {"default": "BLUE"}) 28 | 29 | setup_logger(LOG_PATH) 30 | -------------------------------------------------------------------------------- /noteboard/__main__.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import sys 3 | import os 4 | import re 5 | import shlex 6 | import logging 7 | from colorama import init, deinit, Fore, Back, Style 8 | 9 | from . import DEFAULT_BOARD, TAGS 10 | from .__version__ import __version__ 11 | from .storage import Storage, History, NoteboardException 12 | from .utils import time_diff, add_date, to_timestamp, to_datetime 13 | 14 | logger = logging.getLogger("noteboard") 15 | COLORS = { 16 | "add": "GREEN", 17 | "remove": "LIGHTMAGENTA_EX", 18 | "clear": "RED", 19 | "run": "BLUE", 20 | 21 | "tick": "GREEN", 22 | "mark": "YELLOW", 23 | "star": "YELLOW", 24 | "tag": "LIGHTBLUE_EX", 25 | "untick": "GREEN", 26 | "unmark": "YELLOW", 27 | "unstar": "YELLOW", 28 | "untag": "LIGHTBLUE_EX", 29 | 30 | "due": "LIGHTBLUE_EX", 31 | "edit": "LIGHTCYAN_EX", 32 | "move": "LIGHTCYAN_EX", 33 | "rename": "LIGHTCYAN_EX", 34 | "undo": "LIGHTCYAN_EX", 35 | "import": "", 36 | "export": "", 37 | } 38 | 39 | 40 | def p(*args, **kwargs): 41 | # print text with spaces indented 42 | print(" ", *args, **kwargs) 43 | 44 | 45 | def error_print(text): 46 | print(Style.BRIGHT + Fore.LIGHTRED_EX + "✘ " + text) 47 | 48 | 49 | def get_fore_color(action): 50 | color = COLORS.get(action, "") 51 | if color == "": 52 | return "" 53 | return eval("Fore." + color) 54 | 55 | 56 | def get_back_color(action): 57 | color = COLORS.get(action, "") 58 | if color == "": 59 | return Back.LIGHTWHITE_EX 60 | return eval("Back." + color) 61 | 62 | 63 | def print_footer(): 64 | with Storage() as s: 65 | shelf = dict(s.shelf) 66 | ticks = 0 67 | marks = 0 68 | stars = 0 69 | for board in shelf: 70 | for item in shelf[board]: 71 | if item["tick"] is True: 72 | ticks += 1 73 | if item["mark"] is True: 74 | marks += 1 75 | if item["star"] is True: 76 | stars += 1 77 | p(Fore.GREEN + str(ticks), Fore.LIGHTBLACK_EX + "done •", Fore.LIGHTRED_EX + str(marks), Fore.LIGHTBLACK_EX + "marked •", Fore.LIGHTYELLOW_EX + str(stars), Fore.LIGHTBLACK_EX + "starred") 78 | 79 | 80 | def print_total(): 81 | with Storage() as s: 82 | total = s.total 83 | p(Fore.LIGHTCYAN_EX + "Total Items:", Style.DIM + str(total)) 84 | 85 | 86 | def run(args): 87 | color = get_fore_color("run") 88 | item = args.item 89 | with Storage() as s: 90 | i = s.get_item(item) 91 | # Run 92 | import subprocess 93 | cmd = shlex.split(i["text"]) 94 | if "|" in cmd: 95 | command = i["text"] 96 | shell = True 97 | elif len(cmd) == 1: 98 | command = i["text"] 99 | shell = True 100 | else: 101 | command = cmd 102 | shell = False 103 | execuatble = os.environ.get("SHELL", None) 104 | process = subprocess.Popen(command, shell=shell, stderr=subprocess.STDOUT, stdout=subprocess.PIPE, stdin=subprocess.PIPE, executable=execuatble) 105 | # Live stdout output 106 | deinit() 107 | print(color + "[>] Running item" + Fore.RESET, Style.BRIGHT + str(i["id"]) + Style.RESET_ALL, color + "as command...\n" + Fore.RESET) 108 | for line in iter(process.stdout.readline, b""): 109 | sys.stdout.write(line.decode("utf-8")) 110 | process.wait() 111 | 112 | 113 | def add(args): 114 | color = get_fore_color("add") 115 | items = args.item 116 | board = args.board 117 | print() 118 | with Storage() as s: 119 | for item in items: 120 | if not item: 121 | error_print("Text must not be empty") 122 | return 123 | s.save_history() 124 | i = s.add_item(board, item) 125 | p(color + "[+] Added item", Style.BRIGHT + str(i["id"]), color + "to", Style.BRIGHT + (board or DEFAULT_BOARD)) 126 | s.write_history("add", "added item {} [{}] to board [{}]".format(str(i["id"]), item, (board or DEFAULT_BOARD))) 127 | print_total() 128 | print() 129 | 130 | 131 | def remove(args): 132 | color = get_fore_color("remove") 133 | items = args.item 134 | print() 135 | with Storage() as s: 136 | for item in items: 137 | s.save_history() 138 | i, board = s.remove_item(item) 139 | p(color + "[-] Removed item", Style.BRIGHT + str(i["id"]), color + "on", Style.BRIGHT + board) 140 | s.write_history("remove", "removed item {} [{}] from board [{}]".format(str(i["id"]), item, (board or DEFAULT_BOARD))) 141 | print_total() 142 | print() 143 | 144 | 145 | def clear(args): 146 | color = get_fore_color("clear") 147 | boards = args.board 148 | print() 149 | with Storage() as s: 150 | if boards: 151 | for board in boards: 152 | s.save_history() 153 | amt = s.clear_board(board) 154 | p(color + "[x] Cleared", Style.DIM + str(amt) + Style.RESET_ALL, color + "items on", Style.BRIGHT + board) 155 | s.write_history("clear", "cleared {} items on board [{}]".format(str(amt), board)) 156 | else: 157 | s.save_history() 158 | amt = s.clear_board(None) 159 | p(color + "[x] Cleared", Style.DIM + str(amt) + Style.RESET_ALL, color + "items on all boards") 160 | s.write_history("clear", "cleared {} items on all board".format(str(amt))) 161 | print_total() 162 | print() 163 | 164 | 165 | def tick(args): 166 | color = get_fore_color("tick") 167 | items = args.item 168 | with Storage() as s: 169 | print() 170 | for item in items: 171 | state = not s.get_item(item)["tick"] 172 | s.save_history() 173 | i = s.modify_item(item, "tick", state) 174 | if state is True: 175 | p(color + "[✓] Ticked item", Style.BRIGHT + str(i["id"]), color) 176 | s.write_history("tick", "ticked item {} [{}]".format(str(i["id"]), i["text"])) 177 | else: 178 | p(color + "[✓] Unticked item", Style.BRIGHT + str(i["id"]), color) 179 | s.write_history("untick", "unticked item {} [{}]".format(str(i["id"]), i["text"])) 180 | print() 181 | 182 | 183 | def mark(args): 184 | color = get_fore_color("mark") 185 | items = args.item 186 | with Storage() as s: 187 | print() 188 | for item in items: 189 | state = not s.get_item(item)["mark"] 190 | s.save_history() 191 | i = s.modify_item(item, "mark", state) 192 | if state is True: 193 | p(color + "[!] Marked item", Style.BRIGHT + str(i["id"])) 194 | s.write_history("mark", "marked item {} [{}]".format(str(i["id"]), i["text"])) 195 | else: 196 | p(color + "[!] Unmarked item", Style.BRIGHT + str(i["id"])) 197 | s.write_history("unmark", "unmarked item {} [{}]".format(str(i["id"]), i["text"])) 198 | print() 199 | 200 | 201 | def star(args): 202 | color = get_fore_color("star") 203 | items = args.item 204 | with Storage() as s: 205 | print() 206 | for item in items: 207 | state = not s.get_item(item)["star"] 208 | s.save_history() 209 | i = s.modify_item(item, "star", state) 210 | if state is True: 211 | p(color + "[*] Starred item", Style.BRIGHT + str(i["id"])) 212 | s.write_history("star", "starred item {} [{}]".format(str(i["id"]), i["text"])) 213 | else: 214 | p(color + "[*] Unstarred item", Style.BRIGHT + str(i["id"])) 215 | s.write_history("unstar", "unstarred item {} [{}]".format(str(i["id"]), i["text"])) 216 | print() 217 | 218 | 219 | def edit(args): 220 | color = get_fore_color("edit") 221 | item = args.item 222 | text = (args.text or "").strip() 223 | if text == "": 224 | error_print("Text must not be empty") 225 | return 226 | with Storage() as s: 227 | s.save_history() 228 | i = s.modify_item(item, "text", text) 229 | s.write_history("edit", "editted item {} from [{}] to [{}]".format(str(i["id"]), i["text"], text)) 230 | print() 231 | p(color + "[~] Edited text of item", Style.BRIGHT + str(i["id"]), color + "from", i["text"], color + "to", text) 232 | print() 233 | 234 | 235 | def tag(args): 236 | color = get_fore_color("tag") 237 | items = args.item 238 | text = (args.text or "").strip() 239 | if len(text) > 10: 240 | error_print("Tag text length should not be longer than 10 characters") 241 | return 242 | if text != "": 243 | c = TAGS.get(text, "") or TAGS["default"] 244 | tag_color = eval("Fore." + c.upper()) 245 | tag_text = text.replace(" ", "-") 246 | else: 247 | tag_text = "" 248 | with Storage() as s: 249 | print() 250 | for item in items: 251 | s.save_history() 252 | i = s.modify_item(item, "tag", tag_text) 253 | if text != "": 254 | p(color + "[#] Tagged item", Style.BRIGHT + str(i["id"]), color + "with", tag_color + tag_text) 255 | s.write_history("tag", "tagged item {} [{}] with tag text [{}]".format(str(i["id"]), i["text"], text)) 256 | else: 257 | p(color + "[#] Untagged item", Style.BRIGHT + str(i["id"])) 258 | s.write_history("tag", "untagged item {} [{}]".format(str(i["id"]), i["text"])) 259 | print() 260 | 261 | 262 | def due(args): 263 | color = get_fore_color("due") 264 | items = args.item 265 | date = args.date or "" 266 | if date and not re.match(r"\d+[d|w]", date): 267 | error_print("Invalid date pattern format") 268 | return 269 | match = re.findall(r"\d+[d|w]", date) 270 | if date: 271 | days = 0 272 | for m in match: 273 | if m[-1] == "d": 274 | days += int(m[:-1]) 275 | elif m[-1] == "w": 276 | days += int(m[:-1]) * 7 277 | duedate = add_date(days) 278 | ts = to_timestamp(duedate) 279 | else: 280 | ts = None 281 | 282 | with Storage() as s: 283 | print() 284 | for item in items: 285 | s.save_history() 286 | i = s.modify_item(item, "due", ts) 287 | if ts: 288 | p(color + "[:] Assigned due date", duedate, color + "to", Style.BRIGHT + str(item)) 289 | s.write_history("due", "assiged due date [{}] to item {} [{}]".format(duedate, str(i["id"]), i["text"])) 290 | else: 291 | p(color + "[:] Unassigned due date of item", Style.BRIGHT + str(item)) 292 | s.write_history("due", "unassiged due date of item {} [{}]".format(str(i["id"]), i["text"])) 293 | print() 294 | 295 | 296 | def move(args): 297 | color = get_fore_color("move") 298 | items = args.item 299 | board = args.board 300 | with Storage() as s: 301 | print() 302 | for item in items: 303 | s.save_history() 304 | i, b = s.move_item(item, board) 305 | p(color + "[&] Moved item", Style.BRIGHT + str(i["id"]), color + "to", Style.BRIGHT + board) 306 | s.write_history("move", "moved item {} [{}] from board [{}] to [{}]".format(str(i["id"]), i["text"], b, board)) 307 | print() 308 | 309 | 310 | def rename(args): 311 | color = get_fore_color("rename") 312 | board = args.board 313 | new = (args.new or "").strip() 314 | if new == "": 315 | error_print("Board name must not be empty") 316 | return 317 | with Storage() as s: 318 | print() 319 | s.get_board(board) # try to get -> to test existence of the board 320 | s.save_history() 321 | s.shelf[new] = s.shelf.pop(board) 322 | p(color + "[~] Renamed", Style.BRIGHT + board, color + "to", Style.BRIGHT + new) 323 | s.write_history("rename", "renamed board [{}] to [{}]".format(board, new)) 324 | print() 325 | 326 | 327 | def undo(_): 328 | color = get_fore_color("undo") 329 | with Storage() as s: 330 | all_hist = s.history.load() 331 | hist = [i for i in all_hist if i["data"] is not None] 332 | if len(hist) == 0: 333 | error_print("Already at oldest change") 334 | return 335 | state = hist[-1] 336 | print() 337 | p(color + Style.BRIGHT + "Last Action:") 338 | p("=>", get_fore_color(state["action"]) + state["info"]) 339 | print() 340 | ask = input("[?] Continue (y/n) ? ") 341 | if ask != "y": 342 | error_print("Operation aborted") 343 | return 344 | s.history.revert() 345 | print(color + "[^] Undone", "=>", get_fore_color(state["action"]) + state["info"]) 346 | 347 | 348 | def import_(args): 349 | color = get_fore_color("import") 350 | path = args.path 351 | with Storage() as s: 352 | s.save_history() 353 | full_path = s.import_(path) 354 | s.write_history("import", "imported boards from [{}]".format(full_path)) 355 | print() 356 | p(color + "[I] Imported boards from", Style.BRIGHT + full_path) 357 | print_total() 358 | print() 359 | 360 | 361 | def export(args): 362 | color = get_fore_color("export") 363 | dest = args.dest 364 | path = os.path.abspath(os.path.expanduser(dest)) 365 | if os.path.isfile(path): 366 | print("[i] File {} already exists".format(path)) 367 | ask = input("[?] Overwrite (y/n) ? ") 368 | if ask != "y": 369 | error_print("Operation aborted") 370 | return 371 | with Storage() as s: 372 | full_path = s.export(path) 373 | s.write_history("export", "exported boards to [{}]".format(full_path)) 374 | print() 375 | p(color + "[E] Exported boards to", Style.BRIGHT + full_path) 376 | print() 377 | 378 | 379 | def history(_): 380 | hist = History.load() 381 | for action in hist: 382 | name = action["action"] 383 | info = action["info"] 384 | date = action["date"] 385 | print(Fore.LIGHTYELLOW_EX + date, get_back_color(name) + Fore.BLACK + name.upper().center(9), info) 386 | 387 | 388 | def display_board(shelf, date=False, timeline=False): 389 | # print initial help message 390 | if not shelf: 391 | print() 392 | c = "`board --help`" 393 | p(Style.BRIGHT + "Type", Style.BRIGHT + Fore.YELLOW + c, Style.BRIGHT + "to get started") 394 | 395 | for board in shelf: 396 | # Print Board title 397 | if len(shelf[board]) == 0: 398 | continue 399 | print() 400 | p("\033[4m" + Style.BRIGHT + board, Fore.LIGHTBLACK_EX + "[{}]".format(len(shelf[board]))) 401 | 402 | # Print Item 403 | for item in shelf[board]: 404 | mark = Fore.BLUE + "●" 405 | text_color = "" 406 | tag_text = "" 407 | 408 | # tick 409 | if item["tick"] is True: 410 | mark = Fore.GREEN + "✔" 411 | text_color = Fore.LIGHTBLACK_EX 412 | 413 | # mark 414 | if item["mark"] is True: 415 | if item["tick"] is False: 416 | mark = Fore.LIGHTRED_EX + "!" 417 | text_color = Style.BRIGHT + Fore.RED 418 | 419 | # tag 420 | if item["tag"]: 421 | c = TAGS.get(item["tag"], "") or TAGS["default"] 422 | tag_color = eval("Fore." + c.upper()) 423 | tag_text = " " + tag_color + "(" + item["tag"] + ")" 424 | 425 | # Star 426 | star = " " 427 | if item["star"] is True: 428 | star = Fore.LIGHTYELLOW_EX + "⭑" 429 | 430 | # Day difference 431 | days = time_diff(item["time"]).days 432 | if days <= 0: 433 | day_text = "" 434 | else: 435 | day_text = Fore.LIGHTBLACK_EX + "{}d".format(days) 436 | 437 | # Due date 438 | due_text = "" 439 | color = "" 440 | if item["due"]: 441 | due_days = time_diff(item["due"], reverse=True).days + 1 # + 1 because today is included 442 | if due_days == 0: 443 | text = "today" 444 | color = Fore.RED 445 | elif due_days == 1: 446 | text = "tomorrow" 447 | color = Fore.YELLOW 448 | elif due_days == -1: 449 | text = "yesterday" 450 | color = Fore.BLUE 451 | elif due_days < 0: 452 | text = "{}d ago".format(due_days*-1) 453 | elif due_days > 0: 454 | text = "{}d".format(due_days) 455 | due_text = "{}(due: {}{})".format(Fore.LIGHTBLACK_EX, color + text, Style.RESET_ALL + Fore.LIGHTBLACK_EX) 456 | 457 | # print text all together 458 | if date is True and timeline is False: 459 | p(star, Fore.LIGHTMAGENTA_EX + str(item["id"]).rjust(2), mark, text_color + item["text"], tag_text, Fore.LIGHTBLACK_EX + str(item["date"]), 460 | (Fore.LIGHTBLACK_EX + "(due: {})".format(color + str(to_datetime(item["due"])) + Fore.LIGHTBLACK_EX)) if item["due"] else "") 461 | else: 462 | p(star, Fore.LIGHTMAGENTA_EX + str(item["id"]).rjust(2), mark, text_color + item["text"] + (Style.RESET_ALL + Fore.LIGHTBLUE_EX + " @" + item["board"] if timeline else ""), 463 | tag_text, day_text, due_text) 464 | print() 465 | print_footer() 466 | print_total() 467 | print() 468 | 469 | 470 | def main(): 471 | description = (Style.BRIGHT + " \033[4mNoteboard" + Style.RESET_ALL + " lets you manage your " + Fore.YELLOW + "notes" + Fore.RESET + " & " + Fore.CYAN + "tasks" + Fore.RESET 472 | + " in a " + Fore.LIGHTMAGENTA_EX + "tidy" + Fore.RESET + " and " + Fore.LIGHTMAGENTA_EX + "fancy" + Fore.RESET + " way.") 473 | epilog = ( 474 | "Examples:\n" 475 | ' $ board add "improve cli" -b "Todo List"\n' 476 | ' $ board remove 2 4\n' 477 | ' $ board clear "Todo List" "Coding"\n' 478 | ' $ board edit 1 "improve cli"\n' 479 | ' $ board tag 1 6 -t "enhancement" -c GREEN\n' 480 | ' $ board tick 1 5 9\n' 481 | ' $ board move 2 3 -b "Destination"\n' 482 | ' $ board import ~/Documents/board.json\n' 483 | ' $ board export ~/Documents/save.json\n\n' 484 | "{0}crafted with {1}\u2764{2} by tnychn{3} (https://github.com/tnychn/noteboard)".format(Style.BRIGHT, Fore.RED, Fore.RESET, Style.RESET_ALL) 485 | ) 486 | parser = argparse.ArgumentParser( 487 | prog="board", 488 | description=description, 489 | epilog=epilog, 490 | formatter_class=argparse.RawTextHelpFormatter 491 | ) 492 | parser._positionals.title = "Actions" 493 | parser._optionals.title = "Options" 494 | parser.add_argument("--version", action="version", version="noteboard " + __version__) 495 | parser.add_argument("-d", "--date", help="show boards with the added date of every item", default=False, action="store_true", dest="d") 496 | parser.add_argument("-s", "--sort", help="show boards with items on each board sorted alphabetically", default=False, action="store_true", dest="s") 497 | parser.add_argument("-t", "--timeline", help="show boards in timeline view, ignore the -d/--date option", default=False, action="store_true", dest="t") 498 | subparsers = parser.add_subparsers() 499 | 500 | add_parser = subparsers.add_parser("add", help=get_fore_color("add") + "[+] Add an item to a board" + Fore.RESET) 501 | add_parser.add_argument("item", help="the item you want to add", type=str, metavar="", nargs="+") 502 | add_parser.add_argument("-b", "--board", help="the board you want to add the item to (default: {})".format(DEFAULT_BOARD), type=str, metavar="") 503 | add_parser.set_defaults(func=add) 504 | 505 | remove_parser = subparsers.add_parser("remove", help=get_fore_color("remove") + "[-] Remove items" + Fore.RESET) 506 | remove_parser.add_argument("item", help="id of the item you want to remove", type=int, metavar="", nargs="+") 507 | remove_parser.set_defaults(func=remove) 508 | 509 | clear_parser = subparsers.add_parser("clear", help=get_fore_color("clear") + "[x] Clear all items on a/all board(s)" + Fore.RESET) 510 | clear_parser.add_argument("board", help="clear this specific board", type=str, metavar="", nargs="*") 511 | clear_parser.set_defaults(func=clear) 512 | 513 | tick_parser = subparsers.add_parser("tick", help=get_fore_color("tick") + "[✓] Tick/Untick an item" + Fore.RESET) 514 | tick_parser.add_argument("item", help="id of the item you want to tick/untick", type=int, metavar="", nargs="+") 515 | tick_parser.set_defaults(func=tick) 516 | 517 | mark_parser = subparsers.add_parser("mark", help=get_fore_color("mark") + "[!] Mark/Unmark an item" + Fore.RESET) 518 | mark_parser.add_argument("item", help="id of the item you want to mark/unmark", type=int, metavar="", nargs="+") 519 | mark_parser.set_defaults(func=mark) 520 | 521 | star_parser = subparsers.add_parser("star", help=get_fore_color("star") + "[*] Star/Unstar an item" + Fore.RESET) 522 | star_parser.add_argument("item", help="id of the item you want to star/unstar", type=int, metavar="", nargs="+") 523 | star_parser.set_defaults(func=star) 524 | 525 | edit_parser = subparsers.add_parser("edit", help=get_fore_color("edit") + "[~] Edit the text of an item" + Fore.RESET) 526 | edit_parser.add_argument("item", help="id of the item you want to edit", type=int, metavar="") 527 | edit_parser.add_argument("text", help="new text to replace the old one", type=str, metavar="") 528 | edit_parser.set_defaults(func=edit) 529 | 530 | tag_parser = subparsers.add_parser("tag", help=get_fore_color("tag") + "[#] Tag an item with text" + Fore.RESET) 531 | tag_parser.add_argument("item", help="id of the item you want to tag", type=int, metavar="", nargs="+") 532 | tag_parser.add_argument("-t", "--text", help="text of tag (do not specify this argument to untag)", type=str, metavar="") 533 | tag_parser.set_defaults(func=tag) 534 | 535 | due_parser = subparsers.add_parser("due", help=get_fore_color("due") + "[:] Assign a due date to an item" + Fore.RESET) 536 | due_parser.add_argument("item", help="id of the item", type=int, metavar="", nargs="+") 537 | due_parser.add_argument("-d", "--date", help="due date of the item in the format of `` e.g. '1w4d' for 1 week and 4 days (11 days)", type=str, metavar="") 538 | due_parser.set_defaults(func=due) 539 | 540 | run_parser = subparsers.add_parser("run", help=get_fore_color("run") + "[>] Run an item as command" + Fore.RESET) 541 | run_parser.add_argument("item", help="id of the item you want to run", type=int, metavar="") 542 | run_parser.set_defaults(func=run) 543 | 544 | move_parser = subparsers.add_parser("move", help=get_fore_color("move") + "[&] Move an item to another board" + Fore.RESET) 545 | move_parser.add_argument("item", help="id of the item you want to move", type=int, metavar="", nargs="+") 546 | move_parser.add_argument("-b", "--board", help="name of the destination board", type=str, metavar="", required=True) 547 | move_parser.set_defaults(func=move) 548 | 549 | rename_parser = subparsers.add_parser("rename", help=get_fore_color("rename") + "[~] Rename the name of the board" + Fore.RESET) 550 | rename_parser.add_argument("board", help="name of the board you want to rename", type=str, metavar="") 551 | rename_parser.add_argument("new", help="new name to replace the old one", type=str, metavar="") 552 | rename_parser.set_defaults(func=rename) 553 | 554 | undo_parser = subparsers.add_parser("undo", help=get_fore_color("undo") + "[^] Undo the last action" + Fore.RESET) 555 | undo_parser.set_defaults(func=undo) 556 | 557 | import_parser = subparsers.add_parser("import", help=get_fore_color("import") + "[I] Import and load boards from JSON file" + Fore.RESET) 558 | import_parser.add_argument("path", help="path to the target import file", type=str, metavar="") 559 | import_parser.set_defaults(func=import_) 560 | 561 | export_parser = subparsers.add_parser("export", help=get_fore_color("export") + "[E] Export boards as a JSON file" + Fore.RESET) 562 | export_parser.add_argument("-d", "--dest", help="destination of the exported file (default: ./board.json)", type=str, default="./board.json", metavar="") 563 | export_parser.set_defaults(func=export) 564 | 565 | history_parser = subparsers.add_parser("history", help="[.] Prints out the historical changes") 566 | history_parser.set_defaults(func=history) 567 | 568 | args = parser.parse_args() 569 | init(autoreset=True) 570 | try: 571 | args.func 572 | except AttributeError: 573 | with Storage() as s: 574 | shelf = dict(s.shelf) 575 | 576 | if args.s: 577 | # sort alphabetically 578 | for board in shelf: 579 | shelf[board] = sorted(shelf[board], key=lambda x: x["text"].lower()) 580 | elif args.d: 581 | # sort by date 582 | for board in shelf: 583 | shelf[board] = sorted(shelf[board], key=lambda x: x["time"], reverse=True) 584 | 585 | if args.t: 586 | data = {} 587 | for board in shelf: 588 | for item in shelf[board]: 589 | if item["date"]: 590 | if item["date"] not in data: 591 | data[item["date"]] = [] 592 | item.update({"board": board}) 593 | data[item["date"]].append(item) 594 | shelf = data 595 | display_board(shelf, date=args.d, timeline=args.t) 596 | else: 597 | try: 598 | args.func(args) 599 | except KeyboardInterrupt: 600 | error_print("Operation aborted") 601 | except NoteboardException as e: 602 | error_print(str(e)) 603 | logger.debug("(ERROR)", exc_info=True) 604 | except Exception as e: 605 | error_print(str(e)) 606 | logger.debug("(ERROR)", exc_info=True) 607 | deinit() 608 | 609 | 610 | if __name__ == "__main__": 611 | main() 612 | -------------------------------------------------------------------------------- /noteboard/__version__.py: -------------------------------------------------------------------------------- 1 | __version__ = "1.1.5" 2 | -------------------------------------------------------------------------------- /noteboard/storage.py: -------------------------------------------------------------------------------- 1 | import shelve 2 | import gzip 3 | import shutil 4 | import json 5 | import os 6 | import logging 7 | 8 | from . import DIR_PATH, HISTORY_PATH, STORAGE_PATH, STORAGE_GZ_PATH, DEFAULT_BOARD 9 | from .utils import get_time, to_datetime 10 | 11 | logger = logging.getLogger("noteboard") 12 | 13 | 14 | class NoteboardException(Exception): 15 | """Base Exception Class of Noteboard.""" 16 | 17 | 18 | class ItemNotFoundError(NoteboardException): 19 | """Raised when no item with the specified id found.""" 20 | 21 | def __init__(self, id): 22 | self.id = id 23 | 24 | def __str__(self): 25 | return "Item {} not found".format(self.id) 26 | 27 | 28 | class BoardNotFoundError(NoteboardException): 29 | """Raised when no board with specified name found.""" 30 | 31 | def __init__(self, name): 32 | self.name = name 33 | 34 | def __str__(self): 35 | return "Board '{}' not found".format(self.name) 36 | 37 | 38 | class History: 39 | 40 | def __init__(self, storage): 41 | self.storage = storage 42 | self.buffer = None 43 | 44 | @staticmethod 45 | def load(): 46 | try: 47 | with gzip.open(HISTORY_PATH, "r") as j: 48 | history = json.loads(j.read().decode("utf-8")) 49 | except FileNotFoundError: 50 | raise NoteboardException("History file not found for loading") 51 | return history 52 | 53 | def revert(self): 54 | history = History.load() 55 | hist = [i for i in history if i["data"] is not None] 56 | if len(hist) == 0: 57 | return {} 58 | state = hist[-1] 59 | logger.debug("Revert state: {}".format(state)) 60 | # Update the shelf 61 | self.storage.shelf.clear() 62 | self.storage.shelf.update(dict(state["data"])) 63 | # Remove state from history 64 | history.remove(state) 65 | # Update the history file 66 | with gzip.open(HISTORY_PATH, "w") as j: 67 | j.write(json.dumps(history).encode("utf-8")) 68 | return state 69 | 70 | def save(self, data): 71 | self.buffer = data.copy() 72 | 73 | def write(self, action, info): 74 | is_new = not os.path.isfile(HISTORY_PATH) 75 | 76 | # Create and initialise history file with an empty list 77 | if is_new: 78 | with gzip.open(HISTORY_PATH, "w+") as j: 79 | j.write(json.dumps([]).encode("utf-8")) 80 | 81 | # Write data to disk 82 | # => read the current saved states 83 | history = History.load() 84 | # => dump history data 85 | state = {"action": action, "info": info, "date": get_time("%d %b %Y %X")[0], "data": dict(self.buffer) if self.buffer else self.buffer} 86 | logger.debug("Write history: {}".format(state)) 87 | history.append(state) 88 | with gzip.open(HISTORY_PATH, "w") as j: 89 | j.write(json.dumps(history).encode("utf-8")) 90 | self.buffer = None # empty the buffer 91 | 92 | 93 | class Storage: 94 | 95 | def __init__(self): 96 | self._shelf = None 97 | self.history = History(self) 98 | 99 | def __enter__(self): 100 | self.open() 101 | return self 102 | 103 | def __exit__(self, *args, **kwargs): 104 | self.close() 105 | return False 106 | 107 | def open(self): 108 | # Open shelf 109 | if self._shelf is not None: 110 | raise NoteboardException("Shelf object has already been opened.") 111 | 112 | if not os.path.isdir(DIR_PATH): 113 | logger.debug("Making directory {} ...".format(DIR_PATH)) 114 | os.mkdir(DIR_PATH) 115 | 116 | if os.path.isfile(STORAGE_GZ_PATH): 117 | # decompress compressed storage.gz to a storage file 118 | with gzip.open(STORAGE_GZ_PATH, "rb") as f_in: 119 | with open(STORAGE_PATH, "wb") as f_out: 120 | shutil.copyfileobj(f_in, f_out) 121 | os.remove(STORAGE_GZ_PATH) 122 | 123 | self._shelf = shelve.open(STORAGE_PATH, "c", writeback=True) 124 | 125 | def close(self): 126 | if self._shelf is None: 127 | raise NoteboardException("No opened shelf object to be closed.") 128 | 129 | # Cleanup 130 | for board in self.shelf: 131 | # remove empty boards 132 | if not self.shelf[board]: 133 | self.shelf.pop(board) 134 | continue 135 | # always sort items on the boards before closing 136 | self.shelf[board] = list(sorted(self.shelf[board], key=lambda x: x["id"])) 137 | self._shelf.close() 138 | 139 | # compress storage to storage.gz 140 | with gzip.open(STORAGE_GZ_PATH, "wb") as f_out: 141 | with open(STORAGE_PATH, "rb") as f_in: 142 | shutil.copyfileobj(f_in, f_out) 143 | os.remove(STORAGE_PATH) 144 | 145 | @property 146 | def shelf(self): 147 | """Use this property to access the shelf object from the outside.""" 148 | if self._shelf is None: 149 | raise NoteboardException("No opened shelf object to be accessed.") 150 | return self._shelf 151 | 152 | @property 153 | def boards(self): 154 | """Get all existing board titles.""" 155 | return list(self.shelf.keys()) 156 | 157 | @property 158 | def items(self): 159 | """Get all existing items with ids and texts.""" 160 | results = {} 161 | for board in self.shelf: 162 | for item in self.shelf[board]: 163 | results[item["id"]] = item["text"] 164 | return results 165 | 166 | @property 167 | def total(self): 168 | """Get the total amount of items in all boards.""" 169 | return len(self.items) 170 | 171 | def get_item(self, id): 172 | """Get the item with the give ID. ItemNotFoundError will be raised if nothing found.""" 173 | for board in self.shelf: 174 | for item in self.shelf[board]: 175 | if item["id"] == id: 176 | return item 177 | raise ItemNotFoundError(id) 178 | 179 | def get_board(self, name): 180 | """Get the board with the given name. BoardNotFound will be raised if nothing found.""" 181 | for board in self.shelf: 182 | if board == name: 183 | return self.shelf[name] 184 | raise BoardNotFoundError(name) 185 | 186 | def get_all_items(self): 187 | items = [] 188 | for board in self.shelf: 189 | for item in self.shelf[board]: 190 | items.append(item) 191 | return items 192 | 193 | def _add_board(self, board): 194 | if board.strip() == "": 195 | raise ValueError("Board title must not be empty.") 196 | if board in self.shelf.keys(): 197 | raise KeyError("Board already exists.") 198 | logger.debug("Added Board: '{}'".format(board)) 199 | self.shelf[board] = [] # register board by adding an empty list 200 | 201 | def _add_item(self, id, board, text): 202 | date, timestamp = get_time() 203 | payload = { 204 | "id": id, # int 205 | "text": text, # str 206 | "time": timestamp, # int 207 | "date": date, # str 208 | "due": None, # int 209 | "tick": False, # bool 210 | "mark": False, # bool 211 | "star": False, # bool 212 | "tag": "" # str 213 | } 214 | self.shelf[board].append(payload) 215 | logger.debug("Added Item: {} to Board: '{}'".format(json.dumps(payload), board)) 216 | return payload 217 | 218 | def add_item(self, board, text): 219 | """[Action] 220 | * Can be Undone: Yes 221 | Prepare data to be dumped into the shelf. 222 | If the specified board not found, it automatically creates and initialise a new board. 223 | This method passes the prepared dictionary data to self._add_item to encrypt it and really add it to the board. 224 | 225 | Returns: 226 | dict -- data of the added item 227 | """ 228 | current_id = 1 229 | # get all existing ids 230 | ids = list(sorted(self.items.keys())) 231 | if ids: 232 | current_id = ids[-1] + 1 233 | # board name 234 | board = board or DEFAULT_BOARD 235 | # add 236 | if board not in self.shelf: 237 | # create board 238 | self._add_board(board) 239 | # add item 240 | return self._add_item(current_id, board, text) 241 | 242 | def remove_item(self, id): 243 | """[Action] 244 | * Can be Undone: Yes 245 | Remove an existing item from board. 246 | 247 | Returns: 248 | dict -- data of the removed item 249 | str -- board name of the regarding board of the removed item 250 | """ 251 | status = False 252 | for board in self.shelf: 253 | for item in self.shelf[board]: 254 | if item["id"] == id: 255 | # remove 256 | self.shelf[board].remove(item) 257 | removed = item 258 | board_of_removed = board 259 | logger.debug("Removed Item: {} on Board: '{}'".format(json.dumps(item), board)) 260 | status = True 261 | if len(self.shelf[board]) == 0: 262 | del self.shelf[board] 263 | if status is False: 264 | raise ItemNotFoundError(id) 265 | return removed, board_of_removed 266 | 267 | def clear_board(self, board=None): 268 | """[Action] 269 | * Can be Undone: Yes 270 | Remove all items of a board or of all boards (if no board is specified). 271 | 272 | Returns: 273 | int -- total amount of items removed 274 | """ 275 | if not board: 276 | amt = len(self.items) 277 | # remove all items of all boards 278 | self.shelf.clear() 279 | logger.debug("Cleared all {} Items".format(amt)) 280 | else: 281 | # remove 282 | if board not in self.shelf: 283 | raise BoardNotFoundError(board) 284 | amt = len(self.shelf[board]) 285 | del self.shelf[board] 286 | logger.debug("Cleared {} Items on Board: '{}'".format(amt, board)) 287 | return amt 288 | 289 | def modify_item(self, id, key, value): 290 | """[Action] 291 | * Can be Undone: Partially (only when modifying text) 292 | Modify the data of an item, given its ID. 293 | If the item does not have the key, one will be created. 294 | 295 | Arguments: 296 | id {int} -- id of the item you want to modify 297 | key {str} -- one of [id, text, time, tick, star, mark, tag] 298 | value -- new value to replace the old value 299 | 300 | Returns: 301 | dict -- the item before modification 302 | """ 303 | item = self.get_item(id) 304 | old = item.copy() 305 | item[key] = value 306 | logger.debug("Modified Item from {} to {}".format(json.dumps(old), json.dumps(item))) 307 | return old 308 | 309 | def move_item(self, id, board): 310 | """[Action] 311 | * Can be undone: No 312 | Move the whole item to the destination board, given the id of the item and the name of the board. 313 | 314 | If the destination board does not exist, one will be created. 315 | 316 | Arguments: 317 | id {int} -- id of the item you want to move 318 | board {str} -- name of the destination board 319 | 320 | Returns: 321 | item {dict} -- the item that is moved 322 | b {str} -- the name of board the item originally from 323 | """ 324 | for b in self.shelf: 325 | for item in self.shelf[b]: 326 | if item["id"] == id: 327 | if not self.shelf.get(board): 328 | # register board with a empty list if board not found 329 | self.shelf[board] = [] 330 | # append to dest board `board` 331 | self.shelf[board].append(item) 332 | # remove from the current board `b` 333 | self.shelf[b].remove(item) 334 | return item, b 335 | raise ItemNotFoundError(id) 336 | 337 | @staticmethod 338 | def _validate_json(data): 339 | keys = ["id", "text", "time", "date", "due", "tick", "mark", "star", "tag"] 340 | for board in data: 341 | if board.strip() == "": 342 | return False 343 | # Check for board type (list) 344 | if not isinstance(data[board], list): 345 | return False 346 | for item in data[board]: 347 | # Check for item type (dictionary) 348 | if not isinstance(item, dict): 349 | return False 350 | # Check for existence of keys 351 | for key in keys: 352 | if key not in item.keys(): 353 | return False 354 | # Automatically make one from supplied timestamp if date is not supplied 355 | if not item["date"] and item["time"]: 356 | item["date"] = to_datetime(float(item["time"])).strftime("%a %d %b %Y") 357 | return True 358 | 359 | def import_(self, path): 360 | """[Action] 361 | * Can be Undone: Yes 362 | Import and load a local file (json) and overwrite the current boards. 363 | 364 | Arguments: 365 | path {str} -- path to the archive file 366 | 367 | Returns: 368 | path {str} -- full path of the imported file 369 | """ 370 | path = os.path.abspath(path) 371 | try: 372 | with open(path, "r") as f: 373 | data = json.load(f) 374 | except FileNotFoundError: 375 | raise NoteboardException("File not found ({})".format(path)) 376 | except json.JSONDecodeError: 377 | raise NoteboardException("Failed to decode JSON") 378 | else: 379 | if self._validate_json(data) is False: 380 | raise NoteboardException("Invalid JSON structure for noteboard") 381 | # Overwrite the current shelf and update it 382 | self.shelf.clear() 383 | self.shelf.update(dict(data)) 384 | return path 385 | 386 | def export(self, dest="./board.json"): 387 | """[Action] 388 | * Can be Undone: No 389 | Exoport the current shelf as a JSON file to `dest`. 390 | 391 | Arguments: 392 | dest {str} -- path of the destination 393 | 394 | Returns: 395 | path {str} -- full path of the exported file 396 | """ 397 | dest = os.path.abspath(dest) 398 | data = dict(self.shelf) 399 | with open(dest, "w") as f: 400 | json.dump(data, f, indent=4, sort_keys=True) 401 | return dest 402 | 403 | def save_history(self): 404 | data = {} 405 | for board in self.shelf: 406 | data[board] = [] 407 | for item in self.shelf[board]: 408 | data[board].append(item.copy()) 409 | self.history.save(data) 410 | 411 | def write_history(self, action, info): 412 | self.history.write(action, info) 413 | -------------------------------------------------------------------------------- /noteboard/utils.py: -------------------------------------------------------------------------------- 1 | import time 2 | import datetime 3 | import os 4 | import json 5 | import logging 6 | 7 | 8 | DEFAULT = { 9 | "StoragePath": "~/.noteboard/", 10 | "DefaultBoardName": "Board", 11 | "Tags": { 12 | "default": "BLUE", 13 | } 14 | } 15 | 16 | 17 | def get_time(fmt=None): 18 | if fmt: 19 | date = datetime.datetime.now().strftime(fmt) # str 20 | else: 21 | date = datetime.datetime.now().strftime("%a %d %b %Y") # str 22 | timestamp = time.time() 23 | return date, timestamp 24 | 25 | 26 | def to_timestamp(date): 27 | return int(time.mktime(date.timetuple())) 28 | 29 | 30 | def to_datetime(ts): 31 | return datetime.date.fromtimestamp(ts) # datetime instance 32 | 33 | 34 | def time_diff(ts, reverse=False): 35 | """Get the time difference between the given timestamp and the current time.""" 36 | date = datetime.datetime.fromtimestamp(ts) 37 | now = datetime.datetime.fromtimestamp(get_time()[1]) 38 | if reverse: 39 | return date - now # datetime instance 40 | return now - date # datetime instance 41 | 42 | 43 | def add_date(days): 44 | """Get the datetime with `days` added to the current datetime.""" 45 | today = datetime.date.today() 46 | date = today + datetime.timedelta(days=days) 47 | return date # datetime instance 48 | 49 | 50 | def setup_logger(path): 51 | formatter = logging.Formatter("%(asctime)s [%(levelname)s] (%(funcName)s in %(filename)s) %(message)s", "") 52 | handler = logging.FileHandler(path, mode="a+") 53 | handler.setLevel(logging.DEBUG) 54 | handler.setFormatter(formatter) 55 | 56 | logger = logging.getLogger("noteboard") 57 | logger.setLevel(logging.DEBUG) 58 | if not logger.hasHandlers(): 59 | logger.addHandler(handler) 60 | return logger 61 | 62 | 63 | def init_config(path): 64 | """Initialise configurations file. If file already exists, it will be overwritten.""" 65 | with open(path, "w+") as f: 66 | json.dump(DEFAULT, f, sort_keys=True, indent=4) 67 | 68 | 69 | def load_config(path): 70 | """Load configurations file. If file does not exist, call `init_config()`.""" 71 | if not os.path.isfile(path): 72 | init_config(path) 73 | 74 | with open(path, "r+") as f: 75 | config = json.load(f) 76 | return config 77 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tnychn/noteboard/d218a112df8926adffeaf1af4520ab5d21ac2614/screenshot.png -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from setuptools import setup, find_packages 3 | 4 | here = os.path.abspath(os.path.dirname(__file__)) 5 | 6 | # Package Meta-Data 7 | NAME = "noteboard" 8 | DESCRIPTION = "Manage your notes & tasks in a tidy and fancy way." 9 | URL = "https://github.com/tnychn/noteboard" 10 | EMAIL = "tnychn@protonmail.com" 11 | AUTHOR = "tnychn" 12 | REQUIRES_PYTHON = ">=3.6.0" 13 | REQUIRED = [ 14 | "colorama" 15 | ] 16 | about = {} 17 | with open(os.path.join(here, NAME, "__version__.py"), "r") as f: 18 | exec(f.read(), about) 19 | 20 | long_description = \ 21 | """ 22 | Noteboard lets you manage your notes & tasks in a tidy and fancy way. 23 | 24 | ## Features 25 | 26 | * Fancy interface ✨ 27 | * Simple & Easy to use 🚀 28 | * Fast as lightning ⚡️ 29 | * Manage notes & tasks in multiple boards 🗒 30 | * Run item as command inside terminal (subprocess) 💨 31 | * Tag item with color and text 🏷 32 | * Import boards from external JSON files & Export boards as JSON files 33 | * Undo multiple actions / changes 34 | * Keep historical states 🕥 35 | * `Gzip` compressed storage 📚 36 | * Configurable through `~/.noteboard.json` 37 | """ 38 | 39 | # Setup 40 | setup( 41 | name=NAME, 42 | version=about["__version__"], 43 | description=DESCRIPTION, 44 | long_description=long_description, 45 | long_description_content_type="text/markdown", 46 | author=AUTHOR, 47 | author_email=EMAIL, 48 | python_requires=REQUIRES_PYTHON, 49 | url=URL, 50 | entry_points={ 51 | "console_scripts": ["board=noteboard.__main__:main"], 52 | }, 53 | install_requires=REQUIRED, 54 | include_package_data=True, 55 | packages=find_packages(), 56 | license="MIT", 57 | keywords=["cli", "todo", "task", "note", "board", "gzip", "interactive", "taskbook"], 58 | classifiers=[ 59 | "License :: OSI Approved :: MIT License", 60 | "Programming Language :: Python", 61 | "Programming Language :: Python :: 3", 62 | "Programming Language :: Python :: 3.6", 63 | "Programming Language :: Python :: Implementation :: CPython", 64 | "Programming Language :: Python :: Implementation :: PyPy" 65 | ], 66 | ) 67 | --------------------------------------------------------------------------------