Skip to content

Commit 4638163

Browse files
committed
feat(notes): add notes plugin with scratchpad, pinning, and launcher quick-capture
1 parent b11db40 commit 4638163

6 files changed

Lines changed: 912 additions & 0 deletions

File tree

notes/README.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# Notes
2+
3+
Quick notes in a full-height side panel, stored as plain text files in a
4+
directory you control — yours to sync, edit externally, and grep.
5+
6+
## Plugin
7+
8+
| Field | Value |
9+
| --- | --- |
10+
| ID | `noctalia/notes` |
11+
| Entries | Panel: `panel`; bar widget: `notes`; launcher provider: `launcher` |
12+
13+
## Usage
14+
15+
1. Enable the plugin in Settings → Plugins.
16+
2. Add the `notes` bar widget and click it, or run:
17+
18+
```sh
19+
noctalia msg panel-toggle noctalia/notes:panel
20+
```
21+
22+
3. **+** creates a note named after the current time; rename it from the editor
23+
header (press Enter to apply). The editor autosaves a couple of seconds
24+
after you stop typing, when you navigate away, and when the panel closes;
25+
Ctrl+Enter saves immediately.
26+
4. The pin button on a row keeps that note at the top of the list and ranks it
27+
higher in launcher results. Deleting asks for an inline confirmation.
28+
29+
A pinned **Scratchpad** note always sits at the top of the list — the place to
30+
dump text instantly without naming anything.
31+
32+
The `/nt` launcher command writes down a thought without opening the panel:
33+
type it, press Enter, done.
34+
35+
- `/nt buy milk`**Add to scratchpad** appends the line to the scratchpad
36+
file; **New note** creates a note from it and opens the panel on it.
37+
- The text also fuzzy-matches existing note names; activating a match opens it
38+
in the panel.
39+
- Bare `/nt` lists the scratchpad, your notes, and **New note from clipboard**.
40+
- **Add to scratchpad** is safe even if the scratchpad was left open in the
41+
editor: the appended line shows up there instead of being lost.
42+
43+
## IPC
44+
45+
```sh
46+
noctalia msg panel-toggle noctalia/notes:panel # toggle the panel
47+
noctalia msg panel-open noctalia/notes:panel # open it
48+
noctalia msg panel-close # close the open panel
49+
```
50+
51+
## Settings
52+
53+
| Setting | Type | Default | Description |
54+
| --- | --- | --- | --- |
55+
| `notes_dir` | `string` | `~/Documents/Notes` | Where the note files live. Created on first open if missing. |
56+
| `extension` | `string` | `md` | Extension for note files (without the dot). Only files with this extension are listed. |
57+
| `glyph` | `glyph` | `notes` | Bar widget icon. |
58+
59+
## Notes
60+
61+
- Pins are stored in a hidden `.pinned.json` sidecar inside the notes
62+
directory, so they sync with the notes.
63+
- The panel remembers which note was open across close/reopen.
64+
- Files are only written by autosave and the `/nt` actions; nothing else in
65+
the notes directory is touched.

notes/launcher.luau

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
--!nonstrict
2+
-- Launcher quick-capture and note search.
3+
--
4+
-- `nt <text>` offers: append <text> to the scratchpad, create a new note from
5+
-- it, or open an existing note whose name fuzzy-matches. With no text, it
6+
-- lists the notes. Opening hands the target filename to the panel through
7+
-- noctalia.state ("open_note") and toggles the panel.
8+
9+
local PANEL_ID = "noctalia/notes:panel"
10+
local SCRATCHPAD_BASE = "scratchpad"
11+
local MAX_MATCHES = 8
12+
13+
local lastText = ""
14+
15+
local function tr(key)
16+
return noctalia.tr(key)
17+
end
18+
19+
local function settings()
20+
local dir = noctalia.expandPath(noctalia.getConfig("notes_dir"))
21+
local extension = noctalia.string.trim(noctalia.getConfig("extension")):gsub("^%.", "")
22+
if extension == "" then
23+
extension = "md"
24+
end
25+
return dir, "." .. extension
26+
end
27+
28+
local function noteFiles(dir, suffix)
29+
local out = {}
30+
local list = noctalia.listDir(dir)
31+
if list ~= nil then
32+
for _, name in ipairs(list) do
33+
if #name > #suffix and name:sub(-#suffix) == suffix and name:sub(1, 1) ~= "." then
34+
table.insert(out, name)
35+
end
36+
end
37+
table.sort(out)
38+
end
39+
return out
40+
end
41+
42+
local function loadPins(dir)
43+
local out = {}
44+
local raw = noctalia.readFile(dir .. "/.pinned.json")
45+
if raw ~= nil then
46+
local decoded = noctalia.json.decode(raw)
47+
if type(decoded) == "table" then
48+
for name, value in pairs(decoded) do
49+
if type(name) == "string" and value == true then
50+
out[name] = true
51+
end
52+
end
53+
end
54+
end
55+
return out
56+
end
57+
58+
local function bumpNotesState()
59+
local bump = noctalia.state.get("notes_bump")
60+
noctalia.state.set("notes_bump", (type(bump) == "number" and bump or 0) + 1)
61+
end
62+
63+
local function openInPanel(file)
64+
noctalia.state.set("open_note", file)
65+
noctalia.togglePanel(PANEL_ID)
66+
end
67+
68+
-- Creates a timestamp-named note holding `content` and opens it in the panel.
69+
local function createNote(content)
70+
local dir, suffix = settings()
71+
local ok, err = noctalia.mkdirAll(dir)
72+
if not ok then
73+
noctalia.notifyError(tr("title"), err or "")
74+
return
75+
end
76+
local base = noctalia.formatTime("%Y-%m-%d %H.%M.%S")
77+
local name = base .. suffix
78+
local counter = 2
79+
while noctalia.fileExists(dir .. "/" .. name) do
80+
name = base .. " (" .. counter .. ")" .. suffix
81+
counter += 1
82+
end
83+
local body = content
84+
if body ~= "" and body:sub(-1) ~= "\n" then
85+
body ..= "\n"
86+
end
87+
local wrote, werr = noctalia.writeFile(dir .. "/" .. name, body)
88+
if not wrote then
89+
noctalia.notifyError(tr("title"), werr or "")
90+
return
91+
end
92+
bumpNotesState()
93+
openInPanel(name)
94+
end
95+
96+
-- Single-line, length-capped preview safe against cutting a UTF-8 sequence.
97+
local function previewLine(text)
98+
local line = noctalia.string.trim(text):gsub("%s+", " ")
99+
if #line <= 60 then
100+
return line
101+
end
102+
local cut = 60
103+
while cut > 1 and line:byte(cut + 1) ~= nil and line:byte(cut + 1) >= 0x80 and line:byte(cut + 1) < 0xC0 do
104+
cut -= 1
105+
end
106+
return line:sub(1, cut) .. "…"
107+
end
108+
109+
function onQuery(query)
110+
local text = noctalia.string.trim(query)
111+
lastText = text
112+
local dir, suffix = settings()
113+
local rows = {}
114+
115+
if text ~= "" then
116+
table.insert(rows, {
117+
id = "append",
118+
title = tr("launcher.append_title"),
119+
subtitle = text,
120+
glyph = "pencil-plus",
121+
})
122+
table.insert(rows, {
123+
id = "create",
124+
title = tr("launcher.new_title"),
125+
subtitle = text,
126+
glyph = "file-plus",
127+
})
128+
else
129+
table.insert(rows, {
130+
id = "open:" .. SCRATCHPAD_BASE .. suffix,
131+
title = tr("scratchpad"),
132+
subtitle = tr("launcher.open_subtitle"),
133+
glyph = "pinned",
134+
})
135+
table.insert(rows, {
136+
id = "home",
137+
title = tr("launcher.home_title"),
138+
subtitle = tr("launcher.home_subtitle"),
139+
glyph = "notes",
140+
})
141+
local clip = noctalia.clipboardText()
142+
if type(clip) == "string" and noctalia.string.trim(clip) ~= "" then
143+
table.insert(rows, {
144+
id = "clip",
145+
title = tr("launcher.clip_title"),
146+
subtitle = previewLine(clip),
147+
glyph = "clipboard-plus",
148+
})
149+
end
150+
end
151+
152+
local pins = loadPins(dir)
153+
local matches = {}
154+
for _, name in ipairs(noteFiles(dir, suffix)) do
155+
if name ~= SCRATCHPAD_BASE .. suffix then
156+
local display = name:sub(1, #name - #suffix)
157+
local score = text == "" and 0 or noctalia.fuzzyScore(text, display)
158+
if score ~= nil then
159+
table.insert(matches, { name = name, display = display, score = score, pinned = pins[name] == true })
160+
end
161+
end
162+
end
163+
table.sort(matches, function(a, b)
164+
if a.score ~= b.score then
165+
return a.score > b.score
166+
end
167+
if a.pinned ~= b.pinned then
168+
return a.pinned
169+
end
170+
return a.display < b.display
171+
end)
172+
for i = 1, math.min(#matches, MAX_MATCHES) do
173+
table.insert(rows, {
174+
id = "open:" .. matches[i].name,
175+
title = matches[i].display,
176+
subtitle = tr("launcher.open_subtitle"),
177+
glyph = matches[i].pinned and "pinned" or "notes",
178+
})
179+
end
180+
181+
launcher.setResults(query, rows)
182+
end
183+
184+
function onActivate(id)
185+
local dir, suffix = settings()
186+
187+
if id == "append" then
188+
if lastText == "" then
189+
return
190+
end
191+
local ok, err = noctalia.mkdirAll(dir)
192+
if not ok then
193+
noctalia.notifyError(tr("title"), err or "")
194+
return
195+
end
196+
local path = dir .. "/" .. SCRATCHPAD_BASE .. suffix
197+
local contents = noctalia.readFile(path) or ""
198+
local sep = (contents == "" or contents:sub(-1) == "\n") and "" or "\n"
199+
local wrote, werr = noctalia.writeFile(path, contents .. sep .. lastText .. "\n")
200+
if not wrote then
201+
noctalia.notifyError(tr("title"), werr or "")
202+
return
203+
end
204+
bumpNotesState()
205+
noctalia.notify(tr("title"), tr("launcher.appended"))
206+
return
207+
end
208+
209+
if id == "create" then
210+
if lastText ~= "" then
211+
createNote(lastText)
212+
end
213+
return
214+
end
215+
216+
if id == "home" then
217+
noctalia.state.set("open_home", true)
218+
noctalia.togglePanel(PANEL_ID)
219+
return
220+
end
221+
222+
if id == "clip" then
223+
local clip = noctalia.clipboardText()
224+
if type(clip) == "string" and noctalia.string.trim(clip) ~= "" then
225+
createNote(clip)
226+
end
227+
return
228+
end
229+
230+
local file = id:match("^open:(.+)$")
231+
if file ~= nil then
232+
openInPanel(file)
233+
end
234+
end

notes/notes.luau

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
--!nonstrict
2+
-- Bar widget that opens the notes panel.
3+
4+
local glyph = barWidget.getConfig("glyph")
5+
6+
local function render()
7+
barWidget.setGlyph(glyph)
8+
barWidget.setTooltip(noctalia.tr("title"))
9+
end
10+
11+
function update()
12+
render()
13+
end
14+
15+
function onClick()
16+
noctalia.togglePanel("noctalia/notes:panel")
17+
end
18+
19+
render()

0 commit comments

Comments
 (0)