nervous-notes 0.0.1__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.
@@ -0,0 +1,10 @@
1
+ from .wrappers.external_messages import get_config_from_env
2
+ from .nervous_note import NervousNote
3
+ from .note_embedded_script import NoteEmbeddedScript
4
+ from .nervous_process import NervousProcess
5
+ from multiprocessing.connection import Connection
6
+ from . import dsl
7
+ __all__ = [
8
+ "NervousNote", "NoteEmbeddedScript", "NervousProcess", "get_config_from_env", "dsl",
9
+ "Connection", # for typing
10
+ ]
@@ -0,0 +1,44 @@
1
+ import logging
2
+ import argparse
3
+
4
+ from .orchestration import Orchestrator
5
+ from .nervous_note import NervousNote
6
+ from .wrappers.external_messages import get_config_from_env
7
+
8
+
9
+ logging.basicConfig(level=logging.INFO,
10
+ format='[%(asctime)s %(pathname)s:%(lineno)s] - %(message)s',
11
+ datefmt='%Y-%m-%d %H:%M:%S')
12
+
13
+ parser = argparse.ArgumentParser()
14
+ parser.add_argument("vault_path", help="Path to the vault directory", type=str)
15
+
16
+ # FIXME: need to make sure this is properly optional, in the Orchestrator
17
+ parser.add_argument("--scripts", help="The directory for scripts")
18
+
19
+ args = parser.parse_args()
20
+
21
+ mqtt_config = get_config_from_env()
22
+ if mqtt_config:
23
+ logging.info(f"Using vault {args.vault_path}, mqtt broker {mqtt_config.broker}:{mqtt_config.port} for user {mqtt_config.username}, and scripts_dir {args.scripts}")
24
+ else:
25
+ logging.info(f"Failed to get mqtt config, proceeding with None config...")
26
+
27
+ NervousNote.start_background_actors(args.vault_path, mqtt_config)
28
+ # FIXME: can I delete the password to let it get garbage collected?
29
+
30
+ deploy_debug_notes = False
31
+ # pykka.DeadLetterRouter.default_router().start() #?
32
+ orchestrator = Orchestrator.wake("Nervous Notes Orchestrator", args.scripts, deploy_debug_notes)
33
+
34
+ try:
35
+ while True:
36
+ input()
37
+ except KeyboardInterrupt:
38
+ logging.info("KeyboardInterrupt detected, stopping...")
39
+ orchestrator.stop()
40
+ logging.info("Orchestrator stopped")
41
+ NervousNote.stop_background_actors()
42
+
43
+ # FIXME: what more is needed for a proper, clean shutdown?
44
+ # exit(0)
nervous_notes/dsl.py ADDED
@@ -0,0 +1,63 @@
1
+ """
2
+ used for typing, not at runtime
3
+
4
+ runtime comes from scriping.CompiledScript.script_scope
5
+ """
6
+
7
+ import logging
8
+ import json
9
+ import datetime
10
+ from time import ctime, sleep
11
+
12
+ import requests
13
+ from pykka import ActorRef
14
+
15
+ from .note_companion_script import TemplateScript
16
+ from .util import TimeUtil, Clock
17
+
18
+ from .nervous_note import MqttWrapper
19
+ from .wrappers.note_api import datetimestamped_markdown_list_line, timestamped_markdown_list_line, NoteAPI
20
+
21
+
22
+ my_note: NoteAPI
23
+ note_name: str
24
+ topic: str
25
+ default_topic: str
26
+ mqtt: MqttWrapper
27
+
28
+ _clock = Clock()
29
+ _timeutils = TimeUtil(_clock)
30
+ next_occurrence = _timeutils.next_occurrence
31
+ seconds_until = _timeutils.seconds_until
32
+ today = _clock.today
33
+
34
+
35
+ # may be expanded beyond template scripts
36
+ def wake(notename: str, template_script_name: TemplateScript) -> ActorRef: pass
37
+
38
+
39
+ def set_timer(delay_seconds: float | int, payload: bytes | None = None, key: str | None = None) -> None: pass
40
+
41
+
42
+ def cancel_timer(key: str) -> None: pass
43
+
44
+
45
+ def subscribe_internal(topic: str, subscriber) -> None: pass
46
+
47
+
48
+ def publish_internal(topic: str, payload: bytes) -> None: pass
49
+
50
+
51
+ def on_mqtt_message(topic: str, payload: bytes) -> None: pass
52
+
53
+
54
+ def on_note_modified() -> None: pass
55
+
56
+
57
+ def on_start() -> None: pass
58
+
59
+
60
+ def on_timer(key: str, payload: object) -> None: pass
61
+
62
+
63
+ def publish_to_ntfy(channel: str, message: str) -> None: pass
@@ -0,0 +1,75 @@
1
+ if TYPE_CHECKING: # IDE support
2
+ from ..dsl import *
3
+
4
+
5
+ def on_start():
6
+ reset_timer_from_note() # THERE's ALWAYS _a_ TIMER, for later today or tomorrow
7
+
8
+
9
+ def on_note_modified():
10
+ note = my_note.note_if_markdown_starts_with_pressed_button()
11
+ if note:
12
+ _frontmatter, markdown = note
13
+ sleep(0.25)
14
+ if today().isoformat() not in markdown:
15
+ updated_markdown = markdown_list_prepender(markdown)
16
+ reset_timer_from_note(note)
17
+ my_note.set_markdown(updated_markdown)
18
+ else:
19
+ my_note.reset_button_at_start_of_markdown()
20
+
21
+
22
+ def on_timer(_key, _payload):
23
+ note = frontmatter, _markdown = my_note.get_note()
24
+ channel = frontmatter.get("channel")
25
+ message = frontmatter.get("message")
26
+ logging.info(f"Timer called, publishing to {channel}: {message}")
27
+ publish_to_ntfy(channel, message)
28
+ reset_timer_from_note(note)
29
+
30
+
31
+ # utils
32
+
33
+ def markdown_list_prepender(markdown: str) -> str:
34
+ """
35
+ - reset the button
36
+ - prepend today's date (after the button)
37
+ """
38
+ lines = markdown.splitlines()
39
+ lines[0] = "- [ ] Mark as done"
40
+ line_to_prepend = f"- [[{my_note.note_name} ({today().isoformat()})]]"
41
+ lines.insert(1, line_to_prepend)
42
+ lines[-1] = lines[-1] + "\n"
43
+ return "\n".join(lines)
44
+
45
+
46
+ def reset_timer_from_note(note=None):
47
+ """
48
+ resets the timer for later today or tomorrow
49
+ """
50
+ if note is None:
51
+ note = my_note.get_note()
52
+
53
+ if note is not None:
54
+ frontmatter, markdown = note
55
+
56
+ next_notification = next_occurrence(frontmatter.get("ifNotDoneBy"))
57
+ rightnows_today = today()
58
+
59
+ today_marked_as_done = rightnows_today.isoformat() in markdown
60
+ next_notification_is_for_today = ((rightnows_today.year, rightnows_today.month, rightnows_today.day) ==
61
+ (next_notification.year, next_notification.month, next_notification.day))
62
+
63
+ next_notification_already_done = today_marked_as_done and next_notification_is_for_today
64
+ if next_notification_already_done:
65
+ next_notification += datetime.timedelta(days=1)
66
+
67
+ delay = seconds_until(next_notification)
68
+ logging.info(f"Setting timer for {next_notification}, delay {delay}s")
69
+ set_timer(
70
+ delay,
71
+ None, # no payload for this timer
72
+ key=f"{my_note.note_name}/TIMER" # there's just one timer
73
+ )
74
+ else:
75
+ logging.warning("No note; need yaml for {channel, message, ifNotDoneBy} (e.g. ifNotDoneBy = 20:32:00-07:00)")
@@ -0,0 +1,34 @@
1
+ def on_start():
2
+ my_note.set_file_contents(f"""---
3
+ in_topic: {default_topic}
4
+ out_topic: {default_topic}/publish
5
+ message_to_send: "Hello :)"
6
+ ---
7
+ - [ ] Send message
8
+ - Instructions:
9
+ - `out_topic` is updated live, but `in_topic` is currently provided but not read back
10
+ - ---
11
+ """)
12
+
13
+ def on_mqtt_message(topic, message):
14
+ my_note.append_datetimestamped_markdown_list_line(f"Received on {topic} message: {message.decode()}")
15
+
16
+ def on_note_modified():
17
+ if (maybe_note := my_note.note_if_markdown_starts_with_pressed_button()) is not None:
18
+ frontmatter, markdown = maybe_note
19
+ else:
20
+ return # not a note update we care about
21
+
22
+ if (out_topic := frontmatter.get("out_topic")) is None:
23
+ logging.warning("Button pressed but Frontmatter out_topic was empty")
24
+ return
25
+
26
+ if (message_to_send := frontmatter.get("message_to_send")) is None:
27
+ logging.warning("Button pressed but Frontmatter message_to_send was empty")
28
+ return
29
+
30
+ # introvert expects `(sender)> (message)` where sender is who they will reply to
31
+ mqtt.publish(out_topic, f"{topic}> {message_to_send}")
32
+
33
+ sleep(.25) # hack so Obsidian sees the update (otherwise it seems to miss it sometimes)
34
+ my_note.reset_button_at_start_of_markdown()
@@ -0,0 +1,54 @@
1
+ def on_start():
2
+ my_note.set_file_contents(f"""---
3
+ in_topic: {default_topic}
4
+ reply_with: "👋"
5
+ ---
6
+ - [ ] Pause replying
7
+ - Instructions:
8
+ - Change `reply_with` to whatever you would like (`in_topic` does not read back updates currently)
9
+ - delay_seconds is an optional field, assumed to be 0 (seconds)
10
+ - ---
11
+ """)
12
+
13
+ sending = True
14
+
15
+ def on_mqtt_message(topic, message):
16
+ config = my_note.get_frontmatter()
17
+
18
+ try:
19
+ # expects:
20
+ # sending_topic> message
21
+ sender, what_they_sent = message.decode().split(">", 1)
22
+ except ValueError:
23
+ my_note.append_datetimestamped_markdown_list_line(f"Received a weirdly formatted message: {message}")
24
+ return
25
+
26
+ if not sending:
27
+ my_note.append_datetimestamped_markdown_list_line(f"{sender} sent {what_they_sent}, not replying (sending paused)")
28
+ return
29
+
30
+ delay_seconds = config.get("delay_seconds", 0)
31
+
32
+ if (reply_with := config.get("reply_with")) is not None:
33
+ if delay_seconds:
34
+ set_timer(delay_seconds, (sender, reply_with))
35
+ my_note.append_datetimestamped_markdown_list_line(f"Received {what_they_sent}, replying with {reply_with} after {delay_seconds}s")
36
+ else:
37
+ my_note.append_datetimestamped_markdown_list_line(f"Received {what_they_sent}, replying with {reply_with}")
38
+ mqtt.publish(sender, reply_with)
39
+ else:
40
+ my_note.append_datetimestamped_markdown_list_line(f"{sender} sent {what_they_sent}, not replying (no reply_with frontmatter)")
41
+
42
+ def on_timer(key, payload):
43
+ sender, reply_with = payload
44
+ mqtt.publish(sender, reply_with)
45
+
46
+
47
+ def on_note_modified():
48
+ global sending # nonlocal doesn't work because sending is not enclosed in a function
49
+
50
+ maybe_note = my_note.note_if_markdown_starts_with_pressed_button()
51
+ if maybe_note is not None:
52
+ sending = False
53
+ else:
54
+ sending = True
@@ -0,0 +1,61 @@
1
+ from datetime import timedelta
2
+ from typing import io
3
+
4
+ if TYPE_CHECKING: # IDE support
5
+ from dsl import *
6
+
7
+
8
+ class Frontmatter:
9
+ pass
10
+
11
+
12
+ class Document:
13
+ frontmatter: Frontmatter
14
+ markdown: str
15
+
16
+
17
+ def on_start():
18
+ note = my_note.get_note()
19
+ if note is not None:
20
+ frontmatter, markdown = note
21
+
22
+ next_date = frontmatter.get("next_date")
23
+ default_interval_days = frontmatter.get("default_interval_days")
24
+
25
+ _button, current_next_note, *history = markdown.splitlines()
26
+
27
+ if current_next_note[:8] != "- Next: " or not current_next_note.endswith(")]]"):
28
+ logging.warning(f"Expected second line to start with `- Next: ` then a wikilink of the format [[Note (YYYY-MM-DD)]]")
29
+ return
30
+
31
+ current_next_date = current_next_note[-13:-3]
32
+ if next_date is None:
33
+ next_date = datetime.datetime.strptime(current_next_date, "%Y-%m-%d") + timedelta(days=default_interval_days)
34
+
35
+
36
+ def on_note_modified():
37
+ note = my_note.note_if_markdown_starts_with_pressed_button()
38
+ if note:
39
+ frontmatter, markdown = note
40
+ # FIXME
41
+ stream = io.StringIO()
42
+ my_note.yaml.dump(data, stream)
43
+
44
+ # Extract the YAML string
45
+ yaml_string = stream.getvalue()
46
+
47
+
48
+ # utils
49
+
50
+ # FIXME: factor out?
51
+ def markdown_list_prepender(markdown: str) -> str:
52
+ """
53
+ - reset the button
54
+ - prepend today's date (after the button)
55
+ """
56
+ lines = markdown.splitlines()
57
+ lines[0] = "- [ ] Mark as done"
58
+ line_to_prepend = f"- [[{my_note.note_name} ({datetime.date.today().isoformat()})]]"
59
+ lines.insert(1, line_to_prepend)
60
+ lines[-1] = lines[-1] + "\n"
61
+ return "\n".join(lines)
@@ -0,0 +1,53 @@
1
+ def on_start():
2
+ my_note.set_file_contents(f"""---
3
+ in_topic: {default_topic}
4
+ out_topic: {default_topic}/publish
5
+ message_to_send: "Hello :)"
6
+ ---
7
+ - [ ] Send message
8
+ - Instructions:
9
+ - press the button above to send `message_to_send` to `out_topic`
10
+ - (this can be observed via mosquitto_pub)
11
+ - send a message to `in_topic` and observe it listed below
12
+ - `out_topic` is read dynamically but `in_topic` is provided for informational purposes only
13
+ - ---
14
+ """)
15
+
16
+
17
+ def on_mqtt_message(topic, message):
18
+ my_note.append_datetimestamped_markdown_list_line(f"received on `{topic}`: {message}")
19
+
20
+
21
+ def on_note_modified():
22
+ if (maybe_note := my_note.note_if_markdown_starts_with_pressed_button()) is not None:
23
+ frontmatter, markdown = maybe_note
24
+ else:
25
+ logging.debug("Note modified but button not pressed")
26
+ return
27
+
28
+ if (out_topic := frontmatter.get("out_topic")) is None:
29
+ logging.warning("Button pressed but Frontmatter out_topic was empty")
30
+ return
31
+
32
+ if (message_to_send := frontmatter.get("message_to_send")) is None:
33
+ logging.warning("Button pressed but Frontmatter message_to_send was empty")
34
+ return
35
+
36
+ logging.info(f"[{note_name}] publishing {message_to_send} to mqtt")
37
+ mqtt.publish(out_topic, message_to_send)
38
+
39
+ logging.info(f"[{note_name}] resetting markdown...")
40
+
41
+ sleep(.25) # hack to prevent a race condition with Obsidian's display
42
+ my_note.upsert_markdown(lambda markdown: upserter(out_topic, message_to_send, markdown))
43
+ logging.info(f"[{note_name}] Markdown reset")
44
+
45
+
46
+ # util
47
+
48
+ def upserter(out_topic, message_to_send, markdown):
49
+ lines = markdown.splitlines()
50
+ lines[0] = f"- [ ] Send (last sent ~{ctime()})"
51
+ lines.append(timestamped_markdown_list_line(f"sending on `{out_topic}`: {message_to_send}"))
52
+
53
+ return "\n".join(lines)
@@ -0,0 +1,30 @@
1
+ def on_start():
2
+ my_note.set_file_contents(f"""---
3
+ out_topic: "(fill in)"
4
+ message_to_send: "hello"
5
+ ---
6
+ - [ ] Send
7
+ """)
8
+
9
+ def on_mqtt_message(topic, message):
10
+ my_note.append_datetimestamped_markdown_list_line(f"(ignoring) received on `{topic}`: {message}")
11
+
12
+
13
+ def on_note_modified():
14
+ if (maybe_note := my_note.note_if_markdown_starts_with_pressed_button()) is not None:
15
+ frontmatter, markdown = maybe_note
16
+ else:
17
+ return # not a note update we care about
18
+
19
+ if (out_topic := frontmatter.get("out_topic")) is None:
20
+ logging.warning("Button pressed but Frontmatter out_topic was empty")
21
+ return
22
+
23
+ if (message_to_send := frontmatter.get("message_to_send")) is None:
24
+ logging.warning("Button pressed but Frontmatter message_to_send was empty")
25
+ return
26
+
27
+ mqtt.publish(out_topic, message_to_send)
28
+
29
+ sleep(.25) # hack so Obsidian sees the update (otherwise it seems to miss it sometimes)
30
+ my_note.reset_button_at_start_of_markdown()
@@ -0,0 +1,8 @@
1
+ def on_start():
2
+ my_note.set_file_contents(f"""---
3
+ in_topic: "{topic}"
4
+ ---
5
+ """)
6
+
7
+ def on_mqtt_message(topic, message):
8
+ my_note.append_datetimestamped_markdown_list_line(f"received on `{topic}`: {message}")
@@ -0,0 +1,25 @@
1
+ if TYPE_CHECKING: # IDE support
2
+ from ..dsl import *
3
+
4
+
5
+ def on_start():
6
+ frontmatter, markdown = my_note.get_note()
7
+
8
+ lines = markdown.strip().split('\n')
9
+
10
+ failed_matches = []
11
+ for line in lines:
12
+ if not line.startswith('- [[') or not line.endswith(']]'):
13
+ failed_matches.append(line)
14
+
15
+ if failed_matches:
16
+ logging.warning(f"Failed matches! {failed_matches} (lines: {lines})")
17
+ return
18
+
19
+ template_to_use = frontmatter.get("template_to_use", "Next-Notes MOC")
20
+ logging.info(f"Using template: {template_to_use}")
21
+
22
+ next_notes = [line[4:-2] for line in lines]
23
+ for next_note in next_notes:
24
+ logging.info(f"waking [[{next_note}]]")
25
+ wake(next_note, TemplateScript(template_to_use))
@@ -0,0 +1,91 @@
1
+ from datetime import timedelta
2
+
3
+ if TYPE_CHECKING: # IDE support
4
+ from ..dsl import *
5
+
6
+ DEFAULT_INTERVAL_DAYS = 7
7
+
8
+
9
+ def on_start():
10
+ if my_note.already_exists():
11
+ return
12
+
13
+ today_today = today()
14
+ my_note.set_file_contents(f"""---
15
+ default_interval_days: {DEFAULT_INTERVAL_DAYS}
16
+ ---
17
+ - [ ] Create next: [[{my_note.note_name} ({today_today.isoformat()})]]
18
+
19
+ ## History
20
+
21
+ - [[{my_note.note_name} ({(today_today + timedelta(days=-DEFAULT_INTERVAL_DAYS)).isoformat()})]]
22
+ - [[{my_note.note_name} ({(today_today + timedelta(days=-DEFAULT_INTERVAL_DAYS*2)).isoformat()})]]
23
+ - (anything from the line above downward is basically ignored)
24
+ - (once you create one real link, e.g. with the button, you can delete these extra lines with no problem)
25
+ """)
26
+
27
+
28
+ def on_note_modified():
29
+ logging.info("[Next-Notes Moc.py:on_note_modified] CANARY")
30
+ note = my_note.note_if_markdown_starts_with_pressed_button()
31
+ if note:
32
+ frontmatter, markdown = note
33
+
34
+ logging.info("Button pressed! ...")
35
+
36
+ lines = markdown.split("\n")
37
+ button = lines[0]
38
+
39
+ # The next note to create is named by the wikilink in the button line,
40
+ # e.g. "- [x] Create next: [[Next Notes Mockup (2026-07-19)]]".
41
+ next_target = _wikilink_target(button)
42
+
43
+ logging.warning(
44
+ f"The user needs to manually create: a calendar event(?) and [[{next_target}]]. Any Next* aliasing has also been left untouched."
45
+ )
46
+ # FIXME: here I can wake() the next target, if only to move the alias along... how to model that?
47
+
48
+ # Advance the button to the note after this one, keeping the label text.
49
+ interval_days = frontmatter.get("default_interval_days", 7)
50
+ following_target = _bump_target(next_target, interval_days)
51
+ label = button[len("- [x] "):button.index("[[")]
52
+ lines[0] = f"- [ ] {label}[[{following_target}]]"
53
+
54
+ # Record the just-created note at the top of History, if not already there.
55
+ history_line = f"- [[{next_target}]]"
56
+ if history_line not in lines:
57
+ sleep(0.25)
58
+ _prepend_to_history(lines, history_line)
59
+
60
+ my_note.set_markdown("\n".join(lines))
61
+ logging.info("Done, setting markdown...")
62
+
63
+
64
+ # utils
65
+
66
+
67
+ def _wikilink_target(text: str) -> str:
68
+ """Return the NAME inside the first [[NAME]] wikilink in `text`."""
69
+ start = text.index("[[") + 2
70
+ end = text.index("]]", start)
71
+ return text[start:end]
72
+
73
+
74
+ def _bump_target(target: str, interval_days: int) -> str:
75
+ """"Base (YYYY-MM-DD)" -> same base with the date advanced `interval_days`."""
76
+ base = target[:target.rindex(" (")]
77
+ date_str = target[target.rindex("(") + 1:target.rindex(")")]
78
+ date = datetime.datetime.strptime(date_str, "%Y-%m-%d").date()
79
+ following = date + datetime.timedelta(days=interval_days)
80
+ return f"{base} ({following.isoformat()})"
81
+
82
+
83
+ def _prepend_to_history(lines: list, history_line: str) -> None:
84
+ """Insert `history_line` before the first existing History entry (or append)."""
85
+ header_index = lines.index("## History")
86
+ insert_at = len(lines)
87
+ for i in range(header_index + 1, len(lines)):
88
+ if lines[i].startswith("- [["):
89
+ insert_at = i
90
+ break
91
+ lines.insert(insert_at, history_line)
@@ -0,0 +1,31 @@
1
+ def on_start():
2
+ my_note.set_file_contents(f"""---
3
+ duration_seconds: 1
4
+ ---
5
+ - [ ] Start timer
6
+ """)
7
+
8
+
9
+ def on_note_modified():
10
+ if (maybe_note := my_note.note_if_markdown_starts_with_pressed_button()) is not None:
11
+ frontmatter, markdown = maybe_note
12
+ else:
13
+ return # not a note update we care about
14
+
15
+ if (duration_seconds := frontmatter.get("duration_seconds")) is None:
16
+ logging.warning("Button pressed but Frontmatter duration_seconds was empty")
17
+ return
18
+
19
+ set_timer(duration_seconds, duration_seconds)
20
+ sleep(.25) # hack so Obsidian sees the update (otherwise it seems to miss it sometimes)
21
+ my_note.upsert_markdown(lambda markdown: upserter(duration_seconds, markdown))
22
+
23
+ def on_timer(key, duration_seconds):
24
+ my_note.append_datetimestamped_markdown_list_line(f"Finished {duration_seconds}s timer")
25
+
26
+ def upserter(duration_seconds, markdown):
27
+ lines = markdown.splitlines()
28
+ lines[0] = f"- [ ] Start timer"
29
+ lines.append(f"- \\[{ctime()}] Just set a timer for {duration_seconds}s\n")
30
+
31
+ return "\n".join(lines)