applesauce-core 0.0.0-next-20250103191026 → 0.0.0-next-20250109205419

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.
@@ -0,0 +1,53 @@
1
+ import { NostrEvent } from "nostr-tools";
2
+ export type FileMetadata = {
3
+ /** URL of the file */
4
+ url: string;
5
+ /** MIME type */
6
+ type?: string;
7
+ /** sha256 hash of the file */
8
+ sha256?: string;
9
+ /**
10
+ * The original sha256 hash before the file was transformed
11
+ * @deprecated
12
+ */
13
+ originalSha256?: string;
14
+ /** size of the file in bytes */
15
+ size?: number;
16
+ /** size of file in pixels in the form <width>x<height> */
17
+ dimensions?: string;
18
+ /** magnet */
19
+ magnet?: string;
20
+ /** torrent infohash */
21
+ infohash?: string;
22
+ /** URL to a thumbnail */
23
+ thumbnail?: string;
24
+ /** URL to a preview image with the same dimensions */
25
+ image?: string;
26
+ /** summary */
27
+ summary?: string;
28
+ /** description for accessability */
29
+ alt?: string;
30
+ /** blurhash */
31
+ blurhash?: string;
32
+ /** fallback URLs */
33
+ fallback?: string[];
34
+ };
35
+ export type MediaAttachment = FileMetadata;
36
+ /**
37
+ * Parses file metadata tags into {@link FileMetadata}
38
+ * @throws
39
+ */
40
+ export declare function parseFileMetadataTags(tags: string[][]): FileMetadata;
41
+ /**
42
+ * Parses a imeta tag into a {@link FileMetadata}
43
+ * @throws
44
+ */
45
+ export declare function getFileMetadataFromImetaTag(tag: string[]): FileMetadata;
46
+ export declare const MediaAttachmentsSymbol: unique symbol;
47
+ /** Gets all the media attachments on an event */
48
+ export declare function getMediaAttachments(event: NostrEvent): FileMetadata[];
49
+ /**
50
+ * Gets {@link FileMetadata} for a NIP-94 kind 1063 event
51
+ * @throws
52
+ */
53
+ export declare function getFileMetadata(file: NostrEvent): FileMetadata;
@@ -0,0 +1,90 @@
1
+ import { getOrComputeCachedValue } from "./cache.js";
2
+ /**
3
+ * Parses file metadata tags into {@link FileMetadata}
4
+ * @throws
5
+ */
6
+ export function parseFileMetadataTags(tags) {
7
+ const fields = {};
8
+ let fallback = undefined;
9
+ for (const [name, value] of tags) {
10
+ switch (name) {
11
+ case "fallback":
12
+ fallback = fallback ? [...fallback, value] : [value];
13
+ break;
14
+ default:
15
+ fields[name] = value;
16
+ break;
17
+ }
18
+ }
19
+ if (!fields.url)
20
+ throw new Error("Missing required url in file metadata");
21
+ const metadata = { url: fields.url, fallback };
22
+ // parse size
23
+ if (fields.size)
24
+ metadata.size = parseInt(fields.size);
25
+ // copy optional fields
26
+ if (fields.m)
27
+ metadata.type = fields.m;
28
+ if (fields.x)
29
+ metadata.sha256 = fields.x;
30
+ if (fields.ox)
31
+ metadata.originalSha256 = fields.ox;
32
+ if (fields.dim)
33
+ metadata.dimensions = fields.dim;
34
+ if (fields.magnet)
35
+ metadata.magnet = fields.magnet;
36
+ if (fields.i)
37
+ metadata.infohash = fields.i;
38
+ if (fields.thumb)
39
+ metadata.thumbnail = fields.thumb;
40
+ if (fields.image)
41
+ metadata.image = fields.image;
42
+ if (fields.summary)
43
+ metadata.summary = fields.summary;
44
+ if (fields.alt)
45
+ metadata.alt = fields.alt;
46
+ if (fields.blurhash)
47
+ metadata.blurhash = fields.blurhash;
48
+ return metadata;
49
+ }
50
+ /**
51
+ * Parses a imeta tag into a {@link FileMetadata}
52
+ * @throws
53
+ */
54
+ export function getFileMetadataFromImetaTag(tag) {
55
+ const parts = tag.slice(1);
56
+ const tags = [];
57
+ for (const part of parts) {
58
+ const match = part.match(/^(.+?)\s(.+)$/);
59
+ if (match) {
60
+ const [_, name, value] = match;
61
+ tags.push([name, value]);
62
+ }
63
+ }
64
+ return parseFileMetadataTags(tags);
65
+ }
66
+ export const MediaAttachmentsSymbol = Symbol.for("media-attachments");
67
+ /** Gets all the media attachments on an event */
68
+ export function getMediaAttachments(event) {
69
+ return getOrComputeCachedValue(event, MediaAttachmentsSymbol, () => {
70
+ return event.tags
71
+ .filter((t) => t[0] === "imeta")
72
+ .map((tag) => {
73
+ try {
74
+ return getFileMetadataFromImetaTag(tag);
75
+ }
76
+ catch (error) {
77
+ // ignore invalid attachments
78
+ return undefined;
79
+ }
80
+ })
81
+ .filter((a) => !!a);
82
+ });
83
+ }
84
+ /**
85
+ * Gets {@link FileMetadata} for a NIP-94 kind 1063 event
86
+ * @throws
87
+ */
88
+ export function getFileMetadata(file) {
89
+ return parseFileMetadataTags(file.tags);
90
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,103 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { getFileMetadataFromImetaTag, parseFileMetadataTags } from "./file-metadata.js";
3
+ describe("file metadata helpers", () => {
4
+ describe("parseFileMetadataTags", () => {
5
+ it("should parse a simple 1060 event", () => {
6
+ const tags = [
7
+ ["url", "https://image.nostr.build/30696696e57a2732d4e9f1b15ff4d4d4eaa64b759df6876863f436ff5d736eae.gif"],
8
+ ["ox", "30696696e57a2732d4e9f1b15ff4d4d4eaa64b759df6876863f436ff5d736eae"],
9
+ ["fallback", "https://media.tenor.com/wpvrkjn192gAAAAC/daenerys-targaryen.gif"],
10
+ ["x", "77fcf42b2b720babcdbe686eff67273d8a68862d74a2672db672bc48439a3ea5"],
11
+ ["m", "image/gif"],
12
+ ["dim", "360x306"],
13
+ ["bh", "L38zleNL00~W^kRj0L-p0KM_^kx]"],
14
+ ["blurhash", "L38zleNL00~W^kRj0L-p0KM_^kx]"],
15
+ [
16
+ "thumb",
17
+ "https://image.nostr.build/thumb/30696696e57a2732d4e9f1b15ff4d4d4eaa64b759df6876863f436ff5d736eae.gif",
18
+ ],
19
+ ["t", "gifbuddy"],
20
+ ["summary", "Khaleesi call dragons Daenerys Targaryen"],
21
+ ["alt", "a woman with blonde hair and a brooch on her shoulder"],
22
+ [
23
+ "thumb",
24
+ "https://media.tenor.com/wpvrkjn192gAAAAx/daenerys-targaryen.webp",
25
+ "5d92423664fc15874b1d26c70a05a541ec09b5c438bf157977a87c8e64b31463",
26
+ ],
27
+ [
28
+ "image",
29
+ "https://media.tenor.com/wpvrkjn192gAAAAe/daenerys-targaryen.png",
30
+ "5d92423664fc15874b1d26c70a05a541ec09b5c438bf157977a87c8e64b31463",
31
+ ],
32
+ ];
33
+ expect(parseFileMetadataTags(tags)).toEqual({
34
+ url: "https://image.nostr.build/30696696e57a2732d4e9f1b15ff4d4d4eaa64b759df6876863f436ff5d736eae.gif",
35
+ type: "image/gif",
36
+ dimensions: "360x306",
37
+ blurhash: "L38zleNL00~W^kRj0L-p0KM_^kx]",
38
+ sha256: "77fcf42b2b720babcdbe686eff67273d8a68862d74a2672db672bc48439a3ea5",
39
+ originalSha256: "30696696e57a2732d4e9f1b15ff4d4d4eaa64b759df6876863f436ff5d736eae",
40
+ thumbnail: "https://media.tenor.com/wpvrkjn192gAAAAx/daenerys-targaryen.webp",
41
+ image: "https://media.tenor.com/wpvrkjn192gAAAAe/daenerys-targaryen.png",
42
+ summary: "Khaleesi call dragons Daenerys Targaryen",
43
+ fallback: ["https://media.tenor.com/wpvrkjn192gAAAAC/daenerys-targaryen.gif"],
44
+ alt: "a woman with blonde hair and a brooch on her shoulder",
45
+ });
46
+ });
47
+ });
48
+ describe("getFileMetadataFromImetaTag", () => {
49
+ it("should parse simple imeta tag", () => {
50
+ expect(getFileMetadataFromImetaTag([
51
+ "imeta",
52
+ "url https://blossom.primal.net/3f4dbf2797ac4e90b00bcfe2728e5c8367ed909c48230ac454cc325f1993646c.jpg",
53
+ "x 3f4dbf2797ac4e90b00bcfe2728e5c8367ed909c48230ac454cc325f1993646c",
54
+ "dim 1024x1024",
55
+ "m image/jpeg",
56
+ "blurhash ggH{Aws:RPWBRjaeay?^ozV@aeRjaej[$gt7kCofWVofkCrrofxuofa|ozbHx]s:tRofaet7ay",
57
+ ])).toEqual({
58
+ url: "https://blossom.primal.net/3f4dbf2797ac4e90b00bcfe2728e5c8367ed909c48230ac454cc325f1993646c.jpg",
59
+ sha256: "3f4dbf2797ac4e90b00bcfe2728e5c8367ed909c48230ac454cc325f1993646c",
60
+ dimensions: "1024x1024",
61
+ type: "image/jpeg",
62
+ blurhash: "ggH{Aws:RPWBRjaeay?^ozV@aeRjaej[$gt7kCofWVofkCrrofxuofa|ozbHx]s:tRofaet7ay",
63
+ });
64
+ });
65
+ it("should parse thumbnail url", () => {
66
+ expect(getFileMetadataFromImetaTag([
67
+ "imeta",
68
+ "url https://blossom.primal.net/3f4dbf2797ac4e90b00bcfe2728e5c8367ed909c48230ac454cc325f1993646c.jpg",
69
+ "x 3f4dbf2797ac4e90b00bcfe2728e5c8367ed909c48230ac454cc325f1993646c",
70
+ "dim 1024x1024",
71
+ "m image/jpeg",
72
+ "blurhash ggH{Aws:RPWBRjaeay?^ozV@aeRjaej[$gt7kCofWVofkCrrofxuofa|ozbHx]s:tRofaet7ay",
73
+ "thumb https://exmaple.com/thumb.jpg",
74
+ ])).toEqual({
75
+ url: "https://blossom.primal.net/3f4dbf2797ac4e90b00bcfe2728e5c8367ed909c48230ac454cc325f1993646c.jpg",
76
+ sha256: "3f4dbf2797ac4e90b00bcfe2728e5c8367ed909c48230ac454cc325f1993646c",
77
+ dimensions: "1024x1024",
78
+ type: "image/jpeg",
79
+ blurhash: "ggH{Aws:RPWBRjaeay?^ozV@aeRjaej[$gt7kCofWVofkCrrofxuofa|ozbHx]s:tRofaet7ay",
80
+ thumbnail: "https://exmaple.com/thumb.jpg",
81
+ });
82
+ });
83
+ it("should parse multiple fallback urls", () => {
84
+ expect(getFileMetadataFromImetaTag([
85
+ "imeta",
86
+ "url https://blossom.primal.net/3f4dbf2797ac4e90b00bcfe2728e5c8367ed909c48230ac454cc325f1993646c.jpg",
87
+ "x 3f4dbf2797ac4e90b00bcfe2728e5c8367ed909c48230ac454cc325f1993646c",
88
+ "dim 1024x1024",
89
+ "m image/jpeg",
90
+ "blurhash ggH{Aws:RPWBRjaeay?^ozV@aeRjaej[$gt7kCofWVofkCrrofxuofa|ozbHx]s:tRofaet7ay",
91
+ "fallback https://exmaple.com/image2.jpg",
92
+ "fallback https://exmaple.com/image3.jpg",
93
+ ])).toEqual({
94
+ url: "https://blossom.primal.net/3f4dbf2797ac4e90b00bcfe2728e5c8367ed909c48230ac454cc325f1993646c.jpg",
95
+ sha256: "3f4dbf2797ac4e90b00bcfe2728e5c8367ed909c48230ac454cc325f1993646c",
96
+ dimensions: "1024x1024",
97
+ type: "image/jpeg",
98
+ blurhash: "ggH{Aws:RPWBRjaeay?^ozV@aeRjaej[$gt7kCofWVofkCrrofxuofa|ozbHx]s:tRofaet7ay",
99
+ fallback: ["https://exmaple.com/image2.jpg", "https://exmaple.com/image3.jpg"],
100
+ });
101
+ });
102
+ });
103
+ });
@@ -13,7 +13,8 @@ export * from "./json.js";
13
13
  export * from "./lnurl.js";
14
14
  export * from "./lru.js";
15
15
  export * from "./mailboxes.js";
16
- export * from "./media-attachment.js";
16
+ export * from "./file-metadata.js";
17
+ export * from "./picture-post.js";
17
18
  export * from "./pointers.js";
18
19
  export * from "./profile.js";
19
20
  export * from "./relays.js";
@@ -13,7 +13,8 @@ export * from "./json.js";
13
13
  export * from "./lnurl.js";
14
14
  export * from "./lru.js";
15
15
  export * from "./mailboxes.js";
16
- export * from "./media-attachment.js";
16
+ export * from "./file-metadata.js";
17
+ export * from "./picture-post.js";
17
18
  export * from "./pointers.js";
18
19
  export * from "./profile.js";
19
20
  export * from "./relays.js";
@@ -2,10 +2,15 @@ import { NostrEvent } from "nostr-tools";
2
2
  export type MediaAttachment = {
3
3
  /** URL of the file */
4
4
  url: string;
5
- /** mime type */
5
+ /** MIME type */
6
6
  type?: string;
7
7
  /** sha256 hash of the file */
8
8
  sha256?: string;
9
+ /**
10
+ * The original sha256 hash before the file was transformed
11
+ * @deprecated
12
+ */
13
+ originalSha256?: string;
9
14
  /** size of the file in bytes */
10
15
  size?: number;
11
16
  /** size of file in pixels in the form <width>x<height> */
@@ -15,13 +20,17 @@ export type MediaAttachment = {
15
20
  /** torrent infohash */
16
21
  infohash?: string;
17
22
  /** URL to a thumbnail */
18
- thumb?: string;
23
+ thumbnail?: string;
19
24
  /** URL to a preview image with the same dimensions */
20
25
  image?: string;
21
26
  /** summary */
22
27
  summary?: string;
23
28
  /** description for accessability */
24
29
  alt?: string;
30
+ /** blurhash */
31
+ blurhash?: string;
32
+ /** fallback URLs */
33
+ fallback?: string[];
25
34
  };
26
35
  /**
27
36
  * Parses a imeta tag into a {@link MediaAttachment}
@@ -6,16 +6,24 @@ import { getOrComputeCachedValue } from "./cache.js";
6
6
  export function parseMediaAttachmentTag(tag) {
7
7
  const parts = tag.slice(1);
8
8
  const fields = {};
9
+ let fallback = undefined;
9
10
  for (const part of parts) {
10
11
  const match = part.match(/^(.+?)\s(.+)$/);
11
12
  if (match) {
12
13
  const [_, name, value] = match;
13
- fields[name] = value;
14
+ switch (name) {
15
+ case "fallback":
16
+ fallback = fallback ? [...fallback, value] : [value];
17
+ break;
18
+ default:
19
+ fields[name] = value;
20
+ break;
21
+ }
14
22
  }
15
23
  }
16
24
  if (!fields.url)
17
25
  throw new Error("Missing required url in attachment");
18
- const attachment = { url: fields.url };
26
+ const attachment = { url: fields.url, fallback };
19
27
  // parse size
20
28
  if (fields.size)
21
29
  attachment.size = parseInt(fields.size);
@@ -24,6 +32,8 @@ export function parseMediaAttachmentTag(tag) {
24
32
  attachment.type = fields.m;
25
33
  if (fields.x)
26
34
  attachment.sha256 = fields.x;
35
+ if (fields.ox)
36
+ attachment.originalSha256 = fields.ox;
27
37
  if (fields.dim)
28
38
  attachment.dimensions = fields.dim;
29
39
  if (fields.magnet)
@@ -31,13 +41,15 @@ export function parseMediaAttachmentTag(tag) {
31
41
  if (fields.i)
32
42
  attachment.infohash = fields.i;
33
43
  if (fields.thumb)
34
- attachment.thumb = fields.thumb;
44
+ attachment.thumbnail = fields.thumb;
35
45
  if (fields.image)
36
46
  attachment.image = fields.image;
37
47
  if (fields.summary)
38
48
  attachment.summary = fields.summary;
39
49
  if (fields.alt)
40
50
  attachment.alt = fields.alt;
51
+ if (fields.blurhash)
52
+ attachment.blurhash = fields.blurhash;
41
53
  return attachment;
42
54
  }
43
55
  export const MediaAttachmentsSymbol = Symbol.for("media-attachments");
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,59 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { parseMediaAttachmentTag } from "./media-attachment.js";
3
+ describe("media attachment helpers", () => {
4
+ describe("parseMediaAttachmentTag", () => {
5
+ it("should parse simple imeta tag", () => {
6
+ expect(parseMediaAttachmentTag([
7
+ "imeta",
8
+ "url https://blossom.primal.net/3f4dbf2797ac4e90b00bcfe2728e5c8367ed909c48230ac454cc325f1993646c.jpg",
9
+ "x 3f4dbf2797ac4e90b00bcfe2728e5c8367ed909c48230ac454cc325f1993646c",
10
+ "dim 1024x1024",
11
+ "m image/jpeg",
12
+ "blurhash ggH{Aws:RPWBRjaeay?^ozV@aeRjaej[$gt7kCofWVofkCrrofxuofa|ozbHx]s:tRofaet7ay",
13
+ ])).toEqual({
14
+ url: "https://blossom.primal.net/3f4dbf2797ac4e90b00bcfe2728e5c8367ed909c48230ac454cc325f1993646c.jpg",
15
+ sha256: "3f4dbf2797ac4e90b00bcfe2728e5c8367ed909c48230ac454cc325f1993646c",
16
+ dimensions: "1024x1024",
17
+ type: "image/jpeg",
18
+ blurhash: "ggH{Aws:RPWBRjaeay?^ozV@aeRjaej[$gt7kCofWVofkCrrofxuofa|ozbHx]s:tRofaet7ay",
19
+ });
20
+ });
21
+ it("should parse thumbnail url", () => {
22
+ expect(parseMediaAttachmentTag([
23
+ "imeta",
24
+ "url https://blossom.primal.net/3f4dbf2797ac4e90b00bcfe2728e5c8367ed909c48230ac454cc325f1993646c.jpg",
25
+ "x 3f4dbf2797ac4e90b00bcfe2728e5c8367ed909c48230ac454cc325f1993646c",
26
+ "dim 1024x1024",
27
+ "m image/jpeg",
28
+ "blurhash ggH{Aws:RPWBRjaeay?^ozV@aeRjaej[$gt7kCofWVofkCrrofxuofa|ozbHx]s:tRofaet7ay",
29
+ "thumb https://exmaple.com/thumb.jpg",
30
+ ])).toEqual({
31
+ url: "https://blossom.primal.net/3f4dbf2797ac4e90b00bcfe2728e5c8367ed909c48230ac454cc325f1993646c.jpg",
32
+ sha256: "3f4dbf2797ac4e90b00bcfe2728e5c8367ed909c48230ac454cc325f1993646c",
33
+ dimensions: "1024x1024",
34
+ type: "image/jpeg",
35
+ blurhash: "ggH{Aws:RPWBRjaeay?^ozV@aeRjaej[$gt7kCofWVofkCrrofxuofa|ozbHx]s:tRofaet7ay",
36
+ thumbnail: "https://exmaple.com/thumb.jpg",
37
+ });
38
+ });
39
+ it("should parse multiple fallback urls", () => {
40
+ expect(parseMediaAttachmentTag([
41
+ "imeta",
42
+ "url https://blossom.primal.net/3f4dbf2797ac4e90b00bcfe2728e5c8367ed909c48230ac454cc325f1993646c.jpg",
43
+ "x 3f4dbf2797ac4e90b00bcfe2728e5c8367ed909c48230ac454cc325f1993646c",
44
+ "dim 1024x1024",
45
+ "m image/jpeg",
46
+ "blurhash ggH{Aws:RPWBRjaeay?^ozV@aeRjaej[$gt7kCofWVofkCrrofxuofa|ozbHx]s:tRofaet7ay",
47
+ "fallback https://exmaple.com/image2.jpg",
48
+ "fallback https://exmaple.com/image3.jpg",
49
+ ])).toEqual({
50
+ url: "https://blossom.primal.net/3f4dbf2797ac4e90b00bcfe2728e5c8367ed909c48230ac454cc325f1993646c.jpg",
51
+ sha256: "3f4dbf2797ac4e90b00bcfe2728e5c8367ed909c48230ac454cc325f1993646c",
52
+ dimensions: "1024x1024",
53
+ type: "image/jpeg",
54
+ blurhash: "ggH{Aws:RPWBRjaeay?^ozV@aeRjaej[$gt7kCofWVofkCrrofxuofa|ozbHx]s:tRofaet7ay",
55
+ fallback: ["https://exmaple.com/image2.jpg", "https://exmaple.com/image3.jpg"],
56
+ });
57
+ });
58
+ });
59
+ });
@@ -0,0 +1,4 @@
1
+ import { NostrEvent } from "nostr-tools";
2
+ export declare const MEDIA_POST_KIND = 20;
3
+ /** Return the media attachments from a kind 20 media post */
4
+ export declare function getMediaPostAttachments(post: NostrEvent): import("./file-metadata.js").FileMetadata[];
@@ -0,0 +1,6 @@
1
+ import { getMediaAttachments } from "./file-metadata.js";
2
+ export const MEDIA_POST_KIND = 20;
3
+ /** Return the media attachments from a kind 20 media post */
4
+ export function getMediaPostAttachments(post) {
5
+ return getMediaAttachments(post);
6
+ }
@@ -0,0 +1,4 @@
1
+ import { NostrEvent } from "nostr-tools";
2
+ export declare const PICTURE_POST_KIND = 20;
3
+ /** Return the media attachments from a kind 20 media post */
4
+ export declare function getPicturePostAttachments(post: NostrEvent): import("./file-metadata.js").FileMetadata[];
@@ -0,0 +1,6 @@
1
+ import { getMediaAttachments } from "./file-metadata.js";
2
+ export const PICTURE_POST_KIND = 20;
3
+ /** Return the media attachments from a kind 20 media post */
4
+ export function getPicturePostAttachments(post) {
5
+ return getMediaAttachments(post);
6
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "applesauce-core",
3
- "version": "0.0.0-next-20250103191026",
3
+ "version": "0.0.0-next-20250109205419",
4
4
  "description": "",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",