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
notesmgr/__init__.py
ADDED
notesmgr/__main__.py
ADDED
notesmgr/actions.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
#! /usr/local/bin/python3
|
|
2
|
+
"""What can be done with what is selected, and when it can be done."""
|
|
3
|
+
|
|
4
|
+
# Copyright (c) 2026 Tom Björkholm
|
|
5
|
+
# MIT License
|
|
6
|
+
|
|
7
|
+
from typing import NamedTuple
|
|
8
|
+
|
|
9
|
+
COPY_RAW = 'Copy raw'
|
|
10
|
+
"""What the action that copies the note as it is written is called."""
|
|
11
|
+
|
|
12
|
+
COPY_FORMATTED = 'Copy formatted'
|
|
13
|
+
"""What the action that copies the note formatted is called."""
|
|
14
|
+
|
|
15
|
+
DUPLICATE = 'Duplicate'
|
|
16
|
+
"""What the action that copies the note into the project is called."""
|
|
17
|
+
|
|
18
|
+
EDIT = 'Edit'
|
|
19
|
+
"""What the action that opens the note in an editor is called."""
|
|
20
|
+
|
|
21
|
+
NEW = 'New'
|
|
22
|
+
"""What the action that makes another note is called."""
|
|
23
|
+
|
|
24
|
+
DELETE = 'Delete'
|
|
25
|
+
"""What the action that takes the note away is called."""
|
|
26
|
+
|
|
27
|
+
MOVE_UP = 'Up'
|
|
28
|
+
"""What the action that moves the note one place up is called."""
|
|
29
|
+
|
|
30
|
+
MOVE_DOWN = 'Down'
|
|
31
|
+
"""What the action that moves the note one place down is called."""
|
|
32
|
+
|
|
33
|
+
NEW_FOLDER = 'New folder…'
|
|
34
|
+
"""What the action that makes another folder is called."""
|
|
35
|
+
|
|
36
|
+
RENAME_FOLDER = 'Rename folder…'
|
|
37
|
+
"""What the action that gives a folder another name is called."""
|
|
38
|
+
|
|
39
|
+
DELETE_FOLDER = 'Delete folder'
|
|
40
|
+
"""What the action that takes an empty folder away is called."""
|
|
41
|
+
|
|
42
|
+
ON_NOTE = (COPY_RAW, COPY_FORMATTED, EDIT)
|
|
43
|
+
"""What can be done with any note that is shown, template or not."""
|
|
44
|
+
|
|
45
|
+
ON_PLAIN = (DUPLICATE, DELETE, MOVE_UP, MOVE_DOWN)
|
|
46
|
+
"""What can be done with a note that is no template of a folder."""
|
|
47
|
+
|
|
48
|
+
ON_PROJECT = (NEW, NEW_FOLDER)
|
|
49
|
+
"""What can be done as long as there is a project to do it in."""
|
|
50
|
+
|
|
51
|
+
ON_FOLDER = (RENAME_FOLDER, DELETE_FOLDER)
|
|
52
|
+
"""What can be done with a folder that is no root of a project."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Selected(NamedTuple):
|
|
56
|
+
"""What is selected, as far as it says what can be done with it.
|
|
57
|
+
|
|
58
|
+
The panel knows whether there is a note to act on, because a note
|
|
59
|
+
can be taken away by another program while it is shown, and the
|
|
60
|
+
rest follows from the item that the explorer has selected.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
note: bool = False
|
|
64
|
+
plain: bool = False
|
|
65
|
+
project: bool = False
|
|
66
|
+
folder: bool = False
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
NOTHING = Selected()
|
|
70
|
+
"""What is selected while nothing at all is."""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def offered(selected: Selected) -> frozenset[str]:
|
|
74
|
+
"""Return what can be done with what is selected.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
selected: What the explorer has selected now.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
The actions that the buttons and the menu entries offer,
|
|
81
|
+
every other action of the application being greyed out.
|
|
82
|
+
"""
|
|
83
|
+
groups = ((selected.note, ON_NOTE), (selected.plain, ON_PLAIN),
|
|
84
|
+
(selected.project, ON_PROJECT), (selected.folder, ON_FOLDER))
|
|
85
|
+
return frozenset(action for allowed, group in groups if allowed
|
|
86
|
+
for action in group)
|
notesmgr/application.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
#! /usr/local/bin/python3
|
|
2
|
+
"""Start-up of the notesmgr application."""
|
|
3
|
+
|
|
4
|
+
# Copyright (c) 2026 Tom Björkholm
|
|
5
|
+
# MIT License
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
import tkinter
|
|
9
|
+
from contextlib import suppress
|
|
10
|
+
from functools import partial
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Optional
|
|
13
|
+
from notesmgr.cmd_line import parse_command_line
|
|
14
|
+
from notesmgr.console import console_or_window
|
|
15
|
+
from notesmgr.errors import NotesmgrError
|
|
16
|
+
from notesmgr.failure_report import report_failures
|
|
17
|
+
from notesmgr.main_window import MainWindow
|
|
18
|
+
from notesmgr.version_info import version_report
|
|
19
|
+
|
|
20
|
+
NO_WINDOW = 'notesmgr cannot open a window: {error}'
|
|
21
|
+
"""What is said when there is no display to show a window on."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def main(argv: Optional[list[str]] = None) -> None:
|
|
25
|
+
"""Run the notesmgr graphical user interface until the user quits.
|
|
26
|
+
|
|
27
|
+
A command line asking for version information is answered on the
|
|
28
|
+
standard output stream, and no window is opened for it. A command
|
|
29
|
+
line naming a project folder opens that project, and says in a
|
|
30
|
+
window of its own when the folder holds no project. What the
|
|
31
|
+
command line has to say is shown in a window where there is no
|
|
32
|
+
console to say it on.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
argv: Command line arguments, or None for the ones this
|
|
36
|
+
program was started with.
|
|
37
|
+
"""
|
|
38
|
+
with console_or_window():
|
|
39
|
+
command_line = parse_command_line(argv)
|
|
40
|
+
if command_line.show_version:
|
|
41
|
+
report_versions()
|
|
42
|
+
return
|
|
43
|
+
run_window(command_line.project)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def report_versions() -> None:
|
|
47
|
+
"""Write the version report on the standard output stream.
|
|
48
|
+
|
|
49
|
+
Raises:
|
|
50
|
+
SystemExit: The report cannot be made, which is said on the
|
|
51
|
+
standard error stream in words rather than as a traceback,
|
|
52
|
+
and is said before leaving, as a command line error is, so
|
|
53
|
+
that a window can show it where there is no console.
|
|
54
|
+
"""
|
|
55
|
+
try:
|
|
56
|
+
version_report(sys.stdout)
|
|
57
|
+
except NotesmgrError as error:
|
|
58
|
+
print(error, file=sys.stderr)
|
|
59
|
+
sys.exit(1)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def new_root() -> tkinter.Tk:
|
|
63
|
+
"""Return the root window, or leave saying why there can be none.
|
|
64
|
+
|
|
65
|
+
Raises:
|
|
66
|
+
SystemExit: There is no display, which a program started from
|
|
67
|
+
a terminal with no display is told in one line.
|
|
68
|
+
"""
|
|
69
|
+
try:
|
|
70
|
+
return tkinter.Tk()
|
|
71
|
+
except tkinter.TclError as error:
|
|
72
|
+
sys.exit(NO_WINDOW.format(error=error))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def open_window(root: tkinter.Tk, project: Optional[Path]) -> None:
|
|
76
|
+
"""Fill the root window, and open the named project in it, if any."""
|
|
77
|
+
window = MainWindow(root)
|
|
78
|
+
if project is not None:
|
|
79
|
+
window.load_project(project)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def run_window(project: Optional[Path]) -> None:
|
|
83
|
+
"""Show the main window until the user quits.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
project: The project to open at start, None for none at all.
|
|
87
|
+
"""
|
|
88
|
+
root = new_root()
|
|
89
|
+
report_failures(root).run(partial(open_window, root, project))
|
|
90
|
+
root.mainloop()
|
|
91
|
+
# Quitting through the menu has already destroyed the root window.
|
|
92
|
+
with suppress(tkinter.TclError):
|
|
93
|
+
root.destroy()
|
notesmgr/button_row.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
#! /usr/local/bin/python3
|
|
2
|
+
"""The row of buttons above the note, and how it is made to fit."""
|
|
3
|
+
|
|
4
|
+
# Copyright (c) 2026 Tom Björkholm
|
|
5
|
+
# MIT License
|
|
6
|
+
|
|
7
|
+
import tkinter
|
|
8
|
+
from tkinter import ttk
|
|
9
|
+
from typing import AbstractSet, Callable, NamedTuple, Optional, Sequence
|
|
10
|
+
from notesmgr.menu_bar import entry_state
|
|
11
|
+
|
|
12
|
+
GAP = 4
|
|
13
|
+
"""Pixels left between two buttons that stand side by side."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ButtonSpec(NamedTuple):
|
|
17
|
+
"""One button of the row, and what pressing it does.
|
|
18
|
+
|
|
19
|
+
A button that is described with no command has nothing to do,
|
|
20
|
+
and is greyed out whatever is selected, so that a row can be
|
|
21
|
+
the whole row before every one of its buttons is wired up.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
label: str
|
|
25
|
+
command: Optional[Callable[[], None]] = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def grid_width(widths: Sequence[int], columns: int, gap: int) -> int:
|
|
29
|
+
"""Return how wide a grid of buttons of these widths is.
|
|
30
|
+
|
|
31
|
+
The buttons are placed row by row, so the buttons of one column
|
|
32
|
+
are every columns-th of them, and a column is as wide as the
|
|
33
|
+
widest button standing in it.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
widths: How wide each button is, in the order they are shown.
|
|
37
|
+
columns: How many buttons stand side by side.
|
|
38
|
+
gap: Pixels left beside each button.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
The width the grid needs, and nothing for no buttons at all.
|
|
42
|
+
"""
|
|
43
|
+
if columns < 1:
|
|
44
|
+
return 0
|
|
45
|
+
return sum(max(widths[column::columns]) + gap
|
|
46
|
+
for column in range(min(columns, len(widths))))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def fitting_columns(widths: Sequence[int], available: int, gap: int) -> int:
|
|
50
|
+
"""Return how many buttons to put side by side in a given width.
|
|
51
|
+
|
|
52
|
+
The buttons are spread over the fewest rows that fit, so that a
|
|
53
|
+
wide panel shows one row and a narrow one shows several rows of
|
|
54
|
+
about the same length rather than one long row and a short one.
|
|
55
|
+
A width too small for even one button still gives one column,
|
|
56
|
+
because a button cut off at the edge is better than none at all.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
widths: How wide each button is, in the order they are shown.
|
|
60
|
+
available: Pixels the row has to lay the buttons out in.
|
|
61
|
+
gap: Pixels left beside each button.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
How many buttons stand side by side, at least one.
|
|
65
|
+
"""
|
|
66
|
+
for rows in range(1, len(widths) + 1):
|
|
67
|
+
columns = (len(widths) + rows - 1) // rows
|
|
68
|
+
if grid_width(widths, columns, gap) <= available:
|
|
69
|
+
return columns
|
|
70
|
+
return 1
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class ButtonRow:
|
|
74
|
+
"""The buttons above the note, in as few rows as the width allows.
|
|
75
|
+
|
|
76
|
+
A button is as wide as its text and the theme of the platform
|
|
77
|
+
make it, and the panel is as wide as the user makes the window,
|
|
78
|
+
so the buttons are laid out again whenever the width changes.
|
|
79
|
+
That way none of them is ever cut off at the edge of the panel,
|
|
80
|
+
at any size the window can be given.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
def __init__(self, parent: tkinter.Misc,
|
|
84
|
+
specs: Sequence[ButtonSpec]) -> None:
|
|
85
|
+
"""Build the described buttons in a frame of their own.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
parent: The widget that the row is placed in.
|
|
89
|
+
specs: The buttons, in the order they are shown.
|
|
90
|
+
"""
|
|
91
|
+
self.frame = ttk.Frame(parent)
|
|
92
|
+
self.specs = list(specs)
|
|
93
|
+
self.buttons = [self._built(spec) for spec in specs]
|
|
94
|
+
self.working = {spec.label: button
|
|
95
|
+
for spec, button in zip(specs, self.buttons)
|
|
96
|
+
if spec.command is not None}
|
|
97
|
+
self.columns = 0
|
|
98
|
+
self.lay_out(max(len(self.buttons), 1))
|
|
99
|
+
self.frame.bind('<Configure>', self._width_changed)
|
|
100
|
+
|
|
101
|
+
def _built(self, spec: ButtonSpec) -> ttk.Button:
|
|
102
|
+
"""Return one button of the row, greyed out to start with."""
|
|
103
|
+
button = ttk.Button(self.frame, text=spec.label,
|
|
104
|
+
state=entry_state(False))
|
|
105
|
+
if spec.command is not None:
|
|
106
|
+
button.configure(command=spec.command)
|
|
107
|
+
return button
|
|
108
|
+
|
|
109
|
+
def _width_changed(self, event: 'tkinter.Event[ttk.Frame]') -> None:
|
|
110
|
+
"""Lay the buttons out again when the row has another width."""
|
|
111
|
+
self.fit_into(event.width)
|
|
112
|
+
|
|
113
|
+
def widths(self) -> list[int]:
|
|
114
|
+
"""Return how wide each button of the row asks to be."""
|
|
115
|
+
return [button.winfo_reqwidth() for button in self.buttons]
|
|
116
|
+
|
|
117
|
+
def fit_into(self, available: int) -> None:
|
|
118
|
+
"""Lay the buttons out for a row of the given width.
|
|
119
|
+
|
|
120
|
+
Laying out a grid that is already laid out that way would
|
|
121
|
+
make Tk report another width and ask again, so it is only
|
|
122
|
+
done when another number of buttons fits now.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
available: Pixels the row has to lay the buttons out in.
|
|
126
|
+
"""
|
|
127
|
+
columns = fitting_columns(self.widths(), available, GAP)
|
|
128
|
+
if columns != self.columns:
|
|
129
|
+
self.lay_out(columns)
|
|
130
|
+
|
|
131
|
+
def lay_out(self, columns: int) -> None:
|
|
132
|
+
"""Put the buttons into a grid so many buttons wide."""
|
|
133
|
+
self.columns = columns
|
|
134
|
+
for index, button in enumerate(self.buttons):
|
|
135
|
+
button.grid(row=index // columns, column=index % columns,
|
|
136
|
+
padx=GAP // 2, pady=GAP // 2, sticky=tkinter.W)
|
|
137
|
+
|
|
138
|
+
def offer(self, labels: AbstractSet[str]) -> None:
|
|
139
|
+
"""Let the buttons that can be used now be pressed.
|
|
140
|
+
|
|
141
|
+
A button that was described with no command has nothing to do
|
|
142
|
+
when pressed, and stays greyed out however much the selection
|
|
143
|
+
would allow it.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
labels: What the buttons that can be used now say.
|
|
147
|
+
"""
|
|
148
|
+
for label, button in self.working.items():
|
|
149
|
+
button.configure(state=entry_state(label in labels))
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#! /usr/local/bin/python3
|
|
2
|
+
"""Putting a formatted copy of a note on the clipboard of a desktop."""
|
|
3
|
+
|
|
4
|
+
# Copyright (c) 2026 Tom Björkholm
|
|
5
|
+
# MIT License
|
|
6
|
+
|
|
7
|
+
import shutil
|
|
8
|
+
from typing import Optional, Sequence
|
|
9
|
+
from notesmgr.clipboard_tool import RichText, run_tool
|
|
10
|
+
from notesmgr.errors import NotesmgrError
|
|
11
|
+
|
|
12
|
+
XCLIP = ('xclip', '-selection', 'clipboard', '-t', 'text/html')
|
|
13
|
+
"""What puts HTML on the clipboard of an X11 desktop."""
|
|
14
|
+
|
|
15
|
+
WL_COPY = ('wl-copy', '-t', 'text/html')
|
|
16
|
+
"""What puts HTML on the clipboard of a Wayland desktop."""
|
|
17
|
+
|
|
18
|
+
TOOLS = (XCLIP, WL_COPY)
|
|
19
|
+
"""The programs that can take a formatted copy, the first one first.
|
|
20
|
+
|
|
21
|
+
A Wayland desktop usually answers for X11 programs as well, so the
|
|
22
|
+
one that works on both is the one that is tried first.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
NO_TOOL = 'Neither xclip nor wl-copy is installed, and one of them is ' \
|
|
26
|
+
'what puts formatted text on the clipboard of this desktop.'
|
|
27
|
+
"""What is said when the desktop has neither of the two programs."""
|
|
28
|
+
|
|
29
|
+
CHARSET = '<meta charset="utf-8">'
|
|
30
|
+
"""What says that the HTML of a copy is written as UTF-8."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def html_tool() -> Optional[Sequence[str]]:
|
|
34
|
+
"""Return the program that takes a formatted copy, None for none."""
|
|
35
|
+
return next((tool for tool in TOOLS
|
|
36
|
+
if shutil.which(tool[0]) is not None), None)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def copy_to_clipboard(payload: RichText) -> None:
|
|
40
|
+
"""Put a formatted copy of a note on the clipboard of a desktop.
|
|
41
|
+
|
|
42
|
+
The clipboard of X11 and of Wayland is owned by a program rather
|
|
43
|
+
than held by the system, and the program that owns it offers the
|
|
44
|
+
one shape it was given, so the formatted shape is what is put
|
|
45
|
+
there. That program goes on running to answer for the clipboard,
|
|
46
|
+
and is therefore not waited for.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
payload: The copy to put there.
|
|
50
|
+
|
|
51
|
+
Raises:
|
|
52
|
+
NotesmgrError: The desktop has no program that takes a
|
|
53
|
+
formatted copy, or the one it has refused this copy.
|
|
54
|
+
"""
|
|
55
|
+
tool = html_tool()
|
|
56
|
+
if tool is None:
|
|
57
|
+
raise NotesmgrError(NO_TOOL)
|
|
58
|
+
run_tool(tool, (CHARSET + payload.html).encode('utf-8'), capture=False)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#! /usr/local/bin/python3
|
|
2
|
+
"""Putting a formatted copy of a note on the pasteboard of macOS."""
|
|
3
|
+
|
|
4
|
+
# Copyright (c) 2026 Tom Björkholm
|
|
5
|
+
# MIT License
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from tempfile import TemporaryDirectory
|
|
9
|
+
from notesmgr.clipboard_tool import RichText, run_tool
|
|
10
|
+
|
|
11
|
+
TEXTUTIL = ('textutil', '-stdin', '-format', 'html', '-inputencoding',
|
|
12
|
+
'UTF-8', '-convert', 'rtf', '-stdout')
|
|
13
|
+
"""What turns the HTML of a note into the rich text that macOS pastes.
|
|
14
|
+
|
|
15
|
+
It is part of macOS itself, so a formatted copy costs notesmgr no
|
|
16
|
+
dependency of its own.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
SCRIPT = """on run argv
|
|
20
|
+
set rtf_path to POSIX file (item 1 of argv)
|
|
21
|
+
set txt_path to POSIX file (item 2 of argv)
|
|
22
|
+
set rtf_data to read rtf_path as «class RTF »
|
|
23
|
+
set plain_text to read txt_path as «class utf8»
|
|
24
|
+
set the clipboard to {«class RTF »:rtf_data, string:plain_text}
|
|
25
|
+
end run"""
|
|
26
|
+
"""What puts both shapes of a copy on the pasteboard in one write.
|
|
27
|
+
|
|
28
|
+
A second write would replace the first, so the rich text and the
|
|
29
|
+
plain text are given to the pasteboard together. They are read from
|
|
30
|
+
files rather than written into the script itself, because a note is
|
|
31
|
+
longer than a command line may be.
|
|
32
|
+
|
|
33
|
+
The names here are of two words each because AppleScript knows a
|
|
34
|
+
great many words of one: a variable called plain, for one, is a
|
|
35
|
+
term of the language already, and setting it is refused.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
OSASCRIPT = ('osascript', '-e', SCRIPT)
|
|
39
|
+
"""What runs that script, with the two files as its arguments."""
|
|
40
|
+
|
|
41
|
+
RTF_NAME = 'note.rtf'
|
|
42
|
+
"""What the file holding the rich text of a copy is called."""
|
|
43
|
+
|
|
44
|
+
TEXT_NAME = 'note.txt'
|
|
45
|
+
"""What the file holding the plain text of a copy is called."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def written(folder: Path, name: str, data: bytes) -> str:
|
|
49
|
+
"""Write one shape of a copy into a folder, and name the file.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
folder: The folder that the file is written into.
|
|
53
|
+
name: What the file is to be called.
|
|
54
|
+
data: What the file is to hold.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
The file, named as the script that reads it names it.
|
|
58
|
+
"""
|
|
59
|
+
path = folder / name
|
|
60
|
+
path.write_bytes(data)
|
|
61
|
+
return str(path)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def copy_to_clipboard(payload: RichText) -> None:
|
|
65
|
+
"""Put a formatted copy of a note on the pasteboard of macOS.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
payload: The copy to put there.
|
|
69
|
+
|
|
70
|
+
Raises:
|
|
71
|
+
NotesmgrError: macOS did not take the copy.
|
|
72
|
+
"""
|
|
73
|
+
rich = run_tool(TEXTUTIL, payload.html.encode('utf-8'))
|
|
74
|
+
with TemporaryDirectory() as made:
|
|
75
|
+
folder = Path(made)
|
|
76
|
+
argv = (*OSASCRIPT, written(folder, RTF_NAME, rich),
|
|
77
|
+
written(folder, TEXT_NAME, payload.text.encode('utf-8')))
|
|
78
|
+
run_tool(argv, b'')
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#! /usr/local/bin/python3
|
|
2
|
+
"""What a formatted copy of a note is, and running what takes it.
|
|
3
|
+
|
|
4
|
+
The clipboard of Tk carries plain text and nothing else, so a copy
|
|
5
|
+
that keeps its formatting is handed to a program of the system
|
|
6
|
+
instead. Running such a program and saying what it could not do is
|
|
7
|
+
the same on every platform, and is therefore done here.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
# Copyright (c) 2026 Tom Björkholm
|
|
11
|
+
# MIT License
|
|
12
|
+
|
|
13
|
+
import subprocess
|
|
14
|
+
from typing import NamedTuple, Optional, Sequence
|
|
15
|
+
from notesmgr.errors import NotesmgrError
|
|
16
|
+
|
|
17
|
+
NOT_INSTALLED = 'The program {program} is not installed, and it is what ' \
|
|
18
|
+
'puts a formatted copy on the clipboard here.'
|
|
19
|
+
"""What is said when the system has not got the program it needs."""
|
|
20
|
+
|
|
21
|
+
REFUSED = 'The program {program} did not take a formatted copy of the ' \
|
|
22
|
+
'note.\n{reason}'
|
|
23
|
+
"""What is said when the program was there but did not do it."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class RichText(NamedTuple):
|
|
27
|
+
"""A copy of a note in the shapes that a clipboard takes it in.
|
|
28
|
+
|
|
29
|
+
The HTML is what goes inside the body of a document rather than
|
|
30
|
+
a whole document, so that each platform can wrap it the way its
|
|
31
|
+
own clipboard asks for. The text is the note as it is written,
|
|
32
|
+
which is what a plain text field is pasted with.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
html: str
|
|
36
|
+
text: str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def said_by(output: Optional[bytes]) -> str:
|
|
40
|
+
"""Return what a program wrote about itself, as text.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
output: What the program wrote on its error output, None
|
|
44
|
+
when nothing was read from it.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
What it said, and nothing at all when it said nothing.
|
|
48
|
+
"""
|
|
49
|
+
return '' if not output else output.decode('utf-8', 'replace').strip()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def run_tool(argv: Sequence[str], data: bytes, capture: bool = True) -> bytes:
|
|
53
|
+
"""Run a program of the system, and return what it wrote.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
argv: The program to run and the arguments to give it.
|
|
57
|
+
data: What is written to the program on its input.
|
|
58
|
+
capture: Whether to read what the program writes. A program
|
|
59
|
+
that goes on running to own the clipboard, as the ones
|
|
60
|
+
of the X11 and the Wayland desktops do, never closes its
|
|
61
|
+
output, and waiting for that output to end would be
|
|
62
|
+
waiting for the program to end.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
What the program wrote, and nothing when it is not read.
|
|
66
|
+
|
|
67
|
+
Raises:
|
|
68
|
+
NotesmgrError: The program is not installed, could not be
|
|
69
|
+
started, or refused to do it.
|
|
70
|
+
"""
|
|
71
|
+
where = subprocess.PIPE if capture else subprocess.DEVNULL
|
|
72
|
+
try:
|
|
73
|
+
done = subprocess.run(argv, input=data, stdout=where, stderr=where,
|
|
74
|
+
check=True)
|
|
75
|
+
except FileNotFoundError as error:
|
|
76
|
+
raise NotesmgrError(NOT_INSTALLED.format(program=argv[0])) from error
|
|
77
|
+
except subprocess.CalledProcessError as error:
|
|
78
|
+
reason = said_by(error.stderr) or str(error)
|
|
79
|
+
raise NotesmgrError(REFUSED.format(program=argv[0],
|
|
80
|
+
reason=reason)) from error
|
|
81
|
+
except OSError as error:
|
|
82
|
+
raise NotesmgrError(REFUSED.format(program=argv[0],
|
|
83
|
+
reason=error)) from error
|
|
84
|
+
return done.stdout or b''
|