freshjots 0.3.0 → 1.0.1

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.
Files changed (5) hide show
  1. package/README.md +60 -46
  2. package/cli.js +137 -15
  3. package/index.d.ts +22 -1
  4. package/index.js +41 -3
  5. package/package.json +1 -1
package/README.md CHANGED
@@ -1,46 +1,8 @@
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`).
5
5
 
6
- ## Install
7
-
8
- ```sh
9
- npm install freshjots
10
- ```
11
-
12
- (Or `pnpm add freshjots`, `yarn add freshjots`, `bun add freshjots`.)
13
-
14
- ## Use
15
-
16
- ```js
17
- import { Client } from "freshjots";
18
-
19
- // Reads FRESHJOTS_TOKEN from the environment by default.
20
- const client = new Client();
21
-
22
- // Append text to a note (creates it if missing).
23
- await client.append("cron-jobs-prod", "backup ok");
24
-
25
- // Read a note's body.
26
- const note = await client.note("cron-jobs-prod");
27
- console.log(note.plain_body);
28
-
29
- // List your notes.
30
- const notes = await client.notes();
31
- for (const n of notes) console.log(`${n.filename}\t${n.title}`);
32
-
33
- // Create a note. The API derives the filename from the title — for a
34
- // note addressable by an exact filename, use append() instead.
35
- const created = await client.create({ title: "Research 2026 Q2", body: "Initial outline." });
36
- console.log(created.filename); // server-derived stream name
37
- ```
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.
43
-
44
6
  ## CLI
45
7
 
46
8
  Installing the package globally puts a `freshjots` command on your
@@ -51,16 +13,27 @@ automatically.
51
13
 
52
14
  ```sh
53
15
  npm install -g freshjots
54
- export FRESHJOTS_TOKEN=mn_… # PowerShell: $env:FRESHJOTS_TOKEN = "mn_…"
16
+
17
+ # Persist the token for every new shell (macOS defaults to zsh; use ~/.bashrc on bash):
18
+ echo 'export FRESHJOTS_TOKEN=mn_…' >> ~/.zshrc && source ~/.zshrc
19
+ # Windows PowerShell: [Environment]::SetEnvironmentVariable("FRESHJOTS_TOKEN", "mn_…", "User")
55
20
  ```
56
21
 
57
- The CLI mirrors the four API methods one-for-one:
22
+ The CLI covers reading, writing, and organizing notes:
58
23
 
59
24
  ```sh
60
- freshjots list # prints "<filename>\t<title>" per row
61
- freshjots show cron-jobs-prod # prints the note's plain_body
62
- freshjots create "Research 2026 Q2" # body comes from stdin or --body
25
+ freshjots ls # prints "<id>\t<filename>\t<title>" per row
26
+ freshjots ls -n 10 --sort created # last 10 by creation (--sort created|updated|appended)
27
+ freshjots ls --folder Work # filter by folder id or name; --root for un-foldered
28
+ freshjots ls --all -l # every page, long format (id, updated, lock, folder, …)
29
+ freshjots get 42 # full note as JSON
30
+ freshjots cat cron-jobs-prod # note body, by id or filename
31
+ freshjots create "Research 2026 Q2" # body from stdin or --body
63
32
  freshjots append cron-jobs-prod "ok" # text may also be piped on stdin
33
+ freshjots rm cron-jobs-prod # delete by id or filename
34
+ freshjots mv cron-jobs-prod Work # move into a folder (id or name); --root to un-folder
35
+ freshjots folders # prints "<id>\t<name>" per row
36
+ freshjots --version # print version (--help for full usage)
64
37
  ```
65
38
 
66
39
  Both `create` and `append` read from stdin when the body or text isn't
@@ -82,6 +55,47 @@ Exit codes: `0` on success, `1` on runtime errors (missing token,
82
55
  network failure, non-2xx API response — printed as `Error: HTTP <status>
83
56
  <code>: <message>`), `2` on usage errors.
84
57
 
58
+ ## Install
59
+
60
+ ```sh
61
+ npm install freshjots
62
+ ```
63
+
64
+ (Or `pnpm add freshjots`, `yarn add freshjots`, `bun add freshjots`.)
65
+
66
+ ## Use
67
+
68
+ ```js
69
+ import { Client } from "freshjots";
70
+
71
+ // Reads FRESHJOTS_TOKEN from the environment by default.
72
+ const client = new Client();
73
+
74
+ // Append text to a note (creates it if missing).
75
+ await client.append("cron-jobs-prod", "backup ok");
76
+
77
+ // Read a note's body.
78
+ const note = await client.note("cron-jobs-prod");
79
+ console.log(note.plain_body);
80
+
81
+ // List your notes (most recent activity first). Pass options to sort/filter:
82
+ const notes = await client.notes({ sort: "created", folderId: 3, limit: 20 });
83
+ for (const n of notes) console.log(`${n.id}\t${n.filename}\t${n.title}`);
84
+
85
+ // Create a note. The API derives the filename from the title — for a
86
+ // note addressable by an exact filename, use append() instead.
87
+ const created = await client.create({ title: "Research 2026 Q2", body: "Initial outline." });
88
+ console.log(created.filename); // server-derived stream name
89
+ ```
90
+
91
+ Client methods: `notes({ sort, folderId, limit, offset })`,
92
+ `note(filename)`, `noteById(id)`, `create({ title, body })`,
93
+ `append(filename, text)`, `remove(id)`, `move(id, folderId)`, and
94
+ `folders()`. `note()`/`noteById()`/`create()` return the note object
95
+ directly (no `{ note: … }` wrapper); `notes()` and `folders()` return
96
+ arrays. For `notes()`, `sort` is `created|updated|appended` and
97
+ `folderId` may be a folder id or `"none"` (un-foldered only).
98
+
85
99
  ## TypeScript
86
100
 
87
101
  Types ship with the package — no `@types/freshjots` needed, no `.d.ts`
@@ -133,8 +147,8 @@ Stable error codes: `unauthenticated`, `forbidden`, `not_found`,
133
147
 
134
148
  ## Auth
135
149
 
136
- Mint a token at <https://freshjots.com/settings/api_tokens> (Dev or
137
- Dev-pro tier required). Set it once:
150
+ Mint a token at <https://freshjots.com/settings/api_tokens> (Pro or
151
+ Team tier required). Set it once:
138
152
 
139
153
  ```sh
140
154
  export FRESHJOTS_TOKEN=<your-token>
package/cli.js CHANGED
@@ -8,10 +8,19 @@ import { Client, ApiError, VERSION } from "./index.js";
8
8
  const USAGE = `freshjots — Fresh Jots CLI
9
9
 
10
10
  Usage:
11
- freshjots list
12
- freshjots show <filename>
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.
13
19
  freshjots create <title> [--body <text>]
14
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.
15
24
  freshjots --help | --version
16
25
 
17
26
  Notes:
@@ -20,6 +29,9 @@ Notes:
20
29
  https://freshjots.com/settings/api_tokens.
21
30
  `;
22
31
 
32
+ const isNumeric = (s) => /^\d+$/.test(s);
33
+ const errResult = (message) => ({ command: "error", message });
34
+
23
35
  export function parseArgs(argv) {
24
36
  if (argv.length === 0) return { command: "help", exitCode: 2 };
25
37
  const [first, ...rest] = argv;
@@ -30,13 +42,38 @@ export function parseArgs(argv) {
30
42
  if (first === "-v" || first === "--version" || first === "version") {
31
43
  return { command: "version" };
32
44
  }
33
- if (first === "list") {
34
- if (rest.length) return { command: "error", message: "list takes no arguments" };
35
- return { command: "list" };
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] };
36
73
  }
37
- if (first === "show") {
38
- if (rest.length !== 1) return { command: "error", message: "show requires exactly one <filename>" };
39
- return { command: "show", filename: rest[0] };
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] };
40
77
  }
41
78
  if (first === "create") {
42
79
  let body;
@@ -44,7 +81,7 @@ export function parseArgs(argv) {
44
81
  for (let i = 0; i < rest.length; i++) {
45
82
  const a = rest[i];
46
83
  if (a === "--body" || a === "-b") {
47
- if (i + 1 >= rest.length) return { command: "error", message: "--body requires a value" };
84
+ if (i + 1 >= rest.length) return errResult("--body requires a value");
48
85
  body = rest[++i];
49
86
  } else if (a.startsWith("--body=")) {
50
87
  body = a.slice("--body=".length);
@@ -52,16 +89,28 @@ export function parseArgs(argv) {
52
89
  positional.push(a);
53
90
  }
54
91
  }
55
- if (positional.length !== 1) return { command: "error", message: "create requires exactly one <title>" };
92
+ if (positional.length !== 1) return errResult("create requires exactly one <title>");
56
93
  return { command: "create", title: positional[0], body };
57
94
  }
58
95
  if (first === "append") {
59
96
  if (rest.length < 1 || rest.length > 2) {
60
- return { command: "error", message: "append requires <filename> and optional <text>" };
97
+ return errResult("append requires <filename> and optional <text>");
61
98
  }
62
99
  return { command: "append", filename: rest[0], text: rest[1] };
63
100
  }
64
- return { command: "error", message: `unknown command: ${first}` };
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}`);
65
114
  }
66
115
 
67
116
  async function readStdin(stdin) {
@@ -73,6 +122,34 @@ async function readStdin(stdin) {
73
122
  return buf;
74
123
  }
75
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
+
76
153
  export async function run(argv, deps = {}) {
77
154
  const env = deps.env ?? process.env;
78
155
  const stdout = deps.stdout ?? ((s) => process.stdout.write(s));
@@ -111,12 +188,40 @@ export async function run(argv, deps = {}) {
111
188
 
112
189
  try {
113
190
  if (parsed.command === "list") {
114
- const notes = await client.notes();
115
- for (const n of notes) stdout(`${n.filename}\t${n.title}\n`);
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`);
116
219
  return 0;
117
220
  }
118
221
  if (parsed.command === "show") {
119
- const note = await client.note(parsed.filename);
222
+ const note = isNumeric(parsed.target)
223
+ ? await client.noteById(parsed.target)
224
+ : await client.note(parsed.target);
120
225
  stdout(note.plain_body ?? "");
121
226
  return 0;
122
227
  }
@@ -137,6 +242,23 @@ export async function run(argv, deps = {}) {
137
242
  await client.append(parsed.filename, text);
138
243
  return 0;
139
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
+ }
140
262
  } catch (e) {
141
263
  if (e instanceof ApiError) {
142
264
  stderr(`Error: HTTP ${e.status} ${e.code}: ${e.message}\n`);
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.3.0";
21
+ export const VERSION = "1.0.1";
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.3.0",
3
+ "version": "1.0.1",
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",