notesmgr 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- notesmgr/__init__.py +5 -0
- notesmgr/__main__.py +10 -0
- notesmgr/actions.py +86 -0
- notesmgr/application.py +93 -0
- notesmgr/button_row.py +149 -0
- notesmgr/clipboard_linux.py +58 -0
- notesmgr/clipboard_macos.py +78 -0
- notesmgr/clipboard_tool.py +84 -0
- notesmgr/clipboard_windows.py +198 -0
- notesmgr/cmd_line.py +63 -0
- notesmgr/commands.py +307 -0
- notesmgr/config.py +152 -0
- notesmgr/config_defaults.py +41 -0
- notesmgr/config_editor.py +62 -0
- notesmgr/config_files.py +147 -0
- notesmgr/console.py +62 -0
- notesmgr/descriptions.py +27 -0
- notesmgr/dialogs.py +389 -0
- notesmgr/editor_command.py +138 -0
- notesmgr/errors.py +14 -0
- notesmgr/explorer_drag.py +222 -0
- notesmgr/explorer_drop.py +195 -0
- notesmgr/explorer_font.py +81 -0
- notesmgr/explorer_tree.py +148 -0
- notesmgr/failure_report.py +92 -0
- notesmgr/file_watch.py +115 -0
- notesmgr/folder_ops.py +173 -0
- notesmgr/main_window.py +474 -0
- notesmgr/markdown_render.py +78 -0
- notesmgr/menu_bar.py +114 -0
- notesmgr/note_blocks.py +511 -0
- notesmgr/note_file.py +203 -0
- notesmgr/note_fonts.py +189 -0
- notesmgr/note_image.py +75 -0
- notesmgr/note_ops.py +316 -0
- notesmgr/note_panel.py +227 -0
- notesmgr/note_table.py +145 -0
- notesmgr/note_tags.py +241 -0
- notesmgr/note_text.py +62 -0
- notesmgr/note_view.py +273 -0
- notesmgr/order_file.py +150 -0
- notesmgr/project.py +168 -0
- notesmgr/project_ops.py +302 -0
- notesmgr/py.typed +0 -0
- notesmgr/rich_clipboard.py +107 -0
- notesmgr/session.py +71 -0
- notesmgr/shortcuts.py +131 -0
- notesmgr/trash.py +31 -0
- notesmgr/version_info.py +78 -0
- notesmgr-0.1.0.dist-info/METADATA +299 -0
- notesmgr-0.1.0.dist-info/RECORD +55 -0
- notesmgr-0.1.0.dist-info/WHEEL +5 -0
- notesmgr-0.1.0.dist-info/entry_points.txt +2 -0
- notesmgr-0.1.0.dist-info/licenses/LICENSE.txt +22 -0
- notesmgr-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
#! /usr/local/bin/python3
|
|
2
|
+
"""Putting a formatted copy of a note on the clipboard of Windows."""
|
|
3
|
+
|
|
4
|
+
# Copyright (c) 2026 Tom Björkholm
|
|
5
|
+
# MIT License
|
|
6
|
+
|
|
7
|
+
import ctypes
|
|
8
|
+
import sys
|
|
9
|
+
from typing import Sequence
|
|
10
|
+
from notesmgr.clipboard_tool import RichText
|
|
11
|
+
from notesmgr.errors import NotesmgrError
|
|
12
|
+
|
|
13
|
+
HEADER = 'Version:0.9\r\nStartHTML:{start:010d}\r\nEndHTML:{end:010d}\r\n' \
|
|
14
|
+
'StartFragment:{first:010d}\r\nEndFragment:{last:010d}\r\n'
|
|
15
|
+
"""What says where the pieces of an HTML Format payload begin and end.
|
|
16
|
+
|
|
17
|
+
Every number is a count of bytes from the very start of the payload,
|
|
18
|
+
written with as many digits as it can ever need, so that filling the
|
|
19
|
+
numbers in does not move what they point at.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
OPENING = '<html><body>\r\n<!--StartFragment-->'
|
|
23
|
+
"""What stands before the piece of the payload that is pasted."""
|
|
24
|
+
|
|
25
|
+
CLOSING = '<!--EndFragment-->\r\n</body></html>'
|
|
26
|
+
"""What stands after the piece of the payload that is pasted."""
|
|
27
|
+
|
|
28
|
+
HTML_FORMAT = 'HTML Format'
|
|
29
|
+
"""What the clipboard of Windows calls the shape that keeps markup."""
|
|
30
|
+
|
|
31
|
+
UNICODE_TEXT = 13
|
|
32
|
+
"""What the clipboard of Windows calls plain text, as CF_UNICODETEXT."""
|
|
33
|
+
|
|
34
|
+
MOVEABLE = 0x0002
|
|
35
|
+
"""How memory that is handed over to the clipboard is allocated."""
|
|
36
|
+
|
|
37
|
+
NOT_WINDOWS = 'This is the clipboard of Microsoft Windows, and this is ' \
|
|
38
|
+
'no Microsoft Windows.'
|
|
39
|
+
"""What is said when the clipboard of Windows is asked for elsewhere."""
|
|
40
|
+
|
|
41
|
+
NOT_OPENED = 'The clipboard is held by another program just now.'
|
|
42
|
+
"""What is said when the clipboard could not be opened at all."""
|
|
43
|
+
|
|
44
|
+
NO_MEMORY = 'There is not memory enough to copy the note.'
|
|
45
|
+
"""What is said when the memory for a copy could not be had."""
|
|
46
|
+
|
|
47
|
+
NOT_TAKEN = 'The clipboard did not take the copy of the note.'
|
|
48
|
+
"""What is said when the clipboard refused a shape of the copy."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def byte_length(text: str) -> int:
|
|
52
|
+
"""Return how many bytes a text is when it is written as UTF-8."""
|
|
53
|
+
return len(text.encode('utf-8'))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def cf_html(fragment: str) -> str:
|
|
57
|
+
"""Return the HTML Format payload that Windows takes a copy in.
|
|
58
|
+
|
|
59
|
+
The header says where the document begins and ends and where the
|
|
60
|
+
piece of it that is pasted begins and ends. All four are counted
|
|
61
|
+
in bytes rather than in characters, so a note holding anything
|
|
62
|
+
but plain ASCII is counted as the UTF-8 that it is written as.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
fragment: The HTML of the note, as it goes inside a body.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
The payload, header and document together.
|
|
69
|
+
"""
|
|
70
|
+
head = byte_length(HEADER.format(start=0, end=0, first=0, last=0))
|
|
71
|
+
first = head + byte_length(OPENING)
|
|
72
|
+
last = first + byte_length(fragment)
|
|
73
|
+
return HEADER.format(start=head, end=last + byte_length(CLOSING),
|
|
74
|
+
first=first, last=last) + OPENING + fragment + \
|
|
75
|
+
CLOSING
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def html_bytes(fragment: str) -> bytes:
|
|
79
|
+
"""Return the formatted shape of a copy as Windows holds it."""
|
|
80
|
+
return cf_html(fragment).encode('utf-8') + b'\0'
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def text_bytes(text: str) -> bytes:
|
|
84
|
+
"""Return the plain shape of a copy as Windows holds it."""
|
|
85
|
+
return text.encode('utf-16-le') + b'\0\0'
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def declare_memory(kernel32: ctypes.CDLL) -> None:
|
|
89
|
+
"""Tell ctypes what the functions that hand out memory take."""
|
|
90
|
+
kernel32.GlobalAlloc.restype = ctypes.c_void_p
|
|
91
|
+
kernel32.GlobalAlloc.argtypes = [ctypes.c_uint, ctypes.c_size_t]
|
|
92
|
+
kernel32.GlobalLock.restype = ctypes.c_void_p
|
|
93
|
+
kernel32.GlobalLock.argtypes = [ctypes.c_void_p]
|
|
94
|
+
kernel32.GlobalUnlock.argtypes = [ctypes.c_void_p]
|
|
95
|
+
kernel32.GlobalFree.argtypes = [ctypes.c_void_p]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def moveable_memory(kernel32: ctypes.CDLL, data: bytes) -> int:
|
|
99
|
+
"""Return a handle to memory holding the given bytes.
|
|
100
|
+
|
|
101
|
+
The clipboard takes over the memory it is given, so what is
|
|
102
|
+
allocated here is moveable, as the clipboard asks, and is not
|
|
103
|
+
freed again by notesmgr once the clipboard has it. Memory that
|
|
104
|
+
was had but could not be written to is freed here instead.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
kernel32: The library of Windows that hands out memory.
|
|
108
|
+
data: What the memory is to hold.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
The handle to give to the clipboard.
|
|
112
|
+
|
|
113
|
+
Raises:
|
|
114
|
+
NotesmgrError: The memory could not be had or written to.
|
|
115
|
+
"""
|
|
116
|
+
declare_memory(kernel32)
|
|
117
|
+
handle = kernel32.GlobalAlloc(MOVEABLE, len(data))
|
|
118
|
+
if not handle:
|
|
119
|
+
raise NotesmgrError(NO_MEMORY)
|
|
120
|
+
place = kernel32.GlobalLock(handle)
|
|
121
|
+
if not place:
|
|
122
|
+
kernel32.GlobalFree(handle)
|
|
123
|
+
raise NotesmgrError(NO_MEMORY)
|
|
124
|
+
ctypes.memmove(place, data, len(data))
|
|
125
|
+
kernel32.GlobalUnlock(handle)
|
|
126
|
+
return int(handle)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def hand_over(user32: ctypes.CDLL, kernel32: ctypes.CDLL, named: int,
|
|
130
|
+
data: bytes) -> None:
|
|
131
|
+
"""Give one shape of a copy to the clipboard, which then owns it.
|
|
132
|
+
|
|
133
|
+
Memory that the clipboard did not take is still notesmgr's own,
|
|
134
|
+
and is freed again rather than left behind.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
user32: The library of Windows that owns the clipboard.
|
|
138
|
+
kernel32: The library of Windows that hands out memory.
|
|
139
|
+
named: What the clipboard calls the shape.
|
|
140
|
+
data: What the shape holds.
|
|
141
|
+
|
|
142
|
+
Raises:
|
|
143
|
+
NotesmgrError: The memory for the shape could not be had, or
|
|
144
|
+
the clipboard did not take it.
|
|
145
|
+
"""
|
|
146
|
+
handle = moveable_memory(kernel32, data)
|
|
147
|
+
if not user32.SetClipboardData(named, handle):
|
|
148
|
+
kernel32.GlobalFree(handle)
|
|
149
|
+
raise NotesmgrError(NOT_TAKEN)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def write_clipboard(user32: ctypes.CDLL, kernel32: ctypes.CDLL,
|
|
153
|
+
shapes: Sequence[tuple[int, bytes]]) -> None:
|
|
154
|
+
"""Put every shape of one copy on the clipboard in one session.
|
|
155
|
+
|
|
156
|
+
The clipboard is emptied once and then filled with every shape,
|
|
157
|
+
because opening it a second time would take away what the first
|
|
158
|
+
session put there.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
user32: The library of Windows that owns the clipboard.
|
|
162
|
+
kernel32: The library of Windows that hands out memory.
|
|
163
|
+
shapes: What each shape of the copy is called and holds.
|
|
164
|
+
|
|
165
|
+
Raises:
|
|
166
|
+
NotesmgrError: The clipboard could not be opened, the memory
|
|
167
|
+
for a shape could not be had, or the clipboard did not
|
|
168
|
+
take a shape.
|
|
169
|
+
"""
|
|
170
|
+
user32.SetClipboardData.restype = ctypes.c_void_p
|
|
171
|
+
user32.SetClipboardData.argtypes = [ctypes.c_uint, ctypes.c_void_p]
|
|
172
|
+
if not user32.OpenClipboard(None):
|
|
173
|
+
raise NotesmgrError(NOT_OPENED)
|
|
174
|
+
try:
|
|
175
|
+
user32.EmptyClipboard()
|
|
176
|
+
for named, data in shapes:
|
|
177
|
+
hand_over(user32, kernel32, named, data)
|
|
178
|
+
finally:
|
|
179
|
+
user32.CloseClipboard()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def copy_to_clipboard(payload: RichText) -> None:
|
|
183
|
+
"""Put a formatted copy of a note on the clipboard of Windows.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
payload: The copy to put there.
|
|
187
|
+
|
|
188
|
+
Raises:
|
|
189
|
+
NotesmgrError: This is no Windows, or Windows did not take
|
|
190
|
+
the copy.
|
|
191
|
+
"""
|
|
192
|
+
if sys.platform != 'win32':
|
|
193
|
+
raise NotesmgrError(NOT_WINDOWS)
|
|
194
|
+
user32 = ctypes.windll.user32
|
|
195
|
+
named = user32.RegisterClipboardFormatW(HTML_FORMAT)
|
|
196
|
+
write_clipboard(user32, ctypes.windll.kernel32,
|
|
197
|
+
((named, html_bytes(payload.html)),
|
|
198
|
+
(UNICODE_TEXT, text_bytes(payload.text))))
|
notesmgr/cmd_line.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#! /usr/local/bin/python3
|
|
2
|
+
"""The command line of the notesmgr application."""
|
|
3
|
+
|
|
4
|
+
# Copyright (c) 2026 Tom Björkholm
|
|
5
|
+
# MIT License
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import NamedTuple, Optional
|
|
10
|
+
import argcomplete
|
|
11
|
+
|
|
12
|
+
PROGRAM_NAME = 'notesmgr'
|
|
13
|
+
"""Name that the command line help calls this program."""
|
|
14
|
+
|
|
15
|
+
DESCRIPTION = 'Manage small notes and AI prompts.'
|
|
16
|
+
"""What the command line help says this program is for."""
|
|
17
|
+
|
|
18
|
+
FOLDER_HELP = 'Project folder to open at start up.'
|
|
19
|
+
"""What the command line help says about the project folder."""
|
|
20
|
+
|
|
21
|
+
VERSION_HELP = 'Report the versions of notesmgr and of what it uses.'
|
|
22
|
+
"""What the command line help says about the version flag."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class CommandLine(NamedTuple):
|
|
26
|
+
"""What the command line asked notesmgr to do."""
|
|
27
|
+
|
|
28
|
+
project: Optional[Path]
|
|
29
|
+
show_version: bool
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def argument_parser() -> argparse.ArgumentParser:
|
|
33
|
+
"""Return the parser that reads the notesmgr command line."""
|
|
34
|
+
parser = argparse.ArgumentParser(prog=PROGRAM_NAME,
|
|
35
|
+
description=DESCRIPTION)
|
|
36
|
+
parser.add_argument('project', nargs='?', default=None, type=Path,
|
|
37
|
+
metavar='PROJECT_FOLDER', help=FOLDER_HELP)
|
|
38
|
+
parser.add_argument('--version', dest='show_version', default=False,
|
|
39
|
+
action='store_true', help=VERSION_HELP)
|
|
40
|
+
return parser
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def parse_command_line(argv: Optional[list[str]] = None) -> CommandLine:
|
|
44
|
+
"""Return what the given command line, or sys.argv, asked for.
|
|
45
|
+
|
|
46
|
+
Whether the named folder holds a project is not decided here. A
|
|
47
|
+
graphical application says that in a window of its own, and not on
|
|
48
|
+
an error stream that a program started from a desktop has not got.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
argv: Command line arguments, or None for the ones this
|
|
52
|
+
program was started with.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
What was asked for.
|
|
56
|
+
"""
|
|
57
|
+
parser = argument_parser()
|
|
58
|
+
argcomplete.autocomplete(parser)
|
|
59
|
+
parsed = parser.parse_args(argv)
|
|
60
|
+
assert isinstance(parsed.project, (Path, type(None)))
|
|
61
|
+
assert isinstance(parsed.show_version, bool)
|
|
62
|
+
return CommandLine(project=parsed.project,
|
|
63
|
+
show_version=parsed.show_version)
|
notesmgr/commands.py
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
#! /usr/local/bin/python3
|
|
2
|
+
"""What the buttons and the menu entries do to the open project."""
|
|
3
|
+
|
|
4
|
+
# Copyright (c) 2026 Tom Björkholm
|
|
5
|
+
# MIT License
|
|
6
|
+
|
|
7
|
+
import tkinter
|
|
8
|
+
from functools import partial
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Callable, NamedTuple, Optional, Sequence, Union
|
|
11
|
+
from notesmgr import folder_ops, note_ops
|
|
12
|
+
from notesmgr.actions import Selected, offered
|
|
13
|
+
from notesmgr.config import NoteExtension
|
|
14
|
+
from notesmgr.dialogs import NameFolder, ask_name, ask_name_folder, \
|
|
15
|
+
ask_yes_no, show_error, show_info
|
|
16
|
+
from notesmgr.editor_command import launch_editor
|
|
17
|
+
from notesmgr.errors import NotesmgrError
|
|
18
|
+
from notesmgr.explorer_drop import Drop, drop_item
|
|
19
|
+
from notesmgr.note_file import is_plain_note, note_stem
|
|
20
|
+
from notesmgr.project import Project, folder_paths, is_project
|
|
21
|
+
from notesmgr.session import Session
|
|
22
|
+
|
|
23
|
+
NOTE_TITLE = 'Note'
|
|
24
|
+
"""What a window asking about or reporting about a note is called."""
|
|
25
|
+
|
|
26
|
+
FOLDER_TITLE = 'Folder'
|
|
27
|
+
"""What a window asking about a folder of the project is called."""
|
|
28
|
+
|
|
29
|
+
DUPLICATE_TITLE = 'Duplicate note'
|
|
30
|
+
"""What the window asking where to copy a note is called."""
|
|
31
|
+
|
|
32
|
+
ASK_NEW_NOTE = 'What is the new note to be called?'
|
|
33
|
+
"""What the user is asked when making a note."""
|
|
34
|
+
|
|
35
|
+
ASK_DELETE_NOTE = 'Move the note {name} to the trash?'
|
|
36
|
+
"""What the user is asked before a note is taken away."""
|
|
37
|
+
|
|
38
|
+
ASK_NEW_FOLDER = 'What is the new folder to be called?'
|
|
39
|
+
"""What the user is asked when making a folder."""
|
|
40
|
+
|
|
41
|
+
ASK_RENAME_FOLDER = 'What is the folder {name} to be called?'
|
|
42
|
+
"""What the user is asked when renaming a folder."""
|
|
43
|
+
|
|
44
|
+
ASK_DELETE_FOLDER = 'Move the folder {name} to the trash?'
|
|
45
|
+
"""What the user is asked before a folder is taken away."""
|
|
46
|
+
|
|
47
|
+
COPY_SUFFIX = ' copy'
|
|
48
|
+
"""What is put after the name of a note to name a copy of it."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def shown_folder(root: Path, folder: Path) -> str:
|
|
52
|
+
"""Return how a folder of a project is named to the user.
|
|
53
|
+
|
|
54
|
+
The root folder is named by itself and every folder below it by
|
|
55
|
+
the way down to it, so that two folders of the same name in
|
|
56
|
+
different places are told apart.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
root: The root folder of the project.
|
|
60
|
+
folder: The folder of the project to name.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
The name to show, which names the folder again through
|
|
64
|
+
folder_of() when the user has chosen it.
|
|
65
|
+
"""
|
|
66
|
+
return str(folder.relative_to(root.parent))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def folder_of(root: Path, shown: str) -> Path:
|
|
70
|
+
"""Return the folder that a name shown to the user stands for."""
|
|
71
|
+
return root.parent / shown
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class WindowHooks(NamedTuple):
|
|
75
|
+
"""What the commands ask the window that holds them to do.
|
|
76
|
+
|
|
77
|
+
The window owns the tree and the menus, so it is what shows the
|
|
78
|
+
project again once a command has changed it, and what offers the
|
|
79
|
+
actions that can be used on what is selected now.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
reopen: Callable[[Optional[Path]], None]
|
|
83
|
+
offer: Callable[[frozenset[str]], None]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class Commands:
|
|
87
|
+
"""What the buttons and the menu entries do to the open project.
|
|
88
|
+
|
|
89
|
+
Each command asks the user what it needs, has the model do it,
|
|
90
|
+
and has the window show the project as it now stands with what
|
|
91
|
+
was made or moved selected in it. What could not be done is put
|
|
92
|
+
to the user in the words the model said it in, and the project
|
|
93
|
+
is left exactly as it was.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
def __init__(self, window: Union[tkinter.Tk, tkinter.Toplevel],
|
|
97
|
+
session: Session, hooks: WindowHooks) -> None:
|
|
98
|
+
"""Get ready to act upon a project, with nothing selected yet.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
window: The window that the questions are asked over.
|
|
102
|
+
session: What the run knows, which is the open project.
|
|
103
|
+
hooks: What the commands ask the window to do.
|
|
104
|
+
"""
|
|
105
|
+
self.window = window
|
|
106
|
+
self.session = session
|
|
107
|
+
self.hooks = hooks
|
|
108
|
+
self.selection: Optional[Path] = None
|
|
109
|
+
|
|
110
|
+
def select(self, path: Optional[Path]) -> None:
|
|
111
|
+
"""Take what the explorer has selected as what to act upon."""
|
|
112
|
+
self.selection = path
|
|
113
|
+
|
|
114
|
+
def note_shown(self, shown: bool) -> None:
|
|
115
|
+
"""Offer afresh what can be done with what is selected.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
shown: Whether the panel has a note to act on, which a
|
|
119
|
+
program other than notesmgr can take away at any time.
|
|
120
|
+
"""
|
|
121
|
+
self.hooks.offer(offered(self.selected(shown)))
|
|
122
|
+
|
|
123
|
+
def selected(self, shown: bool) -> Selected:
|
|
124
|
+
"""Return what is selected, as far as it says what can be done."""
|
|
125
|
+
path = self.selection
|
|
126
|
+
plain = shown and path is not None and is_plain_note(path.name)
|
|
127
|
+
return Selected(note=shown, plain=plain,
|
|
128
|
+
project=self.session.project is not None,
|
|
129
|
+
folder=self.chosen_folder() is not None)
|
|
130
|
+
|
|
131
|
+
def report_error(self, message: str) -> None:
|
|
132
|
+
"""Tell the user what could not be done, and why it could not."""
|
|
133
|
+
show_error(self.window, NOTE_TITLE, message)
|
|
134
|
+
|
|
135
|
+
def report_notice(self, message: str) -> None:
|
|
136
|
+
"""Tell the user of something that was done in another way."""
|
|
137
|
+
show_info(self.window, NOTE_TITLE, message)
|
|
138
|
+
|
|
139
|
+
def chosen_folder(self) -> Optional[Path]:
|
|
140
|
+
"""Return the folder that is selected, None when none is.
|
|
141
|
+
|
|
142
|
+
The root folder of a project is the project itself rather
|
|
143
|
+
than a folder in it to be renamed or taken away, so it
|
|
144
|
+
counts as no folder here.
|
|
145
|
+
"""
|
|
146
|
+
path = self.selection
|
|
147
|
+
if path is None or not path.is_dir() or is_project(path):
|
|
148
|
+
return None
|
|
149
|
+
return path
|
|
150
|
+
|
|
151
|
+
def note_folder(self) -> Optional[Path]:
|
|
152
|
+
"""Return the folder that a new note or folder goes into.
|
|
153
|
+
|
|
154
|
+
That is the selected folder, the folder of the selected note,
|
|
155
|
+
and the root folder of the project while nothing is selected.
|
|
156
|
+
"""
|
|
157
|
+
project = self.session.project
|
|
158
|
+
if project is None:
|
|
159
|
+
return None
|
|
160
|
+
path = self.selection
|
|
161
|
+
if path is None:
|
|
162
|
+
return project.root
|
|
163
|
+
return path if path.is_dir() else path.parent
|
|
164
|
+
|
|
165
|
+
def plain_note(self) -> Optional[Path]:
|
|
166
|
+
"""Return the selected note that is no template, None for none."""
|
|
167
|
+
path = self.selection
|
|
168
|
+
if path is None or not is_plain_note(path.name) or not path.is_file():
|
|
169
|
+
return None
|
|
170
|
+
return path
|
|
171
|
+
|
|
172
|
+
def _extension(self) -> Optional[NoteExtension]:
|
|
173
|
+
"""Return the extension the notes of the open project carry."""
|
|
174
|
+
config = self.session.config()
|
|
175
|
+
return None if config is None else config.file_extension
|
|
176
|
+
|
|
177
|
+
def done(self, doing: Callable[[], Optional[Path]]) -> Optional[Path]:
|
|
178
|
+
"""Do what changes the project, and show it as it now stands.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
doing: What is to be done, giving what is to be selected
|
|
182
|
+
afterwards and None when it leaves nothing to select.
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
What was made or moved, None when the operation could not
|
|
186
|
+
be done at all or left nothing to select.
|
|
187
|
+
"""
|
|
188
|
+
try:
|
|
189
|
+
given = doing()
|
|
190
|
+
except NotesmgrError as error:
|
|
191
|
+
self.report_error(str(error))
|
|
192
|
+
return None
|
|
193
|
+
self.hooks.reopen(given)
|
|
194
|
+
return given
|
|
195
|
+
|
|
196
|
+
def edit(self, note: Path) -> None:
|
|
197
|
+
"""Open a note in the editor that the project is configured with."""
|
|
198
|
+
config = self.session.config()
|
|
199
|
+
if config is None:
|
|
200
|
+
return
|
|
201
|
+
try:
|
|
202
|
+
launch_editor(config.editor, note)
|
|
203
|
+
except NotesmgrError as error:
|
|
204
|
+
self.report_error(str(error))
|
|
205
|
+
|
|
206
|
+
def new_note(self) -> None:
|
|
207
|
+
"""Make a note from the template and open it in the editor."""
|
|
208
|
+
folder = self.note_folder()
|
|
209
|
+
extension = self._extension()
|
|
210
|
+
if folder is None or extension is None:
|
|
211
|
+
return
|
|
212
|
+
typed = ask_name(self.window, NOTE_TITLE, ASK_NEW_NOTE)
|
|
213
|
+
if typed is None:
|
|
214
|
+
return
|
|
215
|
+
made = self.done(partial(note_ops.new_note, folder, typed, extension))
|
|
216
|
+
if made is not None:
|
|
217
|
+
self.edit(made)
|
|
218
|
+
|
|
219
|
+
def duplicate_note(self) -> None:
|
|
220
|
+
"""Copy the selected note into a folder of the project."""
|
|
221
|
+
note = self.plain_note()
|
|
222
|
+
project = self.session.project
|
|
223
|
+
extension = self._extension()
|
|
224
|
+
if note is None or project is None or extension is None:
|
|
225
|
+
return
|
|
226
|
+
given = self._ask_copy(note, project)
|
|
227
|
+
if given is not None:
|
|
228
|
+
folder = folder_of(project.root, given.folder)
|
|
229
|
+
self.done(partial(note_ops.duplicate_note, note, folder,
|
|
230
|
+
given.name, extension))
|
|
231
|
+
|
|
232
|
+
def _ask_copy(self, note: Path, project: Project) \
|
|
233
|
+
-> Optional[NameFolder]:
|
|
234
|
+
"""Ask what a copy of a note is called and where it is put."""
|
|
235
|
+
root = project.root
|
|
236
|
+
given = NameFolder(name=note_stem(note.name) + COPY_SUFFIX,
|
|
237
|
+
folder=shown_folder(root, note.parent))
|
|
238
|
+
return ask_name_folder(self.window, DUPLICATE_TITLE, given,
|
|
239
|
+
self._folder_names(project))
|
|
240
|
+
|
|
241
|
+
@staticmethod
|
|
242
|
+
def _folder_names(project: Project) -> Sequence[str]:
|
|
243
|
+
"""Return the folders of a project as they are named to the user."""
|
|
244
|
+
return [shown_folder(project.root, path)
|
|
245
|
+
for path in folder_paths(project.tree)]
|
|
246
|
+
|
|
247
|
+
def delete_note(self) -> None:
|
|
248
|
+
"""Move the selected note to the trash, once the user is sure."""
|
|
249
|
+
note = self.plain_note()
|
|
250
|
+
if note is None:
|
|
251
|
+
return
|
|
252
|
+
if ask_yes_no(self.window, NOTE_TITLE,
|
|
253
|
+
ASK_DELETE_NOTE.format(name=note.name)):
|
|
254
|
+
self.done(partial(note_ops.delete_note, note))
|
|
255
|
+
|
|
256
|
+
def move_up(self) -> None:
|
|
257
|
+
"""Move the selected note one place up in its folder."""
|
|
258
|
+
self.move(-1)
|
|
259
|
+
|
|
260
|
+
def move_down(self) -> None:
|
|
261
|
+
"""Move the selected note one place down in its folder."""
|
|
262
|
+
self.move(1)
|
|
263
|
+
|
|
264
|
+
def move(self, offset: int) -> None:
|
|
265
|
+
"""Move the selected note so many places in its folder."""
|
|
266
|
+
note = self.plain_note()
|
|
267
|
+
if note is not None:
|
|
268
|
+
self.done(partial(note_ops.shift_note, note, offset))
|
|
269
|
+
|
|
270
|
+
def drop(self, item: Path, drop: Drop) -> None:
|
|
271
|
+
"""Move what was dragged in the tree to where it was dropped.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
item: The note or the folder that was dragged.
|
|
275
|
+
drop: Where it was dropped, as the tree worked it out.
|
|
276
|
+
"""
|
|
277
|
+
self.done(partial(drop_item, item, drop))
|
|
278
|
+
|
|
279
|
+
def new_folder(self) -> None:
|
|
280
|
+
"""Make a folder in the folder that is selected."""
|
|
281
|
+
parent = self.note_folder()
|
|
282
|
+
extension = self._extension()
|
|
283
|
+
if parent is None or extension is None:
|
|
284
|
+
return
|
|
285
|
+
typed = ask_name(self.window, FOLDER_TITLE, ASK_NEW_FOLDER)
|
|
286
|
+
if typed is None:
|
|
287
|
+
return
|
|
288
|
+
self.done(partial(folder_ops.new_folder, parent, typed, extension))
|
|
289
|
+
|
|
290
|
+
def rename_folder(self) -> None:
|
|
291
|
+
"""Give the selected folder another name."""
|
|
292
|
+
folder = self.chosen_folder()
|
|
293
|
+
if folder is None:
|
|
294
|
+
return
|
|
295
|
+
asked = ASK_RENAME_FOLDER.format(name=folder.name)
|
|
296
|
+
typed = ask_name(self.window, FOLDER_TITLE, asked, folder.name)
|
|
297
|
+
if typed is not None:
|
|
298
|
+
self.done(partial(folder_ops.rename_folder, folder, typed))
|
|
299
|
+
|
|
300
|
+
def delete_folder(self) -> None:
|
|
301
|
+
"""Move the selected folder to the trash, once the user is sure."""
|
|
302
|
+
folder = self.chosen_folder()
|
|
303
|
+
if folder is None:
|
|
304
|
+
return
|
|
305
|
+
if ask_yes_no(self.window, FOLDER_TITLE,
|
|
306
|
+
ASK_DELETE_FOLDER.format(name=folder.name)):
|
|
307
|
+
self.done(partial(folder_ops.delete_folder, folder))
|