notefeed 0.7.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
@@ -27,10 +27,10 @@ for await (const note of client.notes()) console.log(note.created_at, note.title
27
27
 
28
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
29
 
30
- A posted note can be changed or removed: `await client.edit(id, markdown)` replaces its text 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.
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
31
 
32
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
33
 
34
- An image can be uploaded to an existing feed and then used in a note: `const { file, url, markdown } = await client.uploadImage(bytes)` takes a `Uint8Array` or a `Blob` (PNG, JPEG, GIF or WebP; the server recognizes the format by the bytes), takes the same `{ feed, feedPassword }` options as `post`, and returns the image's `url` and `markdown` (`![](url)`), which you put in a note. The feed must already have a note, and an image that is too large is a `NoteTooLargeError`. `await client.updateFeed({ title, description, image: file })` makes an uploaded image the feed's title image (`image: ""` removes it; leaving `image` out keeps it). `notefeed image <PATH>` uploads a file and prints the markdown. Images are public to anyone with the feed's read link, and metadata such as EXIF is not removed.
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
35
 
36
36
  Full documentation: https://docs.notefeed.me/clients/
package/dist/cli.js CHANGED
@@ -1,17 +1,20 @@
1
- /** `notefeed post`, `edit`, `delete`, `notes` and `image`: post, change, remove and read notes, and upload images, 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> [--tag TAG]... [--url URL] [--feed FEED] [--password PASSWORD]",
10
- " notefeed edit <id> <text | - | --file PATH> [--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]",
11
13
  " notefeed delete <id> [--url URL] [--feed FEED] [--password PASSWORD]",
12
- " notefeed image <PATH> [--url URL] [--feed FEED] [--password PASSWORD]",
13
14
  " notefeed notes [--limit N] [--tag TAG] [--json] [--url URL] [--feed FEED] [--password PASSWORD]",
14
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" };
15
18
  /** Returns the exit code: 0 ok, 1 server/network error, 2 usage/config error. */
16
19
  export async function main(argv, io = process) {
17
20
  try {
@@ -23,7 +26,11 @@ export async function main(argv, io = process) {
23
26
  feed: { type: "string" },
24
27
  password: { type: "string" },
25
28
  file: { type: "string" },
29
+ type: { type: "string" },
30
+ title: { type: "string" },
31
+ alt: { type: "string" },
26
32
  tag: { type: "string", multiple: true },
33
+ attach: { type: "string", multiple: true },
27
34
  limit: { type: "string" },
28
35
  json: { type: "boolean" },
29
36
  version: { type: "boolean" },
@@ -41,29 +48,27 @@ export async function main(argv, io = process) {
41
48
  }
42
49
  const [command, text, ...rest] = positionals;
43
50
  if (command === "post" && !rest.length) {
44
- const markdown = await read(text, values.file, io);
45
- const note = await client(values).post(markdown, { tags: values.tag });
46
- io.stdout.write(`${note.url}\n`);
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`);
47
55
  return 0;
48
56
  }
49
57
  if (command === "edit" && text !== undefined && rest.length <= 1) {
50
- const note = await client(values).edit(text, await read(rest[0], values.file, io));
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 });
51
60
  io.stdout.write(`${note.url}\n`);
52
61
  return 0;
53
62
  }
54
- if (command === "delete" && text !== undefined && !rest.length) {
55
- await client(values).delete(text);
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`);
56
68
  return 0;
57
69
  }
58
- if (command === "image" && text !== undefined && !rest.length) {
59
- let bytes;
60
- try {
61
- bytes = await readFile(text);
62
- }
63
- catch (e) {
64
- throw new UsageError(`cannot read ${text}: ${e.code ?? e.message}`);
65
- }
66
- io.stdout.write(`${(await client(values).uploadImage(bytes)).markdown}\n`);
70
+ if (command === "delete" && text !== undefined && !rest.length) {
71
+ await client(values).delete(text);
67
72
  return 0;
68
73
  }
69
74
  if (command === "notes" && text === undefined) {
@@ -75,7 +80,7 @@ export async function main(argv, io = process) {
75
80
  // To the second, UTC: the same form as the Python CLI, so scripts read either the same way.
76
81
  const when = new Date(n.created_at).toISOString().replace(/\.\d+Z$/, "Z");
77
82
  // --json: exactly the documented fields, like the Python CLI, even if the server adds more.
78
- 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 };
79
84
  io.stdout.write(values.json ? `${JSON.stringify(fields)}\n` : `${when} ${n.title || n.id} ${n.url}\n`);
80
85
  if (--left === 0)
81
86
  break;
@@ -86,6 +91,9 @@ export async function main(argv, io = process) {
86
91
  }
87
92
  catch (e) {
88
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`);
89
97
  if (e instanceof ConfigError || e instanceof UsageError)
90
98
  return 2;
91
99
  if (e instanceof NotefeedError)
@@ -108,26 +116,45 @@ function client(values) {
108
116
  feedPassword: process.env.NOTEFEED_FEED_PASSWORD,
109
117
  });
110
118
  }
111
- 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) {
112
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;
113
126
  try {
114
- return utf8(await readFile(file), file);
127
+ bytes = await readFile(file);
115
128
  }
116
129
  catch (e) {
117
- if (e instanceof UsageError)
118
- throw e;
119
130
  throw new UsageError(`cannot read ${file}: ${e.code ?? e.message}`);
120
131
  }
132
+ return { content: media === "text/markdown" ? utf8(bytes, file) : bytes, type: media, name: basename(file) };
121
133
  }
122
134
  if (text === "-") {
123
135
  const chunks = [];
124
136
  for await (const c of io.stdin)
125
137
  chunks.push(typeof c === "string" ? Buffer.from(c) : c);
126
- return utf8(Buffer.concat(chunks), "stdin");
138
+ return { content: utf8(Buffer.concat(chunks), "stdin"), type: type ?? "text/markdown" };
127
139
  }
128
140
  if (text === undefined)
129
141
  throw new UsageError('give the note text, "-" for stdin, or --file PATH');
130
- 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
+ }
131
158
  }
132
159
  function utf8(bytes, source) {
133
160
  try {
package/dist/client.d.ts CHANGED
@@ -1,7 +1,14 @@
1
- import type { Created, Error as ApiError, Feed, FeedSettings, ImageUploaded, 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, Feed, FeedSettings, ImageUploaded, 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;
@@ -15,6 +22,9 @@ export declare class NotefeedError extends Error {
15
22
  readonly status: number | null;
16
23
  readonly code: ErrorCode | null;
17
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[];
18
28
  }
19
29
  /** Raised before sending: no URL, no feed, an invalid feed name or password. */
20
30
  export declare class ConfigError extends NotefeedError {
@@ -49,19 +59,47 @@ export declare class Client {
49
59
  constructor(options: ClientOptions);
50
60
  /** NOTEFEED_URL, NOTEFEED_FEED, NOTEFEED_PASSWORD and NOTEFEED_FEED_PASSWORD. */
51
61
  static fromEnv(env?: Record<string, string | undefined>): Client;
52
- /** `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 letters, digits, `-`, `_`, `.`, `:`; not verified). */
53
- 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?: {
54
79
  feed?: string;
55
80
  feedPassword?: string;
81
+ type?: string;
82
+ title?: string;
56
83
  tags?: string[];
57
- }): Promise<Created>;
58
- /** Upload a PNG, JPEG, GIF or WebP image to an existing feed. The server decides the format by the bytes, so no content type is needed. `markdown` in the answer is `![](url)`, to put in a note. Same options as post(). */
59
- uploadImage(data: Uint8Array | Blob, options?: {
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?: {
60
94
  feed?: string;
61
95
  feedPassword?: string;
62
- }): Promise<ImageUploaded>;
63
- /** Replace a note's markdown; its id and URLs stay. Same options as post(). */
64
- edit(id: string, markdown: string, options?: {
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?: {
65
103
  feed?: string;
66
104
  feedPassword?: string;
67
105
  }): Promise<Note>;
@@ -75,7 +113,7 @@ export declare class Client {
75
113
  feed?: string;
76
114
  feedPassword?: string;
77
115
  }): Promise<Feed>;
78
- /** 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. The feed must already exist. Same options as post(). */
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(). */
79
117
  updateFeed(settings: FeedSettings, options?: {
80
118
  feed?: string;
81
119
  feedPassword?: string;
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 { deleteFeed, deleteNote, editNote, getFeed, getNote, getReadNote, listNotes, listReadNotes, postNote, updateFeed, uploadImage } 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 {
@@ -54,9 +57,36 @@ const BY_CODE = {
54
57
  invalid_body: InvalidRequestError,
55
58
  invalid_request: InvalidRequestError,
56
59
  feed_exists: InvalidRequestError,
60
+ taken: InvalidRequestError,
57
61
  unsupported_type: InvalidRequestError,
58
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"];
59
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
+ }
60
90
  export class Client {
61
91
  url;
62
92
  feed;
@@ -87,23 +117,92 @@ export class Client {
87
117
  feedPassword: env.NOTEFEED_FEED_PASSWORD,
88
118
  });
89
119
  }
90
- /** `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 letters, digits, `-`, `_`, `.`, `:`; not verified). */
91
- async post(markdown, options = {}) {
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) {
92
182
  const feed = this.feedFor(options.feed);
93
- const body = { markdown, ...(options.tags?.length && { tags: options.tags }) };
94
- return this.call(postNote({ client: this.api, path: { feed }, body, ...this.opts(options.feedPassword) }));
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) }));
95
194
  }
96
- /** Upload a PNG, JPEG, GIF or WebP image to an existing feed. The server decides the format by the bytes, so no content type is needed. `markdown` in the answer is `![](url)`, to put in a note. Same options as post(). */
97
- async uploadImage(data, options = {}) {
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 = {}) {
98
197
  const feed = this.feedFor(options.feed);
99
- const body = data instanceof Blob ? data : new Blob([data]);
100
- const headers = { ...this.opts(options.feedPassword).headers, "Content-Type": "application/octet-stream" };
101
- return this.call(uploadImage({ client: this.api, path: { feed }, body, signal: AbortSignal.timeout(this.timeoutMs), headers }));
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) }));
102
201
  }
103
- /** Replace a note's markdown; its id and URLs stay. Same options as post(). */
104
- async edit(id, 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 = {}) {
105
204
  const feed = this.feedFor(options.feed);
106
- return this.call(editNote({ client: this.api, path: { feed, id }, body: { markdown }, ...this.opts(options.feedPassword) }));
205
+ return this.call(patchNote({ client: this.api, path: { feed, id }, body: meta, ...this.opts(options.feedPassword) }));
107
206
  }
108
207
  /** Remove a note for good. The feed stays, even with no notes left. Same options as post(). */
109
208
  async delete(id, options = {}) {
@@ -115,10 +214,10 @@ export class Client {
115
214
  const feed = this.feedFor(options.feed);
116
215
  return this.call(getFeed({ client: this.api, path: { feed }, ...this.opts(options.feedPassword) }));
117
216
  }
118
- /** 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. The feed must already exist. Same options as post(). */
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(). */
119
218
  async updateFeed(settings, options = {}) {
120
219
  const feed = this.feedFor(options.feed);
121
- const body = { title: settings.title, description: settings.description, ...(settings.image === undefined ? {} : { image: settings.image }) };
220
+ const body = { title: settings.title, description: settings.description, ...(settings.image === undefined ? {} : { image: settings.image }), ...(settings.read_id === undefined ? {} : { read_id: settings.read_id }) };
122
221
  return this.call(updateFeed({ client: this.api, path: { feed }, body, ...this.opts(options.feedPassword) }));
123
222
  }
124
223
  /** Delete the feed with all its notes, settings and password, for good; its name is free again. Same options as post(). */
@@ -1,2 +1,2 @@
1
- export { changeFeedPassword, deleteFeed, deleteNote, editNote, getFeed, getNote, getOpenApi, getReadFeed, getReadNote, listNotes, listReadNotes, type Options, postNote, removeFeedPassword, updateFeed, uploadImage } 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, ImageUploaded, ListNotesData, ListNotesError, ListNotesErrors, ListNotesResponse, ListNotesResponses, ListReadNotesData, ListReadNotesError, ListReadNotesErrors, ListReadNotesResponse, ListReadNotesResponses, Note, NoteList, PasswordJson, PostForm, PostJson, PostNoteData, PostNoteError, PostNoteErrors, PostNoteResponse, PostNoteResponses, ReadFeed, RemoveFeedPasswordData, RemoveFeedPasswordError, RemoveFeedPasswordErrors, RemoveFeedPasswordResponse, RemoveFeedPasswordResponses, UpdateFeedData, UpdateFeedError, UpdateFeedErrors, UpdateFeedResponse, UpdateFeedResponses, UploadImageData, UploadImageError, UploadImageErrors, UploadImageResponse, UploadImageResponses } 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 { changeFeedPassword, deleteFeed, deleteNote, editNote, getFeed, getNote, getOpenApi, getReadFeed, getReadNote, listNotes, listReadNotes, postNote, removeFeedPassword, updateFeed, uploadImage } 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';
@@ -1,5 +1,5 @@
1
1
  import type { Client, ClientMeta, Options as Options2, RequestResult, TDataShape } from './client/index.js';
2
- import type { ChangeFeedPasswordData, ChangeFeedPasswordErrors, ChangeFeedPasswordResponses, DeleteFeedData, DeleteFeedErrors, DeleteFeedResponses, DeleteNoteData, DeleteNoteErrors, DeleteNoteResponses, EditNoteData, EditNoteErrors, EditNoteResponses, GetFeedData, GetFeedErrors, GetFeedResponses, GetNoteData, GetNoteErrors, GetNoteResponses, GetOpenApiData, GetOpenApiResponses, GetReadFeedData, GetReadFeedErrors, GetReadFeedResponses, GetReadNoteData, GetReadNoteErrors, GetReadNoteResponses, ListNotesData, ListNotesErrors, ListNotesResponses, ListReadNotesData, ListReadNotesErrors, ListReadNotesResponses, PostNoteData, PostNoteErrors, PostNoteResponses, RemoveFeedPasswordData, RemoveFeedPasswordErrors, RemoveFeedPasswordResponses, UpdateFeedData, UpdateFeedErrors, UpdateFeedResponses, UploadImageData, UploadImageErrors, UploadImageResponses } from './types.gen.js';
2
+ import type { ChangeFeedPasswordData, ChangeFeedPasswordErrors, ChangeFeedPasswordResponses, DeleteFeedData, DeleteFeedErrors, DeleteFeedResponses, DeleteNoteData, DeleteNoteErrors, DeleteNoteResponses, EditNoteData, EditNoteErrors, EditNoteResponses, GetFeedData, GetFeedErrors, GetFeedResponses, GetNoteData, GetNoteErrors, GetNoteResponses, GetOpenApiData, GetOpenApiResponses, GetReadFeedData, GetReadFeedErrors, GetReadFeedResponses, GetReadNoteData, GetReadNoteErrors, GetReadNoteResponses, ListNotesData, ListNotesErrors, ListNotesResponses, ListReadNotesData, ListReadNotesErrors, ListReadNotesResponses, PatchNoteData, PatchNoteErrors, PatchNoteResponses, PostNoteData, PostNoteErrors, PostNoteResponses, RemoveFeedPasswordData, RemoveFeedPasswordErrors, RemoveFeedPasswordResponses, UpdateFeedData, UpdateFeedErrors, UpdateFeedResponses } from './types.gen.js';
3
3
  export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean, TResponse = unknown> = Options2<TData, ThrowOnError, TResponse> & {
4
4
  /**
5
5
  * You can provide a client instance returned by `createClient()` instead of
@@ -22,7 +22,7 @@ export declare const listNotes: <ThrowOnError extends boolean = false>(options:
22
22
  /**
23
23
  * Post a note
24
24
  *
25
- * Creates the feed with its first note, optionally protected by its own password (`X-Feed-Password` header or a `password` field in the JSON or form body; 1 to 256 printable ASCII characters, with no space at the start or end). Posting to a protected feed needs that password. Also served at `POST /{feed}`, the short form the client packages and curl one-liners use. The body is at most 102400 bytes and must be UTF-8. `application/x-www-form-urlencoded` (what `curl -d` sends) is read as raw markdown, not as form fields. Tags (at most 10 tags, each 1 to 32 characters of letters, digits, `-`, `_`, `.` and `:`; case is folded to lowercase, duplicates are removed) go in the JSON `tags` array, a repeated `tags` form field, or, for a raw body, the `X-Note-Tags` header.
25
+ * The body is the note, a file, and `Content-Type` says which kind: `text/markdown` (UTF-8, at most 102400 bytes), or an image, `image/png`, `image/jpeg`, `image/gif` or `image/webp` (at most NOTEFEED_MAX_IMAGE_BYTES, default 5 MiB). Nothing is guessed: any other type, or none, is `415`, and so is a body that is not what the type says (an image is recognized by its first bytes; SVG is refused). It is stored byte for byte, with no resizing and no metadata stripped (EXIF such as GPS position stays in an image). Creates the feed with its first note, optionally protected by its own password (`X-Feed-Password`; 1 to 256 printable ASCII characters, with no space at the start or end) and with the read id in `X-Read-Id`; both are only used by the post that creates the feed. Posting to a protected feed needs its password. Also served at `POST /{feed}`, the short form curl one-liners use. The response names the note's `file` and where it is served, `file_url`, under the feed's read id (public like the read link). Metadata goes in headers: `X-Note-Title`, `X-Note-Tags` (at most 10 tags, each 1 to 32 characters of letters, digits, `-`, `_`, `.` and `:`; case is folded to lowercase, duplicates are removed), `X-Note-Alt` (images), `X-Note-Name` (the original file name).
26
26
  */
27
27
  export declare const postNote: <ThrowOnError extends boolean = false>(options: Options<PostNoteData, ThrowOnError>) => RequestResult<PostNoteResponses, PostNoteErrors, ThrowOnError>;
28
28
  /**
@@ -36,17 +36,17 @@ export declare const deleteNote: <ThrowOnError extends boolean = false>(options:
36
36
  */
37
37
  export declare const getNote: <ThrowOnError extends boolean = false>(options: Options<GetNoteData, ThrowOnError>) => RequestResult<GetNoteResponses, GetNoteErrors, ThrowOnError>;
38
38
  /**
39
- * Edit a note
39
+ * Change a note's title or alt text
40
40
  *
41
- * Replaces the note's markdown; its id and creation time stay, the title follows the new text. Needs the feed's password if it has one, and counts against the post rate limit. Read links can't edit. The body is as for posting: at most 102400 bytes, UTF-8, a `password` field is ignored.
41
+ * Sets the note's title and/or alt text (alt only for images). An empty string removes one: a markdown note's title follows its text again. At least one is needed. Needs the feed's password if it has one, and counts against the post rate limit. Read links can't change notes.
42
42
  */
43
- export declare const editNote: <ThrowOnError extends boolean = false>(options: Options<EditNoteData, ThrowOnError>) => RequestResult<EditNoteResponses, EditNoteErrors, ThrowOnError>;
43
+ export declare const patchNote: <ThrowOnError extends boolean = false>(options: Options<PatchNoteData, ThrowOnError>) => RequestResult<PatchNoteResponses, PatchNoteErrors, ThrowOnError>;
44
44
  /**
45
- * Upload an image
45
+ * Replace a note's content
46
46
  *
47
- * The body is the image itself: PNG, JPEG, GIF or WebP, recognized by its first bytes, whatever `Content-Type` is sent (SVG is refused). Stored byte-for-byte, with no resizing and no metadata stripped (EXIF such as GPS position stays in the file), as the first 32 hex characters of its SHA-256 plus an extension: the same bytes always give the same URL. The URL is under the feed's read id, so it works in the feed page, the read-only view and RSS readers without any password. Needs the same credentials as posting and counts against the post rate limit. The feed must exist: it is created by its first note. The size limit is NOTEFEED_MAX_IMAGE_BYTES (default 5 MiB); images deleted only with the feed.
47
+ * The body is the new file, with the same rules as posting: a `Content-Type` that is one of the accepted types, a body that is what it declares. A note keeps its type, so the type must be the note's own (`415` otherwise). Id, creation time and metadata stay: the title of a markdown note without one set follows the new text. Change the title or alt text with `PATCH`. Needs the feed's password if it has one, and counts against the post rate limit. Read links can't edit.
48
48
  */
49
- export declare const uploadImage: <ThrowOnError extends boolean = false>(options: Options<UploadImageData, ThrowOnError>) => RequestResult<UploadImageResponses, UploadImageErrors, ThrowOnError>;
49
+ export declare const editNote: <ThrowOnError extends boolean = false>(options: Options<EditNoteData, ThrowOnError>) => RequestResult<EditNoteResponses, EditNoteErrors, ThrowOnError>;
50
50
  /**
51
51
  * Delete a feed
52
52
  *
@@ -62,7 +62,7 @@ export declare const getFeed: <ThrowOnError extends boolean = false>(options: Op
62
62
  /**
63
63
  * Change a feed's settings
64
64
  *
65
- * Replaces both the title (at most 100 characters) and the description (at most 500); surrounding whitespace is trimmed and control characters are refused. `show_sender` (default true) shows who posted each note to readers; omitted leaves it as it is. `image` is the file name `uploadImage` returned for this feed (title image), empty to remove it, or omitted to leave it as it is; any other value is a 400. Needs the feed's password if it has one, and counts against the post rate limit. Only on a feed that exists: it is created by its first note. Read links can't change settings.
65
+ * Replaces both the title (at most 100 characters) and the description (at most 500); surrounding whitespace is trimmed and control characters are refused. `show_sender` (default true) shows who posted each note to readers; omitted leaves it as it is. `read_id` gives the feed another read link (3 to 64 characters: a-z, 0-9, - and _; empty for a random one): the old id is freed and answers like an unknown read id until another feed takes it, and the notes are not edited (a relative image link follows the new id, a full URL does not). A short readable read id is guessable, so protect the feed with a password if that matters. An instance can turn chosen read ids off (NOTEFEED_ALLOW_CUSTOM_IDS=0): then only an empty `read_id` is accepted. `image` is the file name `uploadImage` returned for this feed (title image), empty to remove it, or omitted to leave it as it is; any other value is a 400. Needs the feed's password if it has one, and counts against the post rate limit. Only on a feed that exists: it is created by its first note. Read links can't change settings.
66
66
  */
67
67
  export declare const updateFeed: <ThrowOnError extends boolean = false>(options: Options<UpdateFeedData, ThrowOnError>) => RequestResult<UpdateFeedResponses, UpdateFeedErrors, ThrowOnError>;
68
68
  /**
@@ -13,14 +13,15 @@ export const listNotes = (options) => (options.client ?? client).get({
13
13
  /**
14
14
  * Post a note
15
15
  *
16
- * Creates the feed with its first note, optionally protected by its own password (`X-Feed-Password` header or a `password` field in the JSON or form body; 1 to 256 printable ASCII characters, with no space at the start or end). Posting to a protected feed needs that password. Also served at `POST /{feed}`, the short form the client packages and curl one-liners use. The body is at most 102400 bytes and must be UTF-8. `application/x-www-form-urlencoded` (what `curl -d` sends) is read as raw markdown, not as form fields. Tags (at most 10 tags, each 1 to 32 characters of letters, digits, `-`, `_`, `.` and `:`; case is folded to lowercase, duplicates are removed) go in the JSON `tags` array, a repeated `tags` form field, or, for a raw body, the `X-Note-Tags` header.
16
+ * The body is the note, a file, and `Content-Type` says which kind: `text/markdown` (UTF-8, at most 102400 bytes), or an image, `image/png`, `image/jpeg`, `image/gif` or `image/webp` (at most NOTEFEED_MAX_IMAGE_BYTES, default 5 MiB). Nothing is guessed: any other type, or none, is `415`, and so is a body that is not what the type says (an image is recognized by its first bytes; SVG is refused). It is stored byte for byte, with no resizing and no metadata stripped (EXIF such as GPS position stays in an image). Creates the feed with its first note, optionally protected by its own password (`X-Feed-Password`; 1 to 256 printable ASCII characters, with no space at the start or end) and with the read id in `X-Read-Id`; both are only used by the post that creates the feed. Posting to a protected feed needs its password. Also served at `POST /{feed}`, the short form curl one-liners use. The response names the note's `file` and where it is served, `file_url`, under the feed's read id (public like the read link). Metadata goes in headers: `X-Note-Title`, `X-Note-Tags` (at most 10 tags, each 1 to 32 characters of letters, digits, `-`, `_`, `.` and `:`; case is folded to lowercase, duplicates are removed), `X-Note-Alt` (images), `X-Note-Name` (the original file name).
17
17
  */
18
18
  export const postNote = (options) => (options.client ?? client).post({
19
+ bodySerializer: null,
19
20
  security: [{ scheme: 'bearer', type: 'http' }],
20
21
  url: '/api/v1/feeds/{feed}/notes',
21
22
  ...options,
22
23
  headers: {
23
- 'Content-Type': 'application/json',
24
+ 'Content-Type': 'text/markdown',
24
25
  ...options.headers
25
26
  }
26
27
  });
@@ -43,11 +44,11 @@ export const getNote = (options) => (options.client ?? client).get({
43
44
  ...options
44
45
  });
45
46
  /**
46
- * Edit a note
47
+ * Change a note's title or alt text
47
48
  *
48
- * Replaces the note's markdown; its id and creation time stay, the title follows the new text. Needs the feed's password if it has one, and counts against the post rate limit. Read links can't edit. The body is as for posting: at most 102400 bytes, UTF-8, a `password` field is ignored.
49
+ * Sets the note's title and/or alt text (alt only for images). An empty string removes one: a markdown note's title follows its text again. At least one is needed. Needs the feed's password if it has one, and counts against the post rate limit. Read links can't change notes.
49
50
  */
50
- export const editNote = (options) => (options.client ?? client).put({
51
+ export const patchNote = (options) => (options.client ?? client).patch({
51
52
  security: [{ scheme: 'bearer', type: 'http' }],
52
53
  url: '/api/v1/feeds/{feed}/notes/{id}',
53
54
  ...options,
@@ -57,17 +58,17 @@ export const editNote = (options) => (options.client ?? client).put({
57
58
  }
58
59
  });
59
60
  /**
60
- * Upload an image
61
+ * Replace a note's content
61
62
  *
62
- * The body is the image itself: PNG, JPEG, GIF or WebP, recognized by its first bytes, whatever `Content-Type` is sent (SVG is refused). Stored byte-for-byte, with no resizing and no metadata stripped (EXIF such as GPS position stays in the file), as the first 32 hex characters of its SHA-256 plus an extension: the same bytes always give the same URL. The URL is under the feed's read id, so it works in the feed page, the read-only view and RSS readers without any password. Needs the same credentials as posting and counts against the post rate limit. The feed must exist: it is created by its first note. The size limit is NOTEFEED_MAX_IMAGE_BYTES (default 5 MiB); images deleted only with the feed.
63
+ * The body is the new file, with the same rules as posting: a `Content-Type` that is one of the accepted types, a body that is what it declares. A note keeps its type, so the type must be the note's own (`415` otherwise). Id, creation time and metadata stay: the title of a markdown note without one set follows the new text. Change the title or alt text with `PATCH`. Needs the feed's password if it has one, and counts against the post rate limit. Read links can't edit.
63
64
  */
64
- export const uploadImage = (options) => (options.client ?? client).post({
65
+ export const editNote = (options) => (options.client ?? client).put({
65
66
  bodySerializer: null,
66
67
  security: [{ scheme: 'bearer', type: 'http' }],
67
- url: '/api/v1/feeds/{feed}/images',
68
+ url: '/api/v1/feeds/{feed}/notes/{id}',
68
69
  ...options,
69
70
  headers: {
70
- 'Content-Type': 'image/png',
71
+ 'Content-Type': 'text/markdown',
71
72
  ...options.headers
72
73
  }
73
74
  });
@@ -94,7 +95,7 @@ export const getFeed = (options) => (options.client ?? client).get({
94
95
  /**
95
96
  * Change a feed's settings
96
97
  *
97
- * Replaces both the title (at most 100 characters) and the description (at most 500); surrounding whitespace is trimmed and control characters are refused. `show_sender` (default true) shows who posted each note to readers; omitted leaves it as it is. `image` is the file name `uploadImage` returned for this feed (title image), empty to remove it, or omitted to leave it as it is; any other value is a 400. Needs the feed's password if it has one, and counts against the post rate limit. Only on a feed that exists: it is created by its first note. Read links can't change settings.
98
+ * Replaces both the title (at most 100 characters) and the description (at most 500); surrounding whitespace is trimmed and control characters are refused. `show_sender` (default true) shows who posted each note to readers; omitted leaves it as it is. `read_id` gives the feed another read link (3 to 64 characters: a-z, 0-9, - and _; empty for a random one): the old id is freed and answers like an unknown read id until another feed takes it, and the notes are not edited (a relative image link follows the new id, a full URL does not). A short readable read id is guessable, so protect the feed with a password if that matters. An instance can turn chosen read ids off (NOTEFEED_ALLOW_CUSTOM_IDS=0): then only an empty `read_id` is accepted. `image` is the file name `uploadImage` returned for this feed (title image), empty to remove it, or omitted to leave it as it is; any other value is a 400. Needs the feed's password if it has one, and counts against the post rate limit. Only on a feed that exists: it is created by its first note. Read links can't change settings.
98
99
  */
99
100
  export const updateFeed = (options) => (options.client ?? client).put({
100
101
  security: [{ scheme: 'bearer', type: 'http' }],
@@ -3,17 +3,41 @@ export type ClientOptions = {
3
3
  };
4
4
  export type Note = {
5
5
  /**
6
- * UTC time to the second plus a slug of the title
6
+ * The note's id: a UTC time to the second plus a random UUID for notes made here; any name without a dot for a file placed by hand
7
7
  */
8
8
  id: string;
9
9
  /**
10
- * The first heading, or the first non-empty line; may be empty
10
+ * The media type of the note's file: `text/markdown`, `image/png`, `image/jpeg`, `image/gif` or `image/webp`
11
+ */
12
+ type: string;
13
+ /**
14
+ * The note's file name, `<id>.<extension>`
15
+ */
16
+ file: string;
17
+ /**
18
+ * Where the note's file is served, absolute, under the feed's read id (public like the read link); null while the feed has no read link
19
+ */
20
+ file_url: string | null;
21
+ /**
22
+ * The size of the note's file in bytes
23
+ */
24
+ size: number;
25
+ /**
26
+ * The title set for the note, else the first heading or the first non-empty line of a markdown note, else the picture's alt text; may be empty
11
27
  */
12
28
  title: string;
13
29
  /**
14
- * The note, byte-for-byte as posted
30
+ * The note's text, byte-for-byte as posted: for a text type (markdown) only; absent for a picture
15
31
  */
16
- markdown: string;
32
+ content?: string;
33
+ /**
34
+ * Alternative text of an image note, when it has one
35
+ */
36
+ alt?: string;
37
+ /**
38
+ * The file name an image was posted with, when it came with one
39
+ */
40
+ name?: string;
17
41
  /**
18
42
  * When the note was posted (UTC)
19
43
  */
@@ -55,20 +79,14 @@ export type Created = {
55
79
  * The feed's read-only RSS link, safe to share; null only if the server can't read the feed's stored read id, or if that id and the derived one both belong to other feeds
56
80
  */
57
81
  read_url: string | null;
58
- };
59
- export type ImageUploaded = {
60
82
  /**
61
- * The stored file's name: 32 hex characters of the SHA-256 plus the extension. Pass it as a feed's `image` setting
83
+ * The note's file name, `<id>.<extension>`. For an image note, pass it as a feed's `image` setting
62
84
  */
63
85
  file: string;
64
86
  /**
65
- * Where the image is served, absolute, under the feed's read id; public like the read link
66
- */
67
- url: string;
68
- /**
69
- * `![](url)`, to paste into a note
87
+ * Where the note's file is served, absolute, under the feed's read id (public like the read link); null while the feed has no read link
70
88
  */
71
- markdown: string;
89
+ file_url: string | null;
72
90
  };
73
91
  export type Error = {
74
92
  /**
@@ -78,29 +96,17 @@ export type Error = {
78
96
  /**
79
97
  * Stable machine-readable code; absent only on a 500
80
98
  */
81
- code?: 'invalid_feed' | 'reserved_feed' | 'auth' | 'rate_limited' | 'too_many_attempts' | 'feed_limit' | 'note_limit' | 'image_limit' | 'empty_note' | 'too_large' | 'unsupported_type' | 'invalid_body' | 'invalid_request' | 'not_found' | 'feed_exists';
99
+ code?: 'invalid_feed' | 'reserved_feed' | 'auth' | 'rate_limited' | 'too_many_attempts' | 'feed_limit' | 'note_limit' | 'image_limit' | 'empty_note' | 'too_large' | 'unsupported_type' | 'invalid_body' | 'invalid_request' | 'not_found' | 'feed_exists' | 'taken';
82
100
  };
83
- export type PostJson = {
84
- markdown: string;
101
+ export type NoteMeta = {
85
102
  /**
86
- * Protects the feed: 1 to 256 printable ASCII characters, with no space at the start or end. Only honored on the post that creates the feed; an existing open feed answers 409. Empty is the same as leaving it out.
103
+ * The note's title, at most 100 characters, one line; empty or left out means the title is taken from the text (a markdown note) or there is none (an image)
87
104
  */
88
- password?: string;
105
+ title?: string;
89
106
  /**
90
- * Labels for the note: at most 10 tags, each 1 to 32 characters of letters, digits, `-`, `_`, `.` and `:`; case is folded to lowercase, duplicates are removed. Free labels, not verified, shown with the note (also to readers of the read link and RSS). Ignored when editing a note: an edit keeps its tags.
107
+ * Alternative text of an image note, at most 500 characters, one line
91
108
  */
92
- tags?: Array<string>;
93
- };
94
- export type PostForm = {
95
- markdown: string;
96
- /**
97
- * Protects the feed: 1 to 256 printable ASCII characters, with no space at the start or end. Only honored on the post that creates the feed; an existing open feed answers 409. Empty is the same as leaving it out.
98
- */
99
- password?: string;
100
- /**
101
- * Labels for the note: at most 10 tags, each 1 to 32 characters of letters, digits, `-`, `_`, `.` and `:`; case is folded to lowercase, duplicates are removed. Free labels, not verified, shown with the note (also to readers of the read link and RSS). Ignored when editing a note: an edit keeps its tags.
102
- */
103
- tags?: Array<string>;
109
+ alt?: string;
104
110
  };
105
111
  export type PasswordJson = {
106
112
  /**
@@ -125,6 +131,10 @@ export type FeedSettings = {
125
131
  * Whether readers (RSS, the read API and pages) see who posted each note; omitted leaves it as it is, a new feed starts with true
126
132
  */
127
133
  show_sender?: boolean;
134
+ /**
135
+ * A new read id (3 to 64 characters: a-z, 0-9, - and _), empty for a random one; omitted leaves it as it is. The old read link stops showing this feed, and may later show another one. Notes are not edited: a relative image link (`![](file)`) follows the new id, a full URL keeps the old one and is yours to change. Reserved feeds keep theirs. 409 when it is taken
136
+ */
137
+ read_id?: string;
128
138
  };
129
139
  export type Feed = {
130
140
  /**
@@ -223,16 +233,35 @@ export type ListNotesResponses = {
223
233
  };
224
234
  export type ListNotesResponse = ListNotesResponses[keyof ListNotesResponses];
225
235
  export type PostNoteData = {
226
- body: PostJson;
236
+ /**
237
+ * The note, a file: its bytes, of the type `Content-Type` declares
238
+ */
239
+ body: Blob | File;
227
240
  headers?: {
228
241
  /**
229
242
  * The feed's own password, when it has one: to post, list or get, and (as the current password) to change or remove it. On the `POST` that creates a feed it sets the feed's password; on a `POST` to an existing feed that has none it answers 409. An empty value is the same as no header, so a `POST` with an empty one creates an open feed.
230
243
  */
231
244
  'X-Feed-Password'?: string;
245
+ /**
246
+ * The read id the feed gets when this post creates it: 3 to 64 characters (a-z, 0-9, - and _), random when left out or empty, ignored for a feed that exists. A short readable one is guessable: protect the feed with a password if that matters. 409 when it is taken
247
+ */
248
+ 'X-Read-Id'?: string;
249
+ /**
250
+ * The note's title, for a raw body; a JSON or form body's own `title` wins. At most 100 characters, one line.
251
+ */
252
+ 'X-Note-Title'?: string;
232
253
  /**
233
254
  * Tags for the note, comma-separated (`ci,deploy`): at most 10 tags, each 1 to 32 characters of letters, digits, `-`, `_`, `.` and `:`; case is folded to lowercase, duplicates are removed. For a raw markdown body; a JSON or form body's own `tags` wins. Empty is none.
234
255
  */
235
256
  'X-Note-Tags'?: string;
257
+ /**
258
+ * For an image body: its alternative text; a form's own `alt` wins.
259
+ */
260
+ 'X-Note-Alt'?: string;
261
+ /**
262
+ * For an image body: the picture's original file name, kept with the note (at most 200 characters)
263
+ */
264
+ 'X-Note-Name'?: string;
236
265
  };
237
266
  path: {
238
267
  /**
@@ -245,7 +274,7 @@ export type PostNoteData = {
245
274
  };
246
275
  export type PostNoteErrors = {
247
276
  /**
248
- * Invalid or reserved feed name; empty note; bad JSON, form or UTF-8; a new password that is not printable ASCII; invalid tags
277
+ * Invalid or reserved feed name; a blank note; a bad title, alt text or tags; a new password that is not printable ASCII
249
278
  */
250
279
  400: Error;
251
280
  /**
@@ -253,15 +282,15 @@ export type PostNoteErrors = {
253
282
  */
254
283
  401: Error;
255
284
  /**
256
- * A password was sent for a feed that already exists without one: it can't be claimed
285
+ * A password was sent for a feed that already exists without one: it can't be claimed; or the chosen read id is taken
257
286
  */
258
287
  409: Error;
259
288
  /**
260
- * Body over 102400 bytes
289
+ * Markdown over 102400 bytes, or an image over NOTEFEED_MAX_IMAGE_BYTES
261
290
  */
262
291
  413: Error;
263
292
  /**
264
- * Unsupported content type
293
+ * Content-Type missing or not one of the accepted types, or the body is not what it declares
265
294
  */
266
295
  415: Error;
267
296
  /**
@@ -269,7 +298,7 @@ export type PostNoteErrors = {
269
298
  */
270
299
  429: Error;
271
300
  /**
272
- * NOTEFEED_MAX_FEEDS or NOTEFEED_MAX_NOTES_PER_FEED reached
301
+ * NOTEFEED_MAX_FEEDS, NOTEFEED_MAX_NOTES_PER_FEED or NOTEFEED_MAX_IMAGES_PER_FEED reached
273
302
  */
274
303
  507: Error;
275
304
  };
@@ -375,8 +404,8 @@ export type GetNoteResponses = {
375
404
  200: Note;
376
405
  };
377
406
  export type GetNoteResponse = GetNoteResponses[keyof GetNoteResponses];
378
- export type EditNoteData = {
379
- body: PostJson;
407
+ export type PatchNoteData = {
408
+ body: NoteMeta;
380
409
  headers?: {
381
410
  /**
382
411
  * The feed's own password, when it has one: to post, list or get, and (as the current password) to change or remove it. On the `POST` that creates a feed it sets the feed's password; on a `POST` to an existing feed that has none it answers 409. An empty value is the same as no header, so a `POST` with an empty one creates an open feed.
@@ -396,9 +425,9 @@ export type EditNoteData = {
396
425
  query?: never;
397
426
  url: '/api/v1/feeds/{feed}/notes/{id}';
398
427
  };
399
- export type EditNoteErrors = {
428
+ export type PatchNoteErrors = {
400
429
  /**
401
- * Invalid or reserved feed name; empty note; bad JSON, form or UTF-8
430
+ * Invalid or reserved feed name; nothing to change; a bad title or alt text; alt for a note that has none; bad JSON
402
431
  */
403
432
  400: Error;
404
433
  /**
@@ -410,11 +439,7 @@ export type EditNoteErrors = {
410
439
  */
411
440
  404: Error;
412
441
  /**
413
- * Body over 102400 bytes
414
- */
415
- 413: Error;
416
- /**
417
- * Unsupported content type
442
+ * Content-Type is not application/json
418
443
  */
419
444
  415: Error;
420
445
  /**
@@ -422,17 +447,17 @@ export type EditNoteErrors = {
422
447
  */
423
448
  429: Error;
424
449
  };
425
- export type EditNoteError = EditNoteErrors[keyof EditNoteErrors];
426
- export type EditNoteResponses = {
450
+ export type PatchNoteError = PatchNoteErrors[keyof PatchNoteErrors];
451
+ export type PatchNoteResponses = {
427
452
  /**
428
453
  * The note as it is now
429
454
  */
430
455
  200: Note;
431
456
  };
432
- export type EditNoteResponse = EditNoteResponses[keyof EditNoteResponses];
433
- export type UploadImageData = {
457
+ export type PatchNoteResponse = PatchNoteResponses[keyof PatchNoteResponses];
458
+ export type EditNoteData = {
434
459
  /**
435
- * The image's bytes: PNG, JPEG, GIF or WebP, recognized by their first bytes, not by the Content-Type
460
+ * The note, a file: its bytes, of the type `Content-Type` declares
436
461
  */
437
462
  body: Blob | File;
438
463
  headers?: {
@@ -446,13 +471,17 @@ export type UploadImageData = {
446
471
  * The feed's name. It is the write key: anyone who knows it can post.
447
472
  */
448
473
  feed: string;
474
+ /**
475
+ * The note's id
476
+ */
477
+ id: string;
449
478
  };
450
479
  query?: never;
451
- url: '/api/v1/feeds/{feed}/images';
480
+ url: '/api/v1/feeds/{feed}/notes/{id}';
452
481
  };
453
- export type UploadImageErrors = {
482
+ export type EditNoteErrors = {
454
483
  /**
455
- * Invalid or reserved feed name
484
+ * Invalid or reserved feed name; a blank note
456
485
  */
457
486
  400: Error;
458
487
  /**
@@ -460,34 +489,30 @@ export type UploadImageErrors = {
460
489
  */
461
490
  401: Error;
462
491
  /**
463
- * No such feed
492
+ * No such note
464
493
  */
465
494
  404: Error;
466
495
  /**
467
- * Body over NOTEFEED_MAX_IMAGE_BYTES
496
+ * Markdown over 102400 bytes, or an image over NOTEFEED_MAX_IMAGE_BYTES
468
497
  */
469
498
  413: Error;
470
499
  /**
471
- * Not a PNG, JPEG, GIF or WebP image
500
+ * Content-Type missing, not accepted or not the note's own type, or the body is not what it declares
472
501
  */
473
502
  415: Error;
474
503
  /**
475
- * Too many posts, uploads, edits and deletes, or wrong passwords, from this client
504
+ * Too many posts, edits and deletes, or wrong passwords, from this client
476
505
  */
477
506
  429: Error;
478
- /**
479
- * NOTEFEED_MAX_IMAGES_PER_FEED reached
480
- */
481
- 507: Error;
482
507
  };
483
- export type UploadImageError = UploadImageErrors[keyof UploadImageErrors];
484
- export type UploadImageResponses = {
508
+ export type EditNoteError = EditNoteErrors[keyof EditNoteErrors];
509
+ export type EditNoteResponses = {
485
510
  /**
486
- * Stored (or already there)
511
+ * The note as it is now
487
512
  */
488
- 201: ImageUploaded;
513
+ 200: Note;
489
514
  };
490
- export type UploadImageResponse = UploadImageResponses[keyof UploadImageResponses];
515
+ export type EditNoteResponse = EditNoteResponses[keyof EditNoteResponses];
491
516
  export type DeleteFeedData = {
492
517
  body?: never;
493
518
  headers?: {
@@ -593,7 +618,7 @@ export type UpdateFeedData = {
593
618
  };
594
619
  export type UpdateFeedErrors = {
595
620
  /**
596
- * Invalid or reserved feed name, bad JSON, or a title or description that is too long or has control characters
621
+ * Invalid or reserved feed name, bad JSON, a title or description that is too long or has control characters, a malformed read id, a reserved feed's read id, or chosen read ids turned off
597
622
  */
598
623
  400: Error;
599
624
  /**
@@ -604,6 +629,10 @@ export type UpdateFeedErrors = {
604
629
  * No such feed
605
630
  */
606
631
  404: Error;
632
+ /**
633
+ * The read id belongs to another feed or is held back for a reserved one
634
+ */
635
+ 409: Error;
607
636
  /**
608
637
  * Too many posts, edits and deletes, or wrong passwords, from this client
609
638
  */
@@ -707,7 +736,7 @@ export type GetReadFeedData = {
707
736
  body?: never;
708
737
  path: {
709
738
  /**
710
- * The feed's read id, from its read link: 22 random characters, or the feed's own name for a reserved feed. Read-only; never reveals the name of any other feed.
739
+ * The feed's read id, from its read link: 22 random characters, one the feed's owner chose, or the feed's own name for a reserved feed. Read-only; never reveals the name of any other feed.
711
740
  */
712
741
  readId: string;
713
742
  };
@@ -732,7 +761,7 @@ export type ListReadNotesData = {
732
761
  body?: never;
733
762
  path: {
734
763
  /**
735
- * The feed's read id, from its read link: 22 random characters, or the feed's own name for a reserved feed. Read-only; never reveals the name of any other feed.
764
+ * The feed's read id, from its read link: 22 random characters, one the feed's owner chose, or the feed's own name for a reserved feed. Read-only; never reveals the name of any other feed.
736
765
  */
737
766
  readId: string;
738
767
  };
@@ -774,7 +803,7 @@ export type GetReadNoteData = {
774
803
  body?: never;
775
804
  path: {
776
805
  /**
777
- * The feed's read id, from its read link: 22 random characters, or the feed's own name for a reserved feed. Read-only; never reveals the name of any other feed.
806
+ * The feed's read id, from its read link: 22 random characters, one the feed's owner chose, or the feed's own name for a reserved feed. Read-only; never reveals the name of any other feed.
778
807
  */
779
808
  readId: string;
780
809
  /**
package/dist/index.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  export { AuthError, Client, ConfigError, InvalidRequestError, LimitReachedError, NotefeedError, NotFoundError, NoteTooLargeError, RateLimitedError, } from "./client.js";
2
- export type { ClientOptions, Created, ErrorCode, Feed, FeedSettings, ImageUploaded, Note } from "./client.js";
2
+ export type { Attachment, ClientOptions, Created, ErrorCode, Feed, FeedSettings, Note } from "./client.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "notefeed",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Client and CLI for notefeed, a self-hosted inbox for markdown notes that you read back as RSS.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Marcel Bruckner",