notefeed 0.6.0 → 0.8.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
@@ -8,6 +8,8 @@ export NOTEFEED_URL=https://notes.example.com NOTEFEED_FEED=homelab-7f3k2q9x4m8w
8
8
  npx notefeed post "# Backup finished"
9
9
  backup.sh 2>&1 | npx notefeed post -
10
10
  npx notefeed notes --limit 5
11
+ npx notefeed edit 20260929T140512Z-backup-finished "# Backup finished, verified"
12
+ npx notefeed delete 20260929T140512Z-backup-finished
11
13
  ```
12
14
 
13
15
  ```js
@@ -19,6 +21,16 @@ console.log(created.url, created.read_url);
19
21
  for await (const note of client.notes()) console.log(note.created_at, note.title); // newest first, page by page
20
22
  ```
21
23
 
24
+ `read_url` is `null` for a feed without a read link (rare; see the operations docs).
25
+
22
26
  `Client.fromEnv()` reads `NOTEFEED_URL` / `NOTEFEED_FEED` / `NOTEFEED_PASSWORD`; so does the `notefeed` command, unless given `--url` / `--feed` / `--password`.
23
27
 
28
+ A feed can have its own password, which gates reading, listing and posting to it. Pass `feedPassword: "..."` to `new Client(...)` (or per call, e.g. `client.post(text, { feedPassword })`), or set `NOTEFEED_FEED_PASSWORD`, which `fromEnv()` and the `notefeed` command both read (there is deliberately no flag, so it stays out of shell history). It is sent as `X-Feed-Password`; `Authorization` stays the instance password. A feed password is 1 to 256 printable ASCII characters with no space at the start or end; an empty one is the same as none.
29
+
30
+ A posted note can be changed or removed: `await client.edit(id, content)` replaces its content and returns the note (its id and URLs stay), and `await client.delete(id)` removes it. Both take the same `{ feed, feedPassword }` options as `post`, and a missing note is a `NotFoundError`. `notefeed edit <id> <text | - | --file PATH>` prints the note URL; `notefeed delete <id>` prints nothing. Anyone who can post to a feed can edit and delete its notes, and deleting is permanent.
31
+
32
+ A feed can have a title and a description, and can be deleted: `await client.feedInfo()` returns `{ name, title, description, protected, read_url }`, `await client.updateFeed({ title, description })` replaces both (an empty string clears one) and returns the feed, and `await client.deleteFeed()` removes the feed with all its notes for good. All three take the same `{ feed, feedPassword }` options as `post`, and a feed that doesn't exist is a `NotFoundError`. Anyone who can post to a feed can do this, and deleting is permanent; there are no `notefeed` commands for it.
33
+
34
+ A picture is a note of its own: `await client.post(bytes, { type: "image/png", alt: "…" })` takes a `Uint8Array` or a `Blob` (PNG, JPEG, GIF or WebP; a Blob's own type is used if you leave `type` out) and returns what a markdown post returns, with the picture's `file` and `file_url` added. Write `![](file)` in a markdown note to show it; the feed is created by its first note, and a picture that is too large is a `NoteTooLargeError`. The server checks the bytes against the declared type. `await client.update(id, { title, alt })` changes a note's title or a picture's alt text, and `await client.edit(id, content)` replaces its content. `await client.updateFeed({ title, description, image: file })` makes a picture the feed's title image (`image: ""` removes it; leaving `image` out keeps it). `notefeed post --file photo.png` posts a file (the type comes from the extension, or `--type`). To post a note with its pictures in one call, `await client.post(text, { attachments: [{ name: "chart.png", content: bytes, type: "image/png" }] })` posts them first and swaps `![](chart.png)` for each file name (`notefeed post "…" --attach chart.png` does the same); a failure carries the pictures already posted as `posted`. Pictures are public to anyone with the feed's read link, and metadata such as EXIF is not removed.
35
+
24
36
  Full documentation: https://docs.notefeed.me/clients/
package/dist/cli.js CHANGED
@@ -1,14 +1,20 @@
1
- /** `notefeed post ...` and `notefeed notes ...`: post and read notes from the command line. */
1
+ /** `notefeed post`, `edit`, `update`, `delete` and `notes`: post, change, remove and read notes, markdown or any accepted file, from the command line. */
2
2
  import { readFileSync } from "node:fs";
3
3
  import { readFile } from "node:fs/promises";
4
+ import { basename, extname } from "node:path";
4
5
  import { parseArgs } from "node:util";
5
6
  import { Client, ConfigError, NotefeedError } from "./client.js";
6
7
  class UsageError extends Error {
7
8
  }
8
9
  const USAGE = [
9
- "usage: notefeed post <text | - | --file PATH> [--url URL] [--feed FEED] [--password PASSWORD]",
10
- " notefeed notes [--limit N] [--json] [--url URL] [--feed FEED] [--password PASSWORD]",
10
+ "usage: notefeed post <text | - | --file PATH> [--type MEDIA_TYPE] [--title TITLE] [--tag TAG]... [--attach PATH]... [--url URL] [--feed FEED] [--password PASSWORD]",
11
+ " notefeed edit <id> <text | - | --file PATH> [--type MEDIA_TYPE] [--url URL] [--feed FEED] [--password PASSWORD]",
12
+ " notefeed update <id> [--title TITLE] [--alt ALT] [--url URL] [--feed FEED] [--password PASSWORD]",
13
+ " notefeed delete <id> [--url URL] [--feed FEED] [--password PASSWORD]",
14
+ " notefeed notes [--limit N] [--tag TAG] [--json] [--url URL] [--feed FEED] [--password PASSWORD]",
11
15
  ].join("\n");
16
+ // The media type of a file by its extension: the types the server accepts.
17
+ const TYPES = { ".md": "text/markdown", ".markdown": "text/markdown", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp" };
12
18
  /** Returns the exit code: 0 ok, 1 server/network error, 2 usage/config error. */
13
19
  export async function main(argv, io = process) {
14
20
  try {
@@ -20,6 +26,11 @@ export async function main(argv, io = process) {
20
26
  feed: { type: "string" },
21
27
  password: { type: "string" },
22
28
  file: { type: "string" },
29
+ type: { type: "string" },
30
+ title: { type: "string" },
31
+ alt: { type: "string" },
32
+ tag: { type: "string", multiple: true },
33
+ attach: { type: "string", multiple: true },
23
34
  limit: { type: "string" },
24
35
  json: { type: "boolean" },
25
36
  version: { type: "boolean" },
@@ -37,21 +48,39 @@ export async function main(argv, io = process) {
37
48
  }
38
49
  const [command, text, ...rest] = positionals;
39
50
  if (command === "post" && !rest.length) {
40
- const markdown = await read(text, values.file, io);
41
- const note = await client(values).post(markdown);
51
+ const input = await read(text, values.file, values.type, io);
52
+ const attachments = await Promise.all((values.attach ?? []).map(attachment));
53
+ const note = await client(values).post(input.content, { type: input.type, title: values.title, tags: values.tag, name: input.name, attachments });
54
+ io.stdout.write(`${[note.url, ...note.attachments.map((a) => a.url)].join("\n")}\n`);
55
+ return 0;
56
+ }
57
+ if (command === "edit" && text !== undefined && rest.length <= 1) {
58
+ const input = await read(rest[0], values.file, values.type, io);
59
+ const note = await client(values).edit(text, input.content, { type: input.type });
42
60
  io.stdout.write(`${note.url}\n`);
43
61
  return 0;
44
62
  }
63
+ if (command === "update" && text !== undefined && !rest.length) {
64
+ if (values.title === undefined && values.alt === undefined)
65
+ throw new UsageError("give --title and/or --alt");
66
+ const note = await client(values).update(text, { title: values.title, alt: values.alt });
67
+ io.stdout.write(`${note.url}\n`);
68
+ return 0;
69
+ }
70
+ if (command === "delete" && text !== undefined && !rest.length) {
71
+ await client(values).delete(text);
72
+ return 0;
73
+ }
45
74
  if (command === "notes" && text === undefined) {
46
75
  const limit = Number(values.limit ?? 20);
47
76
  if (!Number.isInteger(limit) || limit < 1)
48
77
  throw new UsageError("--limit must be a whole number, 1 or more");
49
78
  let left = limit;
50
- for await (const n of client(values).notes({ pageSize: Math.min(limit, 100) })) {
79
+ for await (const n of client(values).notes({ pageSize: Math.min(limit, 100), tag: values.tag?.[0] })) {
51
80
  // To the second, UTC: the same form as the Python CLI, so scripts read either the same way.
52
81
  const when = new Date(n.created_at).toISOString().replace(/\.\d+Z$/, "Z");
53
82
  // --json: exactly the documented fields, like the Python CLI, even if the server adds more.
54
- const fields = { id: n.id, title: n.title, markdown: n.markdown, created_at: when, url: n.url };
83
+ const fields = { id: n.id, type: n.type, title: n.title, ...(n.content !== undefined && { content: n.content }), file: n.file, file_url: n.file_url, size: n.size, tags: n.tags, created_at: when, url: n.url };
55
84
  io.stdout.write(values.json ? `${JSON.stringify(fields)}\n` : `${when} ${n.title || n.id} ${n.url}\n`);
56
85
  if (--left === 0)
57
86
  break;
@@ -62,6 +91,9 @@ export async function main(argv, io = process) {
62
91
  }
63
92
  catch (e) {
64
93
  io.stderr.write(`notefeed: ${e.message.split("\n")[0]}\n`);
94
+ if (e instanceof NotefeedError)
95
+ for (const p of e.posted)
96
+ io.stderr.write(`posted: ${p.url}\n`);
65
97
  if (e instanceof ConfigError || e instanceof UsageError)
66
98
  return 2;
67
99
  if (e instanceof NotefeedError)
@@ -69,7 +101,7 @@ export async function main(argv, io = process) {
69
101
  return 2; // parseArgs rejects unknown options with a TypeError
70
102
  }
71
103
  }
72
- // Flags win over NOTEFEED_URL, NOTEFEED_FEED and NOTEFEED_PASSWORD.
104
+ // Flags win over NOTEFEED_URL, NOTEFEED_FEED and NOTEFEED_PASSWORD. NOTEFEED_FEED_PASSWORD has no flag: it would end up in shell history.
73
105
  function client(values) {
74
106
  const url = values.url || process.env.NOTEFEED_URL;
75
107
  const feed = values.feed || process.env.NOTEFEED_FEED;
@@ -77,28 +109,52 @@ function client(values) {
77
109
  throw new UsageError("no URL given; pass --url or set NOTEFEED_URL");
78
110
  if (!feed)
79
111
  throw new UsageError("no feed given; pass --feed or set NOTEFEED_FEED");
80
- return new Client({ url, feed, password: values.password || process.env.NOTEFEED_PASSWORD });
112
+ return new Client({
113
+ url,
114
+ feed,
115
+ password: values.password || process.env.NOTEFEED_PASSWORD,
116
+ feedPassword: process.env.NOTEFEED_FEED_PASSWORD,
117
+ });
81
118
  }
82
- async function read(text, file, io) {
119
+ // What a post or an edit sends: text on the command line or stdin is markdown; a file is sent as it is, as `--type` or its extension says.
120
+ async function read(text, file, type, io) {
83
121
  if (file) {
122
+ const media = type ?? TYPES[extname(file).toLowerCase()];
123
+ if (!media)
124
+ throw new UsageError(`cannot tell the type of ${file}: pass --type (${Object.values(TYPES).filter((t, i, a) => a.indexOf(t) === i).join(", ")})`);
125
+ let bytes;
84
126
  try {
85
- return utf8(await readFile(file), file);
127
+ bytes = await readFile(file);
86
128
  }
87
129
  catch (e) {
88
- if (e instanceof UsageError)
89
- throw e;
90
130
  throw new UsageError(`cannot read ${file}: ${e.code ?? e.message}`);
91
131
  }
132
+ return { content: media === "text/markdown" ? utf8(bytes, file) : bytes, type: media, name: basename(file) };
92
133
  }
93
134
  if (text === "-") {
94
135
  const chunks = [];
95
136
  for await (const c of io.stdin)
96
137
  chunks.push(typeof c === "string" ? Buffer.from(c) : c);
97
- return utf8(Buffer.concat(chunks), "stdin");
138
+ return { content: utf8(Buffer.concat(chunks), "stdin"), type: type ?? "text/markdown" };
98
139
  }
99
140
  if (text === undefined)
100
141
  throw new UsageError('give the note text, "-" for stdin, or --file PATH');
101
- return text;
142
+ return { content: text, type: type ?? "text/markdown" };
143
+ }
144
+ // A picture to post first: its name is the file's, its type comes from the extension.
145
+ async function attachment(path) {
146
+ const type = TYPES[extname(path).toLowerCase()];
147
+ if (!type || type === "text/markdown")
148
+ throw new UsageError(`cannot attach ${path}: a picture (${Object.keys(TYPES).filter((e) => TYPES[e] !== "text/markdown").join(", ")}) is expected`);
149
+ const name = basename(path);
150
+ if (!/^(?!\.+$)[A-Za-z0-9._-]+$/.test(name))
151
+ throw new UsageError(`cannot attach ${path}: its file name "${name}" is what the text refers to it by, so it may only have letters, digits, ., _ and -: rename the file`);
152
+ try {
153
+ return { name, content: await readFile(path), type };
154
+ }
155
+ catch (e) {
156
+ throw new UsageError(`cannot read ${path}: ${e.code ?? e.message}`);
157
+ }
102
158
  }
103
159
  function utf8(bytes, source) {
104
160
  try {
package/dist/client.d.ts CHANGED
@@ -1,12 +1,20 @@
1
- import type { Created, Error as ApiError, Note } from "./generated/types.gen.js";
1
+ import type { Created, Error as ApiError, Feed, FeedSettings, Note } from "./generated/types.gen.js";
2
2
  /** The stable error codes the API answers with. */
3
3
  export type ErrorCode = NonNullable<ApiError["code"]>;
4
- export type { Created, Note };
4
+ export type { Created, Feed, FeedSettings, Note };
5
+ /** A picture posted with a note: `![](name)` in the markdown refers to it. `type` as for post(). */
6
+ export type Attachment = {
7
+ name: string;
8
+ content: Uint8Array | Blob;
9
+ type?: string;
10
+ alt?: string;
11
+ };
5
12
  /** `timeoutMs` (default 10000) limits each whole request, including reading the answer. */
6
13
  export type ClientOptions = {
7
14
  url: string;
8
15
  feed?: string;
9
16
  password?: string;
17
+ feedPassword?: string;
10
18
  timeoutMs?: number;
11
19
  };
12
20
  /** Any failure talking to notefeed. `status` and `code` are null when there was no API answer. */
@@ -14,6 +22,9 @@ export declare class NotefeedError extends Error {
14
22
  readonly status: number | null;
15
23
  readonly code: ErrorCode | null;
16
24
  constructor(message: string, status?: number | null, code?: ErrorCode | null);
25
+ /** Set when a post with `attachments` failed on one of them: its name, and the images already posted (the text note is not). */
26
+ attachment: string | null;
27
+ posted: Created[];
17
28
  }
18
29
  /** Raised before sending: no URL, no feed, an invalid feed name or password. */
19
30
  export declare class ConfigError extends NotefeedError {
@@ -29,10 +40,10 @@ export declare class RateLimitedError extends NotefeedError {
29
40
  /** No such note, or a malformed read id. */
30
41
  export declare class NotFoundError extends NotefeedError {
31
42
  }
32
- /** The instance's feed or note limit is reached. */
43
+ /** The instance's feed or note limit, or the feed's image limit, is reached. */
33
44
  export declare class LimitReachedError extends NotefeedError {
34
45
  }
35
- /** The note is over the server's size limit. */
46
+ /** The note or image is over the server's size limit. */
36
47
  export declare class NoteTooLargeError extends NotefeedError {
37
48
  }
38
49
  /** The server refused the request itself: invalid or reserved feed, empty note, bad body or parameter. */
@@ -43,24 +54,90 @@ export declare class Client {
43
54
  readonly feed: string | null;
44
55
  private readonly api;
45
56
  private readonly timeoutMs;
57
+ private readonly feedPassword;
46
58
  /** A notefeed server. The feed set here is the default for every call; each call can override it. */
47
59
  constructor(options: ClientOptions);
48
- /** NOTEFEED_URL, NOTEFEED_FEED and NOTEFEED_PASSWORD. */
60
+ /** NOTEFEED_URL, NOTEFEED_FEED, NOTEFEED_PASSWORD and NOTEFEED_FEED_PASSWORD. */
49
61
  static fromEnv(env?: Record<string, string | undefined>): Client;
50
- post(markdown: string, options?: {
62
+ /**
63
+ * Post a note: a string is markdown, bytes or a Blob are a file whose media type is `type` (a Blob's own `type` if left out): `text/markdown`,
64
+ * `image/png`, `image/jpeg`, `image/gif` or `image/webp`. The server accepts nothing else and checks that the body is what the type says.
65
+ * The feed is created by its first note. `Created.file` is the note's file name: write `![](file)` in a markdown note to show a picture, or
66
+ * pass it as `image` to updateFeed; `file_url` is for a link outside notefeed.
67
+ *
68
+ * `feedPassword` overrides the client's, for a feed that has its own password. `tags` label the note (at most 10, each 1 to 32 characters of
69
+ * letters, digits, `-`, `_`, `.`, `:`; not verified). `title` is its title (at most 100 characters, one line); left out, it is taken from the
70
+ * text. `alt` is a picture's alternative text, `name` the file's original name. `readId` is the read id the feed gets when this post creates it
71
+ * (3 to 64 characters of `a-z`, `0-9`, `-`, `_`; random when left out; ignored for a feed that exists; a `taken` error when another feed has it).
72
+ *
73
+ * `attachments` (only with a markdown string) are pictures posted first, one note each, in order: `![](name)` in the markdown shows one (an
74
+ * attachment the text never refers to is added at the end), and `Created.attachments` lists them. `tags` label the images too. The options
75
+ * are checked before anything is sent. If an upload fails, its error is thrown with `attachment` (the name) and `posted` (the images already
76
+ * posted, which stay) set; the text note is not posted.
77
+ */
78
+ post(content: string | Uint8Array | Blob, options?: {
79
+ feed?: string;
80
+ feedPassword?: string;
81
+ type?: string;
82
+ title?: string;
83
+ tags?: string[];
84
+ alt?: string;
85
+ name?: string;
86
+ readId?: string;
87
+ attachments?: Attachment[];
88
+ }): Promise<Created & {
89
+ attachments: Created[];
90
+ }>;
91
+ private postOne;
92
+ /** Replace a note's content (a string is markdown; bytes and Blobs need the note's own `type`, as for post()). Its id, URLs and metadata stay. Same options as post(). */
93
+ edit(id: string, content: string | Uint8Array | Blob, options?: {
94
+ feed?: string;
95
+ feedPassword?: string;
96
+ type?: string;
97
+ }): Promise<Note>;
98
+ /** Set a note's title and/or alt text (alt for pictures); `""` removes one, so a markdown note's title follows its text again. Same options as post(). */
99
+ update(id: string, meta: {
100
+ title?: string;
101
+ alt?: string;
102
+ }, options?: {
103
+ feed?: string;
104
+ feedPassword?: string;
105
+ }): Promise<Note>;
106
+ /** Remove a note for good. The feed stays, even with no notes left. Same options as post(). */
107
+ delete(id: string, options?: {
108
+ feed?: string;
109
+ feedPassword?: string;
110
+ }): Promise<void>;
111
+ /** The feed's name, title, description, whether it has a password, and its read link (null while it has no notes). Same options as post(). */
112
+ feedInfo(options?: {
113
+ feed?: string;
114
+ feedPassword?: string;
115
+ }): Promise<Feed>;
116
+ /** Replace the feed's title and description (both; an empty string clears one), and set (`image`: a file name from uploadImage) or clear (`""`) the title image; leave `image` out to keep it. `read_id` gives the feed another read id (3 to 64 characters of `a-z`, `0-9`, `-`, `_`; `""` for a random one; left out keeps it); the old one is freed, and relative image links in notes follow while full URLs do not. The feed must already exist. Same options as post(). */
117
+ updateFeed(settings: FeedSettings, options?: {
118
+ feed?: string;
119
+ feedPassword?: string;
120
+ }): Promise<Feed>;
121
+ /** Delete the feed with all its notes, settings and password, for good; its name is free again. Same options as post(). */
122
+ deleteFeed(options?: {
51
123
  feed?: string;
52
- }): Promise<Created>;
53
- /** Every note in the feed, newest first, fetched a page at a time; stop iterating whenever you like. */
124
+ feedPassword?: string;
125
+ }): Promise<void>;
126
+ /** Every note in the feed, newest first, fetched a page at a time; stop iterating whenever you like. `tag`: only notes carrying it. */
54
127
  notes(options?: {
55
128
  feed?: string;
129
+ feedPassword?: string;
56
130
  pageSize?: number;
131
+ tag?: string;
57
132
  }): AsyncGenerator<Note>;
58
133
  note(id: string, options?: {
59
134
  feed?: string;
135
+ feedPassword?: string;
60
136
  }): Promise<Note>;
61
137
  /** Like notes(), by the feed's read id: public, read-only, needs no password. */
62
138
  readNotes(readId: string, options?: {
63
139
  pageSize?: number;
140
+ tag?: string;
64
141
  }): AsyncGenerator<Note>;
65
142
  readNote(readId: string, id: string): Promise<Note>;
66
143
  private feedFor;
package/dist/client.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * OpenAPI description (./generated, `npm run generate` in the repo). No runtime dependencies.
4
4
  */
5
5
  import { createClient, createConfig } from "./generated/client/index.js";
6
- import { getNote, getReadNote, listNotes, listReadNotes, postNote } from "./generated/sdk.gen.js";
6
+ import { deleteFeed, deleteNote, editNote, getFeed, getNote, getReadNote, listNotes, listReadNotes, patchNote, postNote, updateFeed } from "./generated/sdk.gen.js";
7
7
  /** Any failure talking to notefeed. `status` and `code` are null when there was no API answer. */
8
8
  export class NotefeedError extends Error {
9
9
  status;
@@ -14,6 +14,9 @@ export class NotefeedError extends Error {
14
14
  this.code = code;
15
15
  this.name = new.target.name;
16
16
  }
17
+ /** Set when a post with `attachments` failed on one of them: its name, and the images already posted (the text note is not). */
18
+ attachment = null;
19
+ posted = [];
17
20
  }
18
21
  /** Raised before sending: no URL, no feed, an invalid feed name or password. */
19
22
  export class ConfigError extends NotefeedError {
@@ -32,10 +35,10 @@ export class RateLimitedError extends NotefeedError {
32
35
  /** No such note, or a malformed read id. */
33
36
  export class NotFoundError extends NotefeedError {
34
37
  }
35
- /** The instance's feed or note limit is reached. */
38
+ /** The instance's feed or note limit, or the feed's image limit, is reached. */
36
39
  export class LimitReachedError extends NotefeedError {
37
40
  }
38
- /** The note is over the server's size limit. */
41
+ /** The note or image is over the server's size limit. */
39
42
  export class NoteTooLargeError extends NotefeedError {
40
43
  }
41
44
  /** The server refused the request itself: invalid or reserved feed, empty note, bad body or parameter. */
@@ -46,23 +49,53 @@ const BY_CODE = {
46
49
  not_found: NotFoundError,
47
50
  feed_limit: LimitReachedError,
48
51
  note_limit: LimitReachedError,
52
+ image_limit: LimitReachedError,
49
53
  too_large: NoteTooLargeError,
50
54
  invalid_feed: InvalidRequestError,
51
55
  reserved_feed: InvalidRequestError,
52
56
  empty_note: InvalidRequestError,
53
57
  invalid_body: InvalidRequestError,
54
58
  invalid_request: InvalidRequestError,
59
+ feed_exists: InvalidRequestError,
60
+ taken: InvalidRequestError,
55
61
  unsupported_type: InvalidRequestError,
56
62
  };
63
+ const ATTACHMENT_NAME = /^(?!\.+$)[A-Za-z0-9._-]+$/; // same rule as the server's MCP tool: safe to write in ![](name)
64
+ const IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
57
65
  const FEED_RE = /^[a-z0-9_-]{1,64}$/; // same rule as the server; reserved names still come back as a 400
66
+ // A header holds bytes, and fetch refuses a character above U+00FF: text that may not be ASCII goes as its UTF-8 bytes written as
67
+ // latin1 characters, which is what the server reads back.
68
+ const headerValue = (text) => Array.from(new TextEncoder().encode(text), (b) => String.fromCharCode(b)).join("");
69
+ // What a post sends: a string is markdown; bytes and Blobs need a media type (a Blob's own, if it has one).
70
+ function asFile(content, type) {
71
+ if (typeof content === "string")
72
+ return { body: new Blob([content]), type: type ?? "text/markdown" };
73
+ const media = type ?? (content instanceof Blob ? content.type : "");
74
+ if (!media)
75
+ throw new ConfigError("give the file's media type as `type`, e.g. image/png");
76
+ return { body: content instanceof Blob ? content : new Blob([content]), type: media };
77
+ }
78
+ // The markdown with each `![](name)` of `sent` (name → file) pointing at its file; a name the text never refers to is appended as `![](file)`.
79
+ function placeImages(markdown, sent) {
80
+ const used = new Set();
81
+ const text = markdown.replace(/(!\[[^\]]*\]\()([^)\s]+)(\))/g, (m, head, dest, tail) => {
82
+ if (!sent.has(dest))
83
+ return m;
84
+ used.add(dest);
85
+ return head + sent.get(dest) + tail;
86
+ });
87
+ const rest = [...sent].filter(([name]) => !used.has(name)).map(([, file]) => `![](${file})`);
88
+ return rest.length === 0 ? text : [text.trimEnd(), ...rest].filter((p) => p !== "").join("\n\n");
89
+ }
58
90
  export class Client {
59
91
  url;
60
92
  feed;
61
93
  api;
62
94
  timeoutMs;
95
+ feedPassword;
63
96
  /** A notefeed server. The feed set here is the default for every call; each call can override it. */
64
97
  constructor(options) {
65
- const { url, feed, password, timeoutMs } = options ?? {};
98
+ const { url, feed, password, feedPassword, timeoutMs } = options ?? {};
66
99
  if (!url)
67
100
  throw new ConfigError("no url given");
68
101
  this.url = url.replace(/\/+$/, "");
@@ -71,29 +104,139 @@ export class Client {
71
104
  // Never echo the value: it would end up in terminals and CI logs.
72
105
  if (pw && /[\x00-\x1f\x7f]/.test(pw))
73
106
  throw new ConfigError("password contains invalid characters");
107
+ this.feedPassword = checkFeedPassword(feedPassword);
74
108
  this.timeoutMs = timeoutMs ?? 10_000;
75
109
  this.api = createClient(createConfig({ baseUrl: this.url, headers: pw ? { Authorization: `Bearer ${pw}` } : {} }));
76
110
  }
77
- /** NOTEFEED_URL, NOTEFEED_FEED and NOTEFEED_PASSWORD. */
111
+ /** NOTEFEED_URL, NOTEFEED_FEED, NOTEFEED_PASSWORD and NOTEFEED_FEED_PASSWORD. */
78
112
  static fromEnv(env = process.env) {
79
- return new Client({ url: env.NOTEFEED_URL ?? "", feed: env.NOTEFEED_FEED || undefined, password: env.NOTEFEED_PASSWORD });
113
+ return new Client({
114
+ url: env.NOTEFEED_URL ?? "",
115
+ feed: env.NOTEFEED_FEED || undefined,
116
+ password: env.NOTEFEED_PASSWORD,
117
+ feedPassword: env.NOTEFEED_FEED_PASSWORD,
118
+ });
119
+ }
120
+ /**
121
+ * Post a note: a string is markdown, bytes or a Blob are a file whose media type is `type` (a Blob's own `type` if left out): `text/markdown`,
122
+ * `image/png`, `image/jpeg`, `image/gif` or `image/webp`. The server accepts nothing else and checks that the body is what the type says.
123
+ * The feed is created by its first note. `Created.file` is the note's file name: write `![](file)` in a markdown note to show a picture, or
124
+ * pass it as `image` to updateFeed; `file_url` is for a link outside notefeed.
125
+ *
126
+ * `feedPassword` overrides the client's, for a feed that has its own password. `tags` label the note (at most 10, each 1 to 32 characters of
127
+ * letters, digits, `-`, `_`, `.`, `:`; not verified). `title` is its title (at most 100 characters, one line); left out, it is taken from the
128
+ * text. `alt` is a picture's alternative text, `name` the file's original name. `readId` is the read id the feed gets when this post creates it
129
+ * (3 to 64 characters of `a-z`, `0-9`, `-`, `_`; random when left out; ignored for a feed that exists; a `taken` error when another feed has it).
130
+ *
131
+ * `attachments` (only with a markdown string) are pictures posted first, one note each, in order: `![](name)` in the markdown shows one (an
132
+ * attachment the text never refers to is added at the end), and `Created.attachments` lists them. `tags` label the images too. The options
133
+ * are checked before anything is sent. If an upload fails, its error is thrown with `attachment` (the name) and `posted` (the images already
134
+ * posted, which stay) set; the text note is not posted.
135
+ */
136
+ async post(content, options = {}) {
137
+ const { attachments = [], ...own } = options;
138
+ if (attachments.length === 0)
139
+ return { ...(await this.postOne(content, own)), attachments: [] };
140
+ if (typeof content !== "string")
141
+ throw new ConfigError("attachments go with a markdown string");
142
+ if ((own.type ?? "text/markdown") !== "text/markdown")
143
+ throw new ConfigError("attachments go with a markdown string");
144
+ const types = attachments.map((a) => {
145
+ if (!ATTACHMENT_NAME.test(a.name))
146
+ throw new ConfigError(`attachment name "${a.name}": use letters, digits, ., _ and - only`);
147
+ const type = asFile(a.content, a.type).type;
148
+ if (!IMAGE_TYPES.includes(type))
149
+ throw new ConfigError(`attachment "${a.name}" must be a picture (${IMAGE_TYPES.join(", ")})`);
150
+ return type;
151
+ });
152
+ const dup = attachments.find((a, i) => attachments.findIndex((b) => b.name === a.name) !== i);
153
+ if (dup)
154
+ throw new ConfigError(`attachment "${dup.name}" is given twice`);
155
+ const posted = [];
156
+ const sent = new Map();
157
+ for (const [i, a] of attachments.entries()) {
158
+ try {
159
+ const done = await this.postOne(a.content, { feed: own.feed, feedPassword: own.feedPassword, type: types[i], alt: a.alt, name: a.name, tags: own.tags, readId: own.readId });
160
+ posted.push(done);
161
+ sent.set(a.name, done.file);
162
+ }
163
+ catch (e) {
164
+ if (e instanceof NotefeedError) {
165
+ e.message = `attachment "${a.name}": ${e.message}`;
166
+ e.attachment = a.name;
167
+ e.posted = posted;
168
+ }
169
+ throw e;
170
+ }
171
+ }
172
+ try {
173
+ return { ...(await this.postOne(placeImages(content, sent), own)), attachments: posted };
174
+ }
175
+ catch (e) {
176
+ if (e instanceof NotefeedError)
177
+ e.posted = posted; // the text failed: the images stay, `attachment` stays null
178
+ throw e;
179
+ }
180
+ }
181
+ async postOne(content, options) {
182
+ const feed = this.feedFor(options.feed);
183
+ const { body, type } = asFile(content, options.type);
184
+ const headers = {
185
+ ...this.opts(options.feedPassword).headers,
186
+ "Content-Type": type,
187
+ ...(options.title && { "X-Note-Title": headerValue(options.title) }),
188
+ ...(options.tags?.length && { "X-Note-Tags": options.tags.join(",") }),
189
+ ...(options.alt && { "X-Note-Alt": headerValue(options.alt) }),
190
+ ...(options.name && { "X-Note-Name": headerValue(options.name) }),
191
+ ...(options.readId && { "X-Read-Id": options.readId }),
192
+ };
193
+ return this.call(postNote({ client: this.api, path: { feed }, body, headers, signal: AbortSignal.timeout(this.timeoutMs) }));
194
+ }
195
+ /** Replace a note's content (a string is markdown; bytes and Blobs need the note's own `type`, as for post()). Its id, URLs and metadata stay. Same options as post(). */
196
+ async edit(id, content, options = {}) {
197
+ const feed = this.feedFor(options.feed);
198
+ const { body, type } = asFile(content, options.type);
199
+ const headers = { ...this.opts(options.feedPassword).headers, "Content-Type": type };
200
+ return this.call(editNote({ client: this.api, path: { feed, id }, body, headers, signal: AbortSignal.timeout(this.timeoutMs) }));
80
201
  }
81
- async post(markdown, options = {}) {
202
+ /** Set a note's title and/or alt text (alt for pictures); `""` removes one, so a markdown note's title follows its text again. Same options as post(). */
203
+ async update(id, meta, options = {}) {
82
204
  const feed = this.feedFor(options.feed);
83
- return this.call(postNote({ client: this.api, path: { feed }, body: { markdown }, ...this.opts() }));
205
+ return this.call(patchNote({ client: this.api, path: { feed, id }, body: meta, ...this.opts(options.feedPassword) }));
84
206
  }
85
- /** Every note in the feed, newest first, fetched a page at a time; stop iterating whenever you like. */
207
+ /** Remove a note for good. The feed stays, even with no notes left. Same options as post(). */
208
+ async delete(id, options = {}) {
209
+ const feed = this.feedFor(options.feed);
210
+ await this.call(deleteNote({ client: this.api, path: { feed, id }, ...this.opts(options.feedPassword) }));
211
+ }
212
+ /** The feed's name, title, description, whether it has a password, and its read link (null while it has no notes). Same options as post(). */
213
+ async feedInfo(options = {}) {
214
+ const feed = this.feedFor(options.feed);
215
+ return this.call(getFeed({ client: this.api, path: { feed }, ...this.opts(options.feedPassword) }));
216
+ }
217
+ /** Replace the feed's title and description (both; an empty string clears one), and set (`image`: a file name from uploadImage) or clear (`""`) the title image; leave `image` out to keep it. `read_id` gives the feed another read id (3 to 64 characters of `a-z`, `0-9`, `-`, `_`; `""` for a random one; left out keeps it); the old one is freed, and relative image links in notes follow while full URLs do not. The feed must already exist. Same options as post(). */
218
+ async updateFeed(settings, options = {}) {
219
+ const feed = this.feedFor(options.feed);
220
+ const body = { title: settings.title, description: settings.description, ...(settings.image === undefined ? {} : { image: settings.image }), ...(settings.read_id === undefined ? {} : { read_id: settings.read_id }) };
221
+ return this.call(updateFeed({ client: this.api, path: { feed }, body, ...this.opts(options.feedPassword) }));
222
+ }
223
+ /** Delete the feed with all its notes, settings and password, for good; its name is free again. Same options as post(). */
224
+ async deleteFeed(options = {}) {
225
+ const feed = this.feedFor(options.feed);
226
+ await this.call(deleteFeed({ client: this.api, path: { feed }, ...this.opts(options.feedPassword) }));
227
+ }
228
+ /** Every note in the feed, newest first, fetched a page at a time; stop iterating whenever you like. `tag`: only notes carrying it. */
86
229
  notes(options = {}) {
87
230
  const feed = this.feedFor(options.feed);
88
- return this.pages((before) => listNotes({ client: this.api, path: { feed }, query: { limit: options.pageSize, before }, ...this.opts() }));
231
+ return this.pages((before) => listNotes({ client: this.api, path: { feed }, query: { limit: options.pageSize, before, tag: options.tag }, ...this.opts(options.feedPassword) }));
89
232
  }
90
233
  async note(id, options = {}) {
91
234
  const feed = this.feedFor(options.feed);
92
- return this.call(getNote({ client: this.api, path: { feed, id }, ...this.opts() }));
235
+ return this.call(getNote({ client: this.api, path: { feed, id }, ...this.opts(options.feedPassword) }));
93
236
  }
94
237
  /** Like notes(), by the feed's read id: public, read-only, needs no password. */
95
238
  readNotes(readId, options = {}) {
96
- return this.pages((before) => listReadNotes({ client: this.api, path: { readId }, query: { limit: options.pageSize, before }, ...this.opts() }));
239
+ return this.pages((before) => listReadNotes({ client: this.api, path: { readId }, query: { limit: options.pageSize, before, tag: options.tag }, ...this.opts() }));
97
240
  }
98
241
  async readNote(readId, id) {
99
242
  return this.call(getReadNote({ client: this.api, path: { readId, id }, ...this.opts() }));
@@ -104,8 +247,9 @@ export class Client {
104
247
  throw new ConfigError("no feed given");
105
248
  return checkFeed(f);
106
249
  }
107
- opts() {
108
- return { signal: AbortSignal.timeout(this.timeoutMs) };
250
+ opts(feedPassword) {
251
+ const fp = checkFeedPassword(feedPassword) ?? this.feedPassword;
252
+ return { signal: AbortSignal.timeout(this.timeoutMs), headers: fp ? { "X-Feed-Password": fp } : {} };
109
253
  }
110
254
  // `before` is the previous page's `next`: older notes only, so notes posted meanwhile never repeat.
111
255
  async *pages(fetchPage) {
@@ -128,6 +272,8 @@ export class Client {
128
272
  if (response.redirected) {
129
273
  throw new NotefeedError(`${this.url} redirected to ${new URL(response.url).origin}: use that address as the URL`, response.status);
130
274
  }
275
+ if (response.status === 204 && error === undefined)
276
+ return undefined; // delete: no body
131
277
  if (response.ok) {
132
278
  if (error === undefined && isObject(data))
133
279
  return data;
@@ -155,3 +301,10 @@ function checkFeed(feed) {
155
301
  throw new ConfigError("invalid feed name: use 1-64 of a-z, 0-9, _ and -");
156
302
  return feed;
157
303
  }
304
+ function checkFeedPassword(value) {
305
+ const v = value?.trim() || null;
306
+ // Never echo the value: it would end up in terminals and CI logs.
307
+ if (v && /[\x00-\x1f\x7f]/.test(v))
308
+ throw new ConfigError("feed password contains invalid characters");
309
+ return v;
310
+ }
@@ -1,2 +1,2 @@
1
- export { getNote, getOpenApi, getReadNote, listNotes, listReadNotes, type Options, postNote } from './sdk.gen.js';
2
- export type { ClientOptions, Created, Error, GetNoteData, GetNoteError, GetNoteErrors, GetNoteResponse, GetNoteResponses, GetOpenApiData, GetOpenApiResponse, GetOpenApiResponses, GetReadNoteData, GetReadNoteError, GetReadNoteErrors, GetReadNoteResponse, GetReadNoteResponses, ListNotesData, ListNotesError, ListNotesErrors, ListNotesResponse, ListNotesResponses, ListReadNotesData, ListReadNotesError, ListReadNotesErrors, ListReadNotesResponse, ListReadNotesResponses, Note, NoteList, PostForm, PostJson, PostNoteData, PostNoteError, PostNoteErrors, PostNoteResponse, PostNoteResponses } from './types.gen.js';
1
+ export { changeFeedPassword, deleteFeed, deleteNote, editNote, getFeed, getNote, getOpenApi, getReadFeed, getReadNote, listNotes, listReadNotes, type Options, patchNote, postNote, removeFeedPassword, updateFeed } from './sdk.gen.js';
2
+ export type { ChangeFeedPasswordData, ChangeFeedPasswordError, ChangeFeedPasswordErrors, ChangeFeedPasswordResponse, ChangeFeedPasswordResponses, ClientOptions, Created, DeleteFeedData, DeleteFeedError, DeleteFeedErrors, DeleteFeedResponse, DeleteFeedResponses, DeleteNoteData, DeleteNoteError, DeleteNoteErrors, DeleteNoteResponse, DeleteNoteResponses, EditNoteData, EditNoteError, EditNoteErrors, EditNoteResponse, EditNoteResponses, Error, Feed, FeedSettings, GetFeedData, GetFeedError, GetFeedErrors, GetFeedResponse, GetFeedResponses, GetNoteData, GetNoteError, GetNoteErrors, GetNoteResponse, GetNoteResponses, GetOpenApiData, GetOpenApiResponse, GetOpenApiResponses, GetReadFeedData, GetReadFeedError, GetReadFeedErrors, GetReadFeedResponse, GetReadFeedResponses, GetReadNoteData, GetReadNoteError, GetReadNoteErrors, GetReadNoteResponse, GetReadNoteResponses, ListNotesData, ListNotesError, ListNotesErrors, ListNotesResponse, ListNotesResponses, ListReadNotesData, ListReadNotesError, ListReadNotesErrors, ListReadNotesResponse, ListReadNotesResponses, Note, NoteList, NoteMeta, PasswordJson, PatchNoteData, PatchNoteError, PatchNoteErrors, PatchNoteResponse, PatchNoteResponses, PostNoteData, PostNoteError, PostNoteErrors, PostNoteResponse, PostNoteResponses, ReadFeed, RemoveFeedPasswordData, RemoveFeedPasswordError, RemoveFeedPasswordErrors, RemoveFeedPasswordResponse, RemoveFeedPasswordResponses, UpdateFeedData, UpdateFeedError, UpdateFeedErrors, UpdateFeedResponse, UpdateFeedResponses } from './types.gen.js';
@@ -1,2 +1,2 @@
1
1
  // This file is auto-generated by @hey-api/openapi-ts
2
- export { getNote, getOpenApi, getReadNote, listNotes, listReadNotes, postNote } from './sdk.gen.js';
2
+ export { changeFeedPassword, deleteFeed, deleteNote, editNote, getFeed, getNote, getOpenApi, getReadFeed, getReadNote, listNotes, listReadNotes, patchNote, postNote, removeFeedPassword, updateFeed } from './sdk.gen.js';