@acedatacloud/skills 2026.703.3 → 2026.703.4

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.
package/AGENTS.md CHANGED
@@ -31,6 +31,7 @@ Skills are located in the `skills/` directory (also mirrored to `.agents/skills/
31
31
  - **face-transform** — Face analysis, beautification, age/gender transform, swap, cartoon
32
32
  - **short-url** — Create and manage short URLs
33
33
  - **onepage-pdf** — Convert an HTML page into one tall single-page PDF (local; no API token; needs Python + pymupdf + Chrome/Edge)
34
+ - **apple-notes** — Manage Apple Notes on macOS: create, search, read, export, organize notes (macOS-only; local; no API token; drives Notes.app via AppleScript)
34
35
  - **acedatacloud** — Manage your AceDataCloud account — balance, usage/spend, API keys, services, orders, announcements
35
36
 
36
37
  ## Authentication
package/README.md CHANGED
@@ -12,7 +12,7 @@
12
12
 
13
13
  Compatible with **30+ AI coding agents** via the [agentskills.io](https://agentskills.io/) open standard: Claude Code, GitHub Copilot, Gemini CLI, OpenAI Codex, Cursor, Roo Code, Goose, and more.
14
14
 
15
- ## Available Skills (29)
15
+ ## Available Skills (30)
16
16
 
17
17
  ### AI Music & Audio
18
18
 
@@ -50,6 +50,7 @@ Compatible with **30+ AI coding agents** via the [agentskills.io](https://agents
50
50
  | [face-transform](skills/face-transform/) | Face analysis, beautification, age/gender transform, swap, cartoon |
51
51
  | [short-url](skills/short-url/) | Create and manage short URLs |
52
52
  | [onepage-pdf](skills/onepage-pdf/) | Convert an HTML page into one tall single-page PDF — no pagination breaks (local, no token) |
53
+ | [apple-notes](skills/apple-notes/) | Manage Apple Notes on macOS — create, search, read, export, and organize notes (macOS-only, local, no token) |
53
54
  | [acedatacloud](skills/acedatacloud/) | Manage your AceDataCloud account — balance, usage/spend, API keys, services, orders, announcements |
54
55
 
55
56
  ### Connectors
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acedatacloud/skills",
3
- "version": "2026.703.3",
3
+ "version": "2026.703.4",
4
4
  "description": "Agent Skills for AceDataCloud AI services — music, image, video generation, LLM chat, web search. Compatible with Claude Code, GitHub Copilot, Gemini CLI, OpenAI Codex, and 30+ AI coding agents.",
5
5
  "keywords": [
6
6
  "agent-skills",
@@ -0,0 +1,126 @@
1
+ ---
2
+ name: apple-notes
3
+ description: Manage Apple Notes on macOS — list, search, read, create, append, move, delete, and export notes, and manage folders. Use when the user mentions Apple Notes / 备忘录, "add a note", "my notes", searching their notes, organizing folders, or exporting a note to Markdown/HTML. macOS-only; runs locally against Notes.app — no cloud API, no token.
4
+ when_to_use: |
5
+ Trigger for anything on the user's local Apple Notes (Notes.app on macOS):
6
+ list / search / read / export a note, create a new one, append to an existing
7
+ one, move it between folders, or manage folders. There is no cloud API for
8
+ Apple Notes, so this runs on the user's own Mac (Claude Code on macOS, or
9
+ driven remotely through a CodingBridge node). Writes act on the user's REAL
10
+ notes, so create / append / move / delete are GATED behind an explicit
11
+ confirmation.
12
+ allowed_tools: [Bash]
13
+ license: Apache-2.0
14
+ compatibility: macOS-only. No API token, no connector — Apple Notes has no cloud API. Drives the local Notes.app via AppleScript (osascript); zero external dependencies (Python 3 standard library only). Requires granting Automation access to Notes.app on first run. Does not call api.acedata.cloud.
15
+ metadata:
16
+ author: acedatacloud
17
+ version: "1.0"
18
+ ---
19
+
20
+ # apple-notes — manage Apple Notes locally via AppleScript
21
+
22
+ Drives the user's **real** Apple Notes through `Notes.app` on macOS. Apple never
23
+ shipped a cloud API for Notes, so there is nothing to OAuth into and no token to
24
+ inject — this skill runs **on the user's own Mac** and talks to Notes.app with
25
+ AppleScript (`osascript`). It works when Claude Code runs on macOS directly, or
26
+ remotely through a CodingBridge node running on the user's Mac.
27
+
28
+ The skill ships [`scripts/notes.py`](scripts/notes.py) — self-contained, Python
29
+ **standard library only** (it shells out to `osascript`; no `pip install`, no
30
+ `brew`). Dynamic values are passed as environment variables, never interpolated
31
+ into the AppleScript source, so note content can't break the script or inject
32
+ commands.
33
+
34
+ ## Requirements
35
+
36
+ - **macOS** with the Notes app. `python3` (system Python is fine).
37
+ - **Automation permission.** The first call triggers a macOS prompt: *"Terminal
38
+ wants to control Notes."* Approve it, or enable it under **System Settings ›
39
+ Privacy & Security › Automation ›** (your terminal / agent) **› Notes**. On an
40
+ authorization error the CLI tells the user exactly this — do **not** loop-retry.
41
+ - Referencing Notes launches the app in the background (no window is forced open).
42
+
43
+ ```sh
44
+ NOTES="${SKILL_DIR:-.}/scripts/notes.py" # run from this skill's directory
45
+ python3 "$NOTES" folders # smoke-test the connection
46
+ ```
47
+
48
+ ## Commands
49
+
50
+ | Command | Read/Write | Purpose |
51
+ | --- | --- | --- |
52
+ | `folders` | read | List folders with note counts |
53
+ | `list [--folder F] [--limit N]` | read | List notes (newest first): id, title, folder, dates |
54
+ | `search QUERY [--folder F] [--limit N]` | read | Case-insensitive substring match on title + body |
55
+ | `view NOTE_ID [--format json\|text\|html]` | read | Show one note (metadata + body) |
56
+ | `export NOTE_ID [--format md\|html\|text] [-o FILE]` | read | Export one note to Markdown/HTML/text |
57
+ | `create --title T [--body B \| --body-file F] [--folder F]` | **write** | Create a note |
58
+ | `append NOTE_ID [--body B \| --body-file F]` | **write** | Append text to an existing note |
59
+ | `move NOTE_ID --folder F` | **write** | Move a note to another folder |
60
+ | `new-folder NAME` | **write** | Create a folder |
61
+ | `delete NOTE_ID` | **write** | Move a note to *Recently Deleted* (recoverable) |
62
+
63
+ Most commands print JSON (and errors are always JSON: `{"error": ...}`). The
64
+ exceptions print raw note content to stdout: `view --format text|html` and
65
+ `export` without `-o`. `NOTE_ID` is the opaque `x-coredata://…` id returned by
66
+ `list` / `search` — always fetch a fresh id first; do not guess one.
67
+
68
+ ## Read examples
69
+
70
+ ```sh
71
+ python3 "$NOTES" folders
72
+ python3 "$NOTES" list --limit 20
73
+ python3 "$NOTES" list --folder "Work" --limit 50
74
+ python3 "$NOTES" search "invoice" --limit 20
75
+ python3 "$NOTES" view "x-coredata://…/p42"
76
+ python3 "$NOTES" export "x-coredata://…/p42" --format md -o note.md
77
+ ```
78
+
79
+ ## Writes are GATED (dry-run unless trailing `--confirm`)
80
+
81
+ `create`, `append`, `move`, `new-folder`, and `delete` change the user's real
82
+ notes. Without a trailing `--confirm` they **dry-run** and print what they would
83
+ do. `--confirm` is honored **only as the very last argument**. Always show the
84
+ dry-run, get an explicit "yes", then re-run with `--confirm` appended.
85
+
86
+ ```sh
87
+ # Create — dry-run, then confirm
88
+ python3 "$NOTES" create --title "Groceries" --body "milk\neggs" # preview
89
+ python3 "$NOTES" create --title "Groceries" --body "milk\neggs" --confirm # writes
90
+
91
+ # Create in a folder, body from a file
92
+ python3 "$NOTES" create --title "Spec" --body-file draft.md --folder "Work" --confirm
93
+
94
+ # Append to an existing note
95
+ python3 "$NOTES" append "x-coredata://…/p42" --body "one more line" --confirm
96
+
97
+ # Move / organize
98
+ python3 "$NOTES" new-folder "Archive" --confirm
99
+ python3 "$NOTES" move "x-coredata://…/p42" --folder "Archive" --confirm
100
+
101
+ # Delete → goes to Recently Deleted (recoverable ~30 days)
102
+ python3 "$NOTES" delete "x-coredata://…/p42" --confirm
103
+ ```
104
+
105
+ Content is treated as **plain text** (each line becomes a paragraph; the
106
+ `--title` is the note's first line, which Notes shows as the title). HTML in the
107
+ input is escaped, so it can't inject markup. Use `\n` for line breaks in
108
+ `--body`, or pass a file with `--body-file`.
109
+
110
+ ## Gotchas
111
+
112
+ - **This is the user's real Notes account.** Confirm before any write.
113
+ - **`list` scans the whole library** — for large accounts it can take a few
114
+ seconds. Narrow with `--folder`, and cap with `--limit` (default 50). `search`
115
+ filters inside Notes and is faster.
116
+ - **Attachments / images:** notes containing images or attachments can be read
117
+ and exported as text, but `append` may not preserve embedded attachments —
118
+ avoid editing attachment notes here; open them in Notes instead.
119
+ - **Export → Markdown** converts common Notes formatting (headings, bold/italic,
120
+ lists, links, line breaks). Rich objects (tables, drawings, scanned docs)
121
+ degrade to plain text.
122
+ - **`move` stays within one account** (e.g. iCloud). Cross-account moves fail.
123
+ - **Never assume a `NOTE_ID`** — ids are store-specific; always get them from
124
+ `list` / `search` first.
125
+ - On a persistent authorization error, the Automation permission is missing —
126
+ tell the user to grant it (see Requirements) and stop; do not retry in a loop.
@@ -0,0 +1,572 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ apple-notes — manage Apple Notes on macOS via AppleScript (osascript).
4
+
5
+ Standard library only. There is no cloud API for Apple Notes, so this drives the
6
+ local Notes.app through `osascript`. Dynamic values (titles, bodies, ids, folder
7
+ names, queries) are passed as ENVIRONMENT VARIABLES and read inside AppleScript
8
+ with `system attribute` — they are never interpolated into the script source, so
9
+ note content cannot break the script or inject AppleScript/shell commands.
10
+
11
+ Read commands run directly. Writes (create / append / move / new-folder /
12
+ delete) are GATED by a trailing `--confirm` (honored only as the last arg);
13
+ without it they dry-run. `delete` moves the note to Recently Deleted (recoverable).
14
+
15
+ Examples:
16
+ python3 notes.py folders
17
+ python3 notes.py list --limit 20
18
+ python3 notes.py search "invoice" --limit 10
19
+ python3 notes.py view <note-id>
20
+ python3 notes.py export <note-id> --format md -o note.md
21
+ python3 notes.py create --title "Groceries" --body "milk\neggs" --confirm
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import html as _html
28
+ import json
29
+ import os
30
+ import re
31
+ import subprocess
32
+ import sys
33
+ from html.parser import HTMLParser
34
+
35
+ # Field / record separators used to pack AppleScript output (U+001F / U+001E).
36
+ US = "\x1f"
37
+ RS = "\x1e"
38
+
39
+ TIMEOUT = 90 # overridden by --timeout in main()
40
+
41
+ # GATED-write handling: a trailing "--confirm" arms writes; it is stripped before
42
+ # argparse so it is never a real flag (and is honored ONLY as the last argument).
43
+ _RAW = sys.argv[1:]
44
+ CONFIRM = bool(_RAW) and _RAW[-1] == "--confirm"
45
+ ARGV = _RAW[:-1] if CONFIRM else list(_RAW)
46
+
47
+
48
+ def out(obj) -> None:
49
+ print(json.dumps(obj, ensure_ascii=False, indent=2, default=str))
50
+
51
+
52
+ def die(msg: str, code: int = 1) -> None:
53
+ out({"error": msg})
54
+ sys.exit(code)
55
+
56
+
57
+ def _int(s) -> int:
58
+ try:
59
+ return int(str(s).strip())
60
+ except (TypeError, ValueError):
61
+ return 0
62
+
63
+
64
+ # ── AppleScript ─────────────────────────────────────────────────────
65
+
66
+ # ISO-8601 date helpers (locale-independent: build the string from integers).
67
+ _HANDLERS = r"""
68
+ on isoDate(d)
69
+ return (pad(year of d, 4)) & "-" & (pad((month of d) as integer, 2)) & "-" & (pad(day of d, 2)) & "T" & (pad(hours of d, 2)) & ":" & (pad(minutes of d, 2)) & ":" & (pad(seconds of d, 2))
70
+ end isoDate
71
+ on pad(n, w)
72
+ set s to ((n as integer) as string)
73
+ repeat while (length of s) < w
74
+ set s to "0" & s
75
+ end repeat
76
+ return s
77
+ end pad
78
+ """
79
+
80
+ _EMIT = (
81
+ ' set fol to ""\n'
82
+ " try\n"
83
+ " set fol to name of container of n\n"
84
+ " end try\n"
85
+ " set outText to outText & (id of n) & US & (name of n) & US & fol"
86
+ " & US & (my isoDate(modification date of n)) & US & (my isoDate(creation date of n)) & RS\n"
87
+ )
88
+
89
+ _SCRIPT_FOLDERS = r"""
90
+ set US to (character id 31)
91
+ set RS to (character id 30)
92
+ set outText to ""
93
+ tell application "Notes"
94
+ repeat with f in folders
95
+ set outText to outText & (name of f) & US & ((count of notes of f) as string) & RS
96
+ end repeat
97
+ end tell
98
+ return outText
99
+ """
100
+
101
+ _SCRIPT_LIST = _HANDLERS + (
102
+ 'set US to (character id 31)\n'
103
+ 'set RS to (character id 30)\n'
104
+ 'set folderName to system attribute "ANOTE_FOLDER"\n'
105
+ 'set outText to ""\n'
106
+ 'tell application "Notes"\n'
107
+ ' if folderName is "" then\n'
108
+ ' set theNotes to every note\n'
109
+ ' else\n'
110
+ ' set theNotes to every note of folder folderName\n'
111
+ ' end if\n'
112
+ ' repeat with n in theNotes\n'
113
+ + _EMIT +
114
+ ' end repeat\n'
115
+ 'end tell\n'
116
+ 'return outText\n'
117
+ )
118
+
119
+ _SCRIPT_SEARCH = _HANDLERS + (
120
+ 'set US to (character id 31)\n'
121
+ 'set RS to (character id 30)\n'
122
+ 'set q to system attribute "ANOTE_QUERY"\n'
123
+ 'set folderName to system attribute "ANOTE_FOLDER"\n'
124
+ 'set outText to ""\n'
125
+ 'tell application "Notes"\n'
126
+ ' if folderName is "" then\n'
127
+ ' set theNotes to (every note whose (name contains q) or (body contains q))\n'
128
+ ' else\n'
129
+ ' set theNotes to (every note of folder folderName whose (name contains q) or (body contains q))\n'
130
+ ' end if\n'
131
+ ' repeat with n in theNotes\n'
132
+ + _EMIT +
133
+ ' end repeat\n'
134
+ 'end tell\n'
135
+ 'return outText\n'
136
+ )
137
+
138
+ _SCRIPT_VIEW = _HANDLERS + (
139
+ 'set US to (character id 31)\n'
140
+ 'set theId to system attribute "ANOTE_ID"\n'
141
+ 'tell application "Notes"\n'
142
+ ' set n to first note whose id is theId\n'
143
+ ' set fol to ""\n'
144
+ ' try\n'
145
+ ' set fol to name of container of n\n'
146
+ ' end try\n'
147
+ ' set outText to (id of n) & US & (name of n) & US & fol & US'
148
+ ' & (my isoDate(creation date of n)) & US & (my isoDate(modification date of n)) & US & (body of n)\n'
149
+ 'end tell\n'
150
+ 'return outText\n'
151
+ )
152
+
153
+ _SCRIPT_CREATE = r"""
154
+ set theBody to system attribute "ANOTE_BODY"
155
+ set folderName to system attribute "ANOTE_FOLDER"
156
+ tell application "Notes"
157
+ if folderName is "" then
158
+ set newNote to make new note with properties {body:theBody}
159
+ else
160
+ tell folder folderName
161
+ set newNote to make new note with properties {body:theBody}
162
+ end tell
163
+ end if
164
+ return id of newNote
165
+ end tell
166
+ """
167
+
168
+ _SCRIPT_APPEND = r"""
169
+ set theId to system attribute "ANOTE_ID"
170
+ set extra to system attribute "ANOTE_BODY"
171
+ tell application "Notes"
172
+ set n to first note whose id is theId
173
+ set body of n to (body of n) & extra
174
+ return id of n
175
+ end tell
176
+ """
177
+
178
+ _SCRIPT_MOVE = r"""
179
+ set theId to system attribute "ANOTE_ID"
180
+ set destFolder to system attribute "ANOTE_FOLDER"
181
+ tell application "Notes"
182
+ set n to first note whose id is theId
183
+ move n to folder destFolder
184
+ return id of n
185
+ end tell
186
+ """
187
+
188
+ _SCRIPT_NEWFOLDER = r"""
189
+ set folderName to system attribute "ANOTE_FOLDER"
190
+ tell application "Notes"
191
+ make new folder with properties {name:folderName}
192
+ end tell
193
+ return folderName
194
+ """
195
+
196
+ _SCRIPT_DELETE = r"""
197
+ set theId to system attribute "ANOTE_ID"
198
+ tell application "Notes"
199
+ set n to first note whose id is theId
200
+ delete n
201
+ end tell
202
+ return theId
203
+ """
204
+
205
+
206
+ def _map_error(err: str) -> None:
207
+ low = err.lower()
208
+ if "-1743" in err or "not authoriz" in low or "not allowed to send" in low:
209
+ die(
210
+ "Not authorized to control Notes. Grant Automation access under "
211
+ "System Settings \u203a Privacy & Security \u203a Automation \u203a "
212
+ "(your terminal/agent) \u203a Notes, then retry."
213
+ )
214
+ if "-1728" in err or "can\u2019t get" in low or "can't get" in low or "-2753" in err:
215
+ die("Not found \u2014 check the note id or folder name (run `list` / `folders` for valid values).")
216
+ if "-600" in err or "isn\u2019t running" in low or "not running" in low:
217
+ die("Notes.app could not launch.")
218
+ die("AppleScript error: " + err if err else "osascript failed with no error message.")
219
+
220
+
221
+ def osa(script: str, **env: str) -> str:
222
+ full = dict(os.environ)
223
+ for key, value in env.items():
224
+ full[key] = "" if value is None else str(value)
225
+ try:
226
+ proc = subprocess.run(
227
+ ["osascript", "-e", script],
228
+ capture_output=True,
229
+ text=True,
230
+ encoding="utf-8",
231
+ errors="replace",
232
+ env=full,
233
+ timeout=TIMEOUT,
234
+ )
235
+ except FileNotFoundError:
236
+ die("osascript not found \u2014 this skill requires macOS.")
237
+ except subprocess.TimeoutExpired:
238
+ die(f"osascript timed out after {TIMEOUT}s \u2014 narrow with --folder / --limit.")
239
+ if proc.returncode != 0:
240
+ _map_error((proc.stderr or "").strip())
241
+ return proc.stdout
242
+
243
+
244
+ # ── content helpers ─────────────────────────────────────────────────
245
+
246
+ def text_to_html(title: str, body: str) -> str:
247
+ """Plain text -> Notes-safe HTML (one <div> per line). Input is escaped."""
248
+ parts = []
249
+ if title:
250
+ parts.append("<div>" + _html.escape(title) + "</div>")
251
+ for line in (body or "").split("\n"):
252
+ parts.append("<div><br></div>" if line.strip() == "" else "<div>" + _html.escape(line) + "</div>")
253
+ return "".join(parts)
254
+
255
+
256
+ class _Converter(HTMLParser):
257
+ """Minimal Notes-HTML -> Markdown/plain-text converter (stdlib only)."""
258
+
259
+ def __init__(self, markdown: bool = True) -> None:
260
+ super().__init__(convert_charrefs=True)
261
+ self.md = markdown
262
+ self.buf: list[str] = []
263
+ self._href: str | None = None
264
+ self._list: list = []
265
+
266
+ def _nl(self) -> None:
267
+ if self.buf and not self.buf[-1].endswith("\n"):
268
+ self.buf.append("\n")
269
+
270
+ def handle_starttag(self, tag: str, attrs) -> None:
271
+ if tag == "br":
272
+ self.buf.append("\n")
273
+ elif tag in ("div", "p"):
274
+ self._nl()
275
+ elif tag in ("h1", "h2", "h3", "h4", "h5", "h6"):
276
+ self._nl()
277
+ if self.md:
278
+ self.buf.append("#" * int(tag[1]) + " ")
279
+ elif tag in ("b", "strong") and self.md:
280
+ self.buf.append("**")
281
+ elif tag in ("i", "em") and self.md:
282
+ self.buf.append("*")
283
+ elif tag == "ul":
284
+ self._list.append("ul")
285
+ self._nl()
286
+ elif tag == "ol":
287
+ self._list.append(["ol", 0])
288
+ self._nl()
289
+ elif tag == "li":
290
+ self._nl()
291
+ if self.md and self._list:
292
+ top = self._list[-1]
293
+ if top == "ul":
294
+ self.buf.append("- ")
295
+ else:
296
+ top[1] += 1
297
+ self.buf.append(f"{top[1]}. ")
298
+ elif tag == "a":
299
+ self._href = dict(attrs).get("href")
300
+ if self.md and self._href:
301
+ self.buf.append("[")
302
+
303
+ def handle_endtag(self, tag: str) -> None:
304
+ if tag in ("b", "strong") and self.md:
305
+ self.buf.append("**")
306
+ elif tag in ("i", "em") and self.md:
307
+ self.buf.append("*")
308
+ elif tag in ("div", "p", "h1", "h2", "h3", "h4", "h5", "h6", "li"):
309
+ self._nl()
310
+ elif tag in ("ul", "ol"):
311
+ if self._list:
312
+ self._list.pop()
313
+ self._nl()
314
+ elif tag == "a":
315
+ if self.md and self._href:
316
+ self.buf.append(f"]({self._href})")
317
+ self._href = None
318
+
319
+ def handle_data(self, data: str) -> None:
320
+ self.buf.append(data)
321
+
322
+ def result(self) -> str:
323
+ text = re.sub(r"\n{3,}", "\n\n", "".join(self.buf))
324
+ return text.strip() + "\n"
325
+
326
+
327
+ def _convert(body_html: str, markdown: bool) -> str:
328
+ conv = _Converter(markdown=markdown)
329
+ conv.feed(body_html or "")
330
+ return conv.result()
331
+
332
+
333
+ def _read_body(args) -> str:
334
+ body_file = getattr(args, "body_file", None)
335
+ if body_file:
336
+ try:
337
+ with open(body_file, "r", encoding="utf-8") as fh:
338
+ return fh.read()
339
+ except OSError as exc:
340
+ die(f"cannot read --body-file: {exc}")
341
+ body = getattr(args, "body", None)
342
+ if body is None:
343
+ return ""
344
+ return body.replace("\\n", "\n").replace("\\t", "\t")
345
+
346
+
347
+ def _parse_notes(raw: str) -> list:
348
+ items = []
349
+ for rec in raw.split(RS):
350
+ rec = rec.strip("\n")
351
+ if not rec:
352
+ continue
353
+ parts = rec.split(US)
354
+ if len(parts) < 5:
355
+ continue
356
+ items.append(
357
+ {"id": parts[0], "title": parts[1], "folder": parts[2], "modified": parts[3], "created": parts[4]}
358
+ )
359
+ return items
360
+
361
+
362
+ # ── commands ────────────────────────────────────────────────────────
363
+
364
+ def cmd_folders(args) -> None:
365
+ raw = osa(_SCRIPT_FOLDERS)
366
+ items = []
367
+ for rec in raw.split(RS):
368
+ rec = rec.strip("\n")
369
+ if not rec:
370
+ continue
371
+ parts = rec.split(US)
372
+ if len(parts) < 2:
373
+ continue
374
+ items.append({"folder": parts[0], "count": _int(parts[1])})
375
+ out({"folders": items, "count": len(items)})
376
+
377
+
378
+ def cmd_list(args) -> None:
379
+ items = _parse_notes(osa(_SCRIPT_LIST, ANOTE_FOLDER=args.folder or ""))
380
+ items.sort(key=lambda x: x.get("modified") or "", reverse=True)
381
+ if args.limit and args.limit > 0:
382
+ items = items[: args.limit]
383
+ out({"notes": items, "count": len(items)})
384
+
385
+
386
+ def cmd_search(args) -> None:
387
+ query = (args.query or "").strip()
388
+ if not query:
389
+ die("search needs a non-empty query.")
390
+ items = _parse_notes(osa(_SCRIPT_SEARCH, ANOTE_QUERY=args.query, ANOTE_FOLDER=args.folder or ""))
391
+ items.sort(key=lambda x: x.get("modified") or "", reverse=True)
392
+ if args.limit and args.limit > 0:
393
+ items = items[: args.limit]
394
+ out({"query": args.query, "notes": items, "count": len(items)})
395
+
396
+
397
+ def _fetch(note_id: str) -> dict:
398
+ raw = osa(_SCRIPT_VIEW, ANOTE_ID=note_id)
399
+ # maxsplit=5 so the HTML body (last field) keeps any US/RS chars it contains
400
+ parts = raw.split(US, 5)
401
+ if len(parts) < 6:
402
+ die("unexpected osascript output while reading the note.")
403
+ body = parts[5]
404
+ if body.endswith("\n"):
405
+ body = body[:-1]
406
+ return {
407
+ "id": parts[0],
408
+ "title": parts[1],
409
+ "folder": parts[2],
410
+ "created": parts[3],
411
+ "modified": parts[4],
412
+ "body_html": body,
413
+ }
414
+
415
+
416
+ def cmd_view(args) -> None:
417
+ note = _fetch(args.note_id)
418
+ if args.format == "html":
419
+ print(note["body_html"])
420
+ return
421
+ if args.format == "text":
422
+ print(_convert(note["body_html"], markdown=False))
423
+ return
424
+ note["text"] = _convert(note["body_html"], markdown=False)
425
+ out(note)
426
+
427
+
428
+ def cmd_export(args) -> None:
429
+ note = _fetch(args.note_id)
430
+ if args.format == "html":
431
+ content = note["body_html"]
432
+ else:
433
+ content = _convert(note["body_html"], markdown=(args.format == "md"))
434
+ if args.output:
435
+ try:
436
+ with open(args.output, "w", encoding="utf-8") as fh:
437
+ fh.write(content if content.endswith("\n") else content + "\n")
438
+ except OSError as exc:
439
+ die(f"cannot write output: {exc}")
440
+ out(
441
+ {
442
+ "exported": args.output,
443
+ "title": note["title"],
444
+ "format": args.format,
445
+ "bytes": len(content.encode("utf-8")),
446
+ }
447
+ )
448
+ else:
449
+ print(content)
450
+
451
+
452
+ def _dry(action: str, **fields) -> None:
453
+ payload = {"dry_run": True, "action": action}
454
+ payload.update(fields)
455
+ payload["hint"] = "re-run with --confirm as the LAST argument to apply"
456
+ out(payload)
457
+
458
+
459
+ def cmd_create(args) -> None:
460
+ body = _read_body(args)
461
+ if not CONFIRM:
462
+ preview = args.title + (("\n" + body) if body else "")
463
+ _dry("create", title=args.title, folder=args.folder or "(default)", preview=preview)
464
+ return
465
+ note_id = osa(_SCRIPT_CREATE, ANOTE_BODY=text_to_html(args.title, body), ANOTE_FOLDER=args.folder or "").strip()
466
+ out({"created": True, "id": note_id, "title": args.title, "folder": args.folder or "(default)"})
467
+
468
+
469
+ def cmd_append(args) -> None:
470
+ body = _read_body(args)
471
+ if not body.strip():
472
+ die("nothing to append (empty body).")
473
+ if not CONFIRM:
474
+ _dry("append", note_id=args.note_id, preview=body)
475
+ return
476
+ note_id = osa(_SCRIPT_APPEND, ANOTE_ID=args.note_id, ANOTE_BODY=text_to_html("", body)).strip()
477
+ out({"appended": True, "id": note_id})
478
+
479
+
480
+ def cmd_move(args) -> None:
481
+ if not CONFIRM:
482
+ _dry("move", note_id=args.note_id, to_folder=args.folder)
483
+ return
484
+ note_id = osa(_SCRIPT_MOVE, ANOTE_ID=args.note_id, ANOTE_FOLDER=args.folder).strip()
485
+ out({"moved": True, "id": note_id, "folder": args.folder})
486
+
487
+
488
+ def cmd_new_folder(args) -> None:
489
+ if not CONFIRM:
490
+ _dry("new-folder", name=args.name)
491
+ return
492
+ name = osa(_SCRIPT_NEWFOLDER, ANOTE_FOLDER=args.name).strip()
493
+ out({"created_folder": True, "name": name})
494
+
495
+
496
+ def cmd_delete(args) -> None:
497
+ if not CONFIRM:
498
+ _dry("delete", note_id=args.note_id, note="moves the note to Recently Deleted (recoverable)")
499
+ return
500
+ note_id = osa(_SCRIPT_DELETE, ANOTE_ID=args.note_id).strip()
501
+ out({"deleted": True, "id": note_id, "note": "moved to Recently Deleted (recoverable ~30 days)"})
502
+
503
+
504
+ def main() -> None:
505
+ if sys.platform != "darwin":
506
+ die("apple-notes is macOS-only (it drives Notes.app via AppleScript).")
507
+
508
+ parser = argparse.ArgumentParser(prog="notes.py", description="Manage Apple Notes via AppleScript.")
509
+ common = argparse.ArgumentParser(add_help=False)
510
+ common.add_argument("--timeout", type=int, default=90, help="osascript timeout in seconds (default 90)")
511
+ sub = parser.add_subparsers(dest="cmd", required=True)
512
+
513
+ sub.add_parser("folders", parents=[common], help="list folders with note counts").set_defaults(func=cmd_folders)
514
+
515
+ p = sub.add_parser("list", parents=[common], help="list notes (newest first)")
516
+ p.add_argument("--folder")
517
+ p.add_argument("--limit", type=int, default=50)
518
+ p.set_defaults(func=cmd_list)
519
+
520
+ p = sub.add_parser("search", parents=[common], help="search notes by title + body")
521
+ p.add_argument("query")
522
+ p.add_argument("--folder")
523
+ p.add_argument("--limit", type=int, default=50)
524
+ p.set_defaults(func=cmd_search)
525
+
526
+ p = sub.add_parser("view", parents=[common], help="show one note")
527
+ p.add_argument("note_id")
528
+ p.add_argument("--format", choices=["json", "text", "html"], default="json")
529
+ p.set_defaults(func=cmd_view)
530
+
531
+ p = sub.add_parser("export", parents=[common], help="export one note")
532
+ p.add_argument("note_id")
533
+ p.add_argument("--format", choices=["md", "html", "text"], default="md")
534
+ p.add_argument("-o", "--output")
535
+ p.set_defaults(func=cmd_export)
536
+
537
+ p = sub.add_parser("create", parents=[common], help="create a note (GATED)")
538
+ p.add_argument("--title", required=True)
539
+ grp = p.add_mutually_exclusive_group()
540
+ grp.add_argument("--body")
541
+ grp.add_argument("--body-file")
542
+ p.add_argument("--folder")
543
+ p.set_defaults(func=cmd_create)
544
+
545
+ p = sub.add_parser("append", parents=[common], help="append text to a note (GATED)")
546
+ p.add_argument("note_id")
547
+ grp = p.add_mutually_exclusive_group(required=True)
548
+ grp.add_argument("--body")
549
+ grp.add_argument("--body-file")
550
+ p.set_defaults(func=cmd_append)
551
+
552
+ p = sub.add_parser("move", parents=[common], help="move a note to a folder (GATED)")
553
+ p.add_argument("note_id")
554
+ p.add_argument("--folder", required=True)
555
+ p.set_defaults(func=cmd_move)
556
+
557
+ p = sub.add_parser("new-folder", parents=[common], help="create a folder (GATED)")
558
+ p.add_argument("name")
559
+ p.set_defaults(func=cmd_new_folder)
560
+
561
+ p = sub.add_parser("delete", parents=[common], help="move a note to Recently Deleted (GATED)")
562
+ p.add_argument("note_id")
563
+ p.set_defaults(func=cmd_delete)
564
+
565
+ args = parser.parse_args(ARGV)
566
+ global TIMEOUT
567
+ TIMEOUT = args.timeout
568
+ args.func(args)
569
+
570
+
571
+ if __name__ == "__main__":
572
+ main()