freshjots 0.2.1 → 0.4.0

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/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # freshjots — JavaScript
1
+ # freshjots — JS, TS, Windows CLI
2
2
 
3
3
  Tiny JavaScript client for the [Fresh Jots](https://freshjots.com) API.
4
4
  One file, zero dependencies (uses Node 18's global `fetch`).
@@ -26,9 +26,9 @@ await client.append("cron-jobs-prod", "backup ok");
26
26
  const note = await client.note("cron-jobs-prod");
27
27
  console.log(note.plain_body);
28
28
 
29
- // List your notes.
30
- const notes = await client.notes();
31
- for (const n of notes) console.log(`${n.filename}\t${n.title}`);
29
+ // List your notes (most recent activity first). Pass options to sort/filter:
30
+ const notes = await client.notes({ sort: "created", folderId: 3, limit: 20 });
31
+ for (const n of notes) console.log(`${n.id}\t${n.filename}\t${n.title}`);
32
32
 
33
33
  // Create a note. The API derives the filename from the title — for a
34
34
  // note addressable by an exact filename, use append() instead.
@@ -36,10 +36,61 @@ const created = await client.create({ title: "Research 2026 Q2", body: "Initial
36
36
  console.log(created.filename); // server-derived stream name
37
37
  ```
38
38
 
39
- The whole API is four methods: `notes()`, `note(filename)`,
40
- `create({ title, body })`, `append(filename, text)`. `note()` and
41
- `create()` return the note object directly (no `{ note: … }` wrapper);
42
- `notes()` returns the array.
39
+ Client methods: `notes({ sort, folderId, limit, offset })`,
40
+ `note(filename)`, `noteById(id)`, `create({ title, body })`,
41
+ `append(filename, text)`, `remove(id)`, `move(id, folderId)`, and
42
+ `folders()`. `note()`/`noteById()`/`create()` return the note object
43
+ directly (no `{ note: … }` wrapper); `notes()` and `folders()` return
44
+ arrays. For `notes()`, `sort` is `created|updated|appended` and
45
+ `folderId` may be a folder id or `"none"` (un-foldered only).
46
+
47
+ ## CLI
48
+
49
+ Installing the package globally puts a `freshjots` command on your
50
+ PATH, so you can read and write notes straight from a terminal without
51
+ writing any JavaScript. This works in bash, zsh, fish, Windows
52
+ PowerShell, and CMD — npm generates a `.cmd` shim on Windows
53
+ automatically.
54
+
55
+ ```sh
56
+ npm install -g freshjots
57
+ export FRESHJOTS_TOKEN=mn_… # PowerShell: $env:FRESHJOTS_TOKEN = "mn_…"
58
+ ```
59
+
60
+ The CLI covers reading, writing, and organizing notes:
61
+
62
+ ```sh
63
+ freshjots ls # prints "<id>\t<filename>\t<title>" per row
64
+ freshjots ls -n 10 --sort created # last 10 by creation (--sort created|updated|appended)
65
+ freshjots ls --folder Work # filter by folder id or name; --root for un-foldered
66
+ freshjots ls --all -l # every page, long format (id, updated, lock, folder, …)
67
+ freshjots get 42 # full note as JSON
68
+ freshjots cat cron-jobs-prod # note body, by id or filename
69
+ freshjots create "Research 2026 Q2" # body from stdin or --body
70
+ freshjots append cron-jobs-prod "ok" # text may also be piped on stdin
71
+ freshjots rm cron-jobs-prod # delete by id or filename
72
+ freshjots mv cron-jobs-prod Work # move into a folder (id or name); --root to un-folder
73
+ freshjots folders # prints "<id>\t<name>" per row
74
+ ```
75
+
76
+ Both `create` and `append` read from stdin when the body or text isn't
77
+ passed as an argument, so the usual pipe patterns work:
78
+
79
+ ```sh
80
+ backup.sh && echo "backup ok $(date -Iseconds)" | freshjots append cron-jobs-prod
81
+ git log -1 --pretty=format:"%h %s" | freshjots append deploys
82
+ ```
83
+
84
+ The same patterns work in PowerShell:
85
+
86
+ ```powershell
87
+ "backup ok $(Get-Date -Format o)" | freshjots append cron-jobs-prod
88
+ freshjots create "Deploy log" --body "Initial entry."
89
+ ```
90
+
91
+ Exit codes: `0` on success, `1` on runtime errors (missing token,
92
+ network failure, non-2xx API response — printed as `Error: HTTP <status>
93
+ <code>: <message>`), `2` on usage errors.
43
94
 
44
95
  ## TypeScript
45
96
 
@@ -92,8 +143,8 @@ Stable error codes: `unauthenticated`, `forbidden`, `not_found`,
92
143
 
93
144
  ## Auth
94
145
 
95
- Mint a token at <https://freshjots.com/settings/api_tokens> (Dev or
96
- Dev-pro tier required). Set it once:
146
+ Mint a token at <https://freshjots.com/settings/api_tokens> (Pro or
147
+ Team tier required). Set it once:
97
148
 
98
149
  ```sh
99
150
  export FRESHJOTS_TOKEN=<your-token>
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import { run } from "../cli.js";
3
+
4
+ run(process.argv.slice(2)).then((code) => process.exit(code));
package/cli.js ADDED
@@ -0,0 +1,271 @@
1
+ // CLI dispatcher for the `freshjots` command. The shebang entry
2
+ // (bin/freshjots.js) is a one-line wrapper around run() so this module
3
+ // stays import-safe and testable: parseArgs is pure, run takes its I/O
4
+ // surface as deps so tests can stub stdin/stdout/stderr and the Client.
5
+
6
+ import { Client, ApiError, VERSION } from "./index.js";
7
+
8
+ const USAGE = `freshjots — Fresh Jots CLI
9
+
10
+ Usage:
11
+ freshjots ls [flags] List notes as id<TAB>filename<TAB>title.
12
+ -n N | --limit N
13
+ --sort created|updated|appended
14
+ --folder <id|name> | --root
15
+ --all fetch every page (past the 200 cap)
16
+ -l|--long id, updated_at, lock, folder, name, title
17
+ freshjots get <id> Print a note as JSON (full metadata).
18
+ freshjots cat <id|filename> Print a note's body.
19
+ freshjots create <title> [--body <text>]
20
+ freshjots append <filename> [<text>]
21
+ freshjots rm <id|filename> Delete a note.
22
+ freshjots mv <id|filename> <folder-id|name|--root>
23
+ freshjots folders List folders as id<TAB>name.
24
+ freshjots --help | --version
25
+
26
+ Notes:
27
+ - <text> for append and --body for create may also be piped on stdin.
28
+ - Auth: set FRESHJOTS_TOKEN. Mint one at
29
+ https://freshjots.com/settings/api_tokens.
30
+ `;
31
+
32
+ const isNumeric = (s) => /^\d+$/.test(s);
33
+ const errResult = (message) => ({ command: "error", message });
34
+
35
+ export function parseArgs(argv) {
36
+ if (argv.length === 0) return { command: "help", exitCode: 2 };
37
+ const [first, ...rest] = argv;
38
+
39
+ if (first === "-h" || first === "--help" || first === "help") {
40
+ return { command: "help", exitCode: 0 };
41
+ }
42
+ if (first === "-v" || first === "--version" || first === "version") {
43
+ return { command: "version" };
44
+ }
45
+ if (first === "list" || first === "ls") {
46
+ const opts = { command: "list", limit: null, sort: null, folder: null, all: false, long: false };
47
+ for (let i = 0; i < rest.length; i++) {
48
+ const a = rest[i];
49
+ if (a === "-n" || a === "--limit") {
50
+ if (i + 1 >= rest.length) return errResult("--limit requires a value");
51
+ opts.limit = rest[++i];
52
+ } else if (a === "--sort") {
53
+ if (i + 1 >= rest.length) return errResult("--sort requires a value");
54
+ opts.sort = rest[++i];
55
+ } else if (a === "--folder") {
56
+ if (i + 1 >= rest.length) return errResult("--folder requires a value");
57
+ opts.folder = rest[++i];
58
+ } else if (a === "--root") {
59
+ opts.folder = "none";
60
+ } else if (a === "--all") {
61
+ opts.all = true;
62
+ } else if (a === "-l" || a === "--long") {
63
+ opts.long = true;
64
+ } else {
65
+ return errResult(`unknown flag for list: ${a}`);
66
+ }
67
+ }
68
+ return opts;
69
+ }
70
+ if (first === "get") {
71
+ if (rest.length !== 1) return errResult("get requires exactly one <id>");
72
+ return { command: "get", id: rest[0] };
73
+ }
74
+ if (first === "show" || first === "cat") {
75
+ if (rest.length !== 1) return errResult(`${first} requires exactly one <id|filename>`);
76
+ return { command: "show", target: rest[0] };
77
+ }
78
+ if (first === "create") {
79
+ let body;
80
+ const positional = [];
81
+ for (let i = 0; i < rest.length; i++) {
82
+ const a = rest[i];
83
+ if (a === "--body" || a === "-b") {
84
+ if (i + 1 >= rest.length) return errResult("--body requires a value");
85
+ body = rest[++i];
86
+ } else if (a.startsWith("--body=")) {
87
+ body = a.slice("--body=".length);
88
+ } else {
89
+ positional.push(a);
90
+ }
91
+ }
92
+ if (positional.length !== 1) return errResult("create requires exactly one <title>");
93
+ return { command: "create", title: positional[0], body };
94
+ }
95
+ if (first === "append") {
96
+ if (rest.length < 1 || rest.length > 2) {
97
+ return errResult("append requires <filename> and optional <text>");
98
+ }
99
+ return { command: "append", filename: rest[0], text: rest[1] };
100
+ }
101
+ if (first === "rm" || first === "delete") {
102
+ if (rest.length !== 1) return errResult(`${first} requires exactly one <id|filename>`);
103
+ return { command: "rm", target: rest[0] };
104
+ }
105
+ if (first === "mv" || first === "move") {
106
+ if (rest.length !== 2) return errResult(`${first} requires <id|filename> <folder-id|name|--root>`);
107
+ return { command: "mv", target: rest[0], dest: rest[1] };
108
+ }
109
+ if (first === "folders") {
110
+ if (rest.length) return errResult("folders takes no arguments");
111
+ return { command: "folders" };
112
+ }
113
+ return errResult(`unknown command: ${first}`);
114
+ }
115
+
116
+ async function readStdin(stdin) {
117
+ if (!stdin || stdin.isTTY) return "";
118
+ let buf = "";
119
+ for await (const chunk of stdin) {
120
+ buf += typeof chunk === "string" ? chunk : chunk.toString("utf8");
121
+ }
122
+ return buf;
123
+ }
124
+
125
+ // A note argument may be a numeric id (used as-is) or a filename/title,
126
+ // resolved to an id via the by-filename lookup (which carries .id).
127
+ async function resolveNoteId(client, target) {
128
+ if (isNumeric(target)) return target;
129
+ return (await client.note(target)).id;
130
+ }
131
+
132
+ // A folder NAME resolves to its id via GET /folders; ambiguous or unknown
133
+ // names are an error (disambiguate with the numeric id).
134
+ async function resolveFolderName(client, name) {
135
+ const matches = (await client.folders()).filter((f) => f.name === name);
136
+ if (matches.length === 0) throw new Error(`no folder named '${name}' (see: freshjots folders)`);
137
+ if (matches.length > 1) throw new Error(`ambiguous folder name '${name}' — use its numeric id`);
138
+ return matches[0].id;
139
+ }
140
+
141
+ function printNotes(notes, long, stdout) {
142
+ for (const n of notes) {
143
+ const title = n.title ?? "(untitled)";
144
+ if (long) {
145
+ const lock = n.append_only ? "L" : "-";
146
+ stdout(`${n.id}\t${n.updated_at}\t${lock}\t${n.folder_id ?? "-"}\t${n.filename}\t${title}\n`);
147
+ } else {
148
+ stdout(`${n.id}\t${n.filename}\t${title}\n`);
149
+ }
150
+ }
151
+ }
152
+
153
+ export async function run(argv, deps = {}) {
154
+ const env = deps.env ?? process.env;
155
+ const stdout = deps.stdout ?? ((s) => process.stdout.write(s));
156
+ const stderr = deps.stderr ?? ((s) => process.stderr.write(s));
157
+ const stdin = deps.stdin ?? process.stdin;
158
+ const clientFactory = deps.clientFactory ?? ((token) => new Client({ token }));
159
+
160
+ const parsed = parseArgs(argv);
161
+
162
+ if (parsed.command === "help") {
163
+ (parsed.exitCode ? stderr : stdout)(USAGE);
164
+ return parsed.exitCode ?? 0;
165
+ }
166
+ if (parsed.command === "version") {
167
+ stdout(`freshjots ${VERSION}\n`);
168
+ return 0;
169
+ }
170
+ if (parsed.command === "error") {
171
+ stderr(`Error: ${parsed.message}\n\n${USAGE}`);
172
+ return 2;
173
+ }
174
+
175
+ const token = env.FRESHJOTS_TOKEN;
176
+ if (!token) {
177
+ stderr("Error: FRESHJOTS_TOKEN is not set. Mint one at https://freshjots.com/settings/api_tokens\n");
178
+ return 1;
179
+ }
180
+
181
+ let client;
182
+ try {
183
+ client = clientFactory(token);
184
+ } catch (e) {
185
+ stderr(`Error: ${e.message}\n`);
186
+ return 1;
187
+ }
188
+
189
+ try {
190
+ if (parsed.command === "list") {
191
+ let folderId;
192
+ if (parsed.folder) {
193
+ if (parsed.folder === "none") folderId = "none";
194
+ else if (isNumeric(parsed.folder)) folderId = parsed.folder;
195
+ else folderId = await resolveFolderName(client, parsed.folder);
196
+ }
197
+ let notes;
198
+ if (parsed.all) {
199
+ notes = [];
200
+ let offset = 0;
201
+ for (;;) {
202
+ const page = await client.notes({ sort: parsed.sort, folderId, limit: 200, offset });
203
+ notes.push(...page);
204
+ if (page.length < 200) break;
205
+ offset += 200;
206
+ if (offset >= 100000) {
207
+ stderr("stopping at 100000 notes (safety cap) — narrow with --folder or --sort\n");
208
+ break;
209
+ }
210
+ }
211
+ } else {
212
+ notes = await client.notes({ sort: parsed.sort, folderId, limit: parsed.limit ?? undefined });
213
+ }
214
+ printNotes(notes, parsed.long, stdout);
215
+ return 0;
216
+ }
217
+ if (parsed.command === "get") {
218
+ stdout(`${JSON.stringify(await client.noteById(parsed.id), null, 2)}\n`);
219
+ return 0;
220
+ }
221
+ if (parsed.command === "show") {
222
+ const note = isNumeric(parsed.target)
223
+ ? await client.noteById(parsed.target)
224
+ : await client.note(parsed.target);
225
+ stdout(note.plain_body ?? "");
226
+ return 0;
227
+ }
228
+ if (parsed.command === "create") {
229
+ let body = parsed.body;
230
+ if (body === undefined) body = await readStdin(stdin);
231
+ const created = await client.create({ title: parsed.title, body });
232
+ stdout(`${created.filename}\n`);
233
+ return 0;
234
+ }
235
+ if (parsed.command === "append") {
236
+ let text = parsed.text;
237
+ if (text === undefined) text = await readStdin(stdin);
238
+ if (!text) {
239
+ stderr("Error: append requires text (as an argument or on stdin)\n");
240
+ return 2;
241
+ }
242
+ await client.append(parsed.filename, text);
243
+ return 0;
244
+ }
245
+ if (parsed.command === "rm") {
246
+ await client.remove(await resolveNoteId(client, parsed.target));
247
+ return 0;
248
+ }
249
+ if (parsed.command === "mv") {
250
+ const id = await resolveNoteId(client, parsed.target);
251
+ let folderId;
252
+ if (["--root", "root", "none", "null"].includes(parsed.dest)) folderId = null;
253
+ else if (isNumeric(parsed.dest)) folderId = parsed.dest;
254
+ else folderId = await resolveFolderName(client, parsed.dest);
255
+ await client.move(id, folderId);
256
+ return 0;
257
+ }
258
+ if (parsed.command === "folders") {
259
+ for (const f of await client.folders()) stdout(`${f.id}\t${f.name}\n`);
260
+ return 0;
261
+ }
262
+ } catch (e) {
263
+ if (e instanceof ApiError) {
264
+ stderr(`Error: HTTP ${e.status} ${e.code}: ${e.message}\n`);
265
+ } else {
266
+ stderr(`Error: ${e.message}\n`);
267
+ }
268
+ return 1;
269
+ }
270
+ return 0;
271
+ }
package/index.d.ts CHANGED
@@ -56,6 +56,23 @@ declare module "freshjots" {
56
56
  constructor(opts: { status: number; code: ApiErrorCode; message: string; details?: unknown });
57
57
  }
58
58
 
59
+ /** A folder, as returned by `folders()`. */
60
+ export interface Folder {
61
+ id: number;
62
+ name: string;
63
+ created_at: string;
64
+ updated_at: string;
65
+ }
66
+
67
+ /** Options for `notes()` — mirror the API's list query params. */
68
+ export interface ListOptions {
69
+ sort?: "created" | "updated" | "appended";
70
+ /** A folder id, or "none" for un-foldered notes only. */
71
+ folderId?: number | string;
72
+ limit?: number;
73
+ offset?: number;
74
+ }
75
+
59
76
  export interface ClientOptions {
60
77
  token?: string;
61
78
  baseUrl?: string;
@@ -75,9 +92,13 @@ declare module "freshjots" {
75
92
  token: string;
76
93
  baseUrl: string;
77
94
  constructor(options?: ClientOptions);
78
- notes(): Promise<NoteSummary[]>;
95
+ notes(options?: ListOptions): Promise<NoteSummary[]>;
79
96
  note(filename: string): Promise<Note>;
97
+ noteById(id: number | string): Promise<Note>;
80
98
  create(input: CreateInput): Promise<Note>;
81
99
  append(filename: string, text: string): Promise<true>;
100
+ remove(id: number | string): Promise<true>;
101
+ move(id: number | string, folderId: number | string | null): Promise<Note>;
102
+ folders(): Promise<Folder[]>;
82
103
  }
83
104
  }
package/index.js CHANGED
@@ -18,7 +18,7 @@
18
18
  // payload ({ "notes": [...] }). show / show-by-filename / create return
19
19
  // the note object at the TOP LEVEL — there is no { "note": ... } wrapper.
20
20
 
21
- export const VERSION = "0.2.1";
21
+ export const VERSION = "0.4.0";
22
22
  const DEFAULT_BASE_URL = "https://freshjots.com/api/v1";
23
23
 
24
24
  export class ApiError extends Error {
@@ -40,8 +40,20 @@ export class Client {
40
40
  this.baseUrl = baseUrl;
41
41
  }
42
42
 
43
- async notes() {
44
- return (await this._request("GET", "/notes")).notes;
43
+ // List notes (summary projection). Options mirror the API query params:
44
+ // sort: "created" | "updated" | "appended" (default updated)
45
+ // folderId: a folder id, or "none" for un-foldered notes only
46
+ // limit/offset: pagination (server caps a page at 200)
47
+ async notes({ sort, folderId, limit, offset } = {}) {
48
+ const qs = new URLSearchParams();
49
+ if (sort) qs.set("sort", sort);
50
+ if (folderId !== undefined && folderId !== null && folderId !== "") {
51
+ qs.set("folder_id", String(folderId));
52
+ }
53
+ if (limit !== undefined && limit !== null && limit !== "") qs.set("limit", String(limit));
54
+ if (offset !== undefined && offset !== null && offset !== "") qs.set("offset", String(offset));
55
+ const q = qs.toString();
56
+ return (await this._request("GET", q ? `/notes?${q}` : "/notes")).notes;
45
57
  }
46
58
 
47
59
  async note(filename) {
@@ -51,6 +63,32 @@ export class Client {
51
63
  return await this._request("GET", path);
52
64
  }
53
65
 
66
+ // Full note by numeric id (GET /notes/:id) — top-level serializer.
67
+ async noteById(id) {
68
+ return await this._request("GET", `/notes/${encodeURIComponent(id)}`);
69
+ }
70
+
71
+ // Delete a note by id. Locked (append-only) notes are refused by the
72
+ // API with note_locked. Returns true on success (204).
73
+ async remove(id) {
74
+ await this._request("DELETE", `/notes/${encodeURIComponent(id)}`);
75
+ return true;
76
+ }
77
+
78
+ // Move a note into a folder (or to the root with folderId null/"none").
79
+ async move(id, folderId) {
80
+ const folder_id =
81
+ folderId === null || folderId === undefined || folderId === "" || folderId === "none"
82
+ ? null
83
+ : folderId;
84
+ return await this._request("POST", `/notes/${encodeURIComponent(id)}/move`, { folder_id });
85
+ }
86
+
87
+ // List folders ({ folders: [...] } envelope).
88
+ async folders() {
89
+ return (await this._request("GET", "/folders")).folders;
90
+ }
91
+
54
92
  // Create a note. The API permits note[title, plain_body, format, ...]
55
93
  // — NOT filename: the server DERIVES the filename from the title. For
56
94
  // a note addressable by an exact, caller-chosen filename, use append()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "freshjots",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "description": "Tiny JavaScript client for the Fresh Jots API. Append-only notebooks for cron jobs, deploy scripts, and bots.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -11,9 +11,14 @@
11
11
  "default": "./index.js"
12
12
  }
13
13
  },
14
+ "bin": {
15
+ "freshjots": "./bin/freshjots.js"
16
+ },
14
17
  "files": [
15
18
  "index.js",
16
19
  "index.d.ts",
20
+ "cli.js",
21
+ "bin/",
17
22
  "README.md",
18
23
  "LICENSE"
19
24
  ],