├── .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 |
--------------------------------------------------------------------------------