@freqhole/api-client 0.1.30 → 0.1.31
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 +49 -0
- package/package.json +5 -1
- package/src/playlistz/convert.ts +0 -267
- package/src/playlistz/index.ts +0 -6
- package/src/playlistz/mutations.ts +0 -181
- package/src/playlistz/protocol.ts +0 -222
- package/src/playlistz/schema.ts +0 -138
- package/src/playlistz/shareLink.ts +0 -76
package/README.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# @freqhole/api-client
|
|
2
|
+
|
|
3
|
+
type-safe TypeScript client for freqhole APIs.
|
|
4
|
+
|
|
5
|
+
this package wraps generated route + schema metadata with a small runtime client so requests are validated and typed on both input and output.
|
|
6
|
+
|
|
7
|
+
## install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm i @freqhole/api-client
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
for local monorepo usage, this repo usually links it by file path.
|
|
14
|
+
|
|
15
|
+
## quick start
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { createHttpClient } from "@freqhole/api-client";
|
|
19
|
+
|
|
20
|
+
const api = createHttpClient({
|
|
21
|
+
baseUrl: "http://localhost:4315",
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const songs = await api.music.listSongs({
|
|
25
|
+
query: { limit: 25, offset: 0 },
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
if (songs.success) {
|
|
29
|
+
console.log(songs.data.songs.length);
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## package structure
|
|
34
|
+
|
|
35
|
+
- `src/codegen/schema.ts`: generated zod schemas
|
|
36
|
+
- `src/codegen/routes.ts`: generated route metadata
|
|
37
|
+
- `src/*.ts`: hand-written helpers and client adapters
|
|
38
|
+
|
|
39
|
+
avoid editing files under `src/codegen/` directly.
|
|
40
|
+
|
|
41
|
+
## development notes
|
|
42
|
+
|
|
43
|
+
if you are working in this repo and need to regenerate types/routes, run codegen from `client-codegen/`.
|
|
44
|
+
|
|
45
|
+
the full generator workflow and architecture docs live in `client-codegen/README.md`.
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
made with 💖 in NYC
|
package/package.json
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@freqhole/api-client",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.31",
|
|
4
4
|
"type": "module",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "https://github.com/freqhole/tomb"
|
|
8
|
+
},
|
|
5
9
|
"main": "./src/index.ts",
|
|
6
10
|
"publishConfig": {
|
|
7
11
|
"access": "public"
|
package/src/playlistz/convert.ts
DELETED
|
@@ -1,267 +0,0 @@
|
|
|
1
|
-
// converters between external record shapes and PlaylistDoc.
|
|
2
|
-
// all converters are total: missing optional source fields become schema defaults.
|
|
3
|
-
// never throw on extra fields.
|
|
4
|
-
import type { Playlist as FreqholePlaylist, Song as FreqholeSong } from "../codegen/schema.js";
|
|
5
|
-
import {
|
|
6
|
-
emptyPlaylistDoc,
|
|
7
|
-
type ImageRef,
|
|
8
|
-
type PlaylistDoc,
|
|
9
|
-
type SongEntry,
|
|
10
|
-
type EntityUrl,
|
|
11
|
-
} from "./schema.js";
|
|
12
|
-
|
|
13
|
-
// ---- shared helpers ----
|
|
14
|
-
|
|
15
|
-
function tsToIso(ts: number): string {
|
|
16
|
-
return new Date(ts).toISOString();
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function mapEntityUrls(
|
|
20
|
-
urls?: Array<{ id?: string | null; name?: string | null; url: string }> | null,
|
|
21
|
-
): EntityUrl[] {
|
|
22
|
-
if (!urls) return [];
|
|
23
|
-
return urls.map((u) => ({
|
|
24
|
-
url: u.url,
|
|
25
|
-
...(u.id != null ? { id: u.id } : {}),
|
|
26
|
-
...(u.name != null ? { name: u.name } : {}),
|
|
27
|
-
}));
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
// ---- legacy playlistz types ----
|
|
31
|
-
// (removed: playlistz has no deployed users, so there is no legacy idb data
|
|
32
|
-
// to convert - new playlists are created directly as docs)
|
|
33
|
-
|
|
34
|
-
// ---- spume idb types (inline - do not import from spume source) ----
|
|
35
|
-
// field shapes from tomb/client/spume/src/music/services/storage/types.ts
|
|
36
|
-
|
|
37
|
-
interface SpumeImageMetadata {
|
|
38
|
-
local_blob_id?: string;
|
|
39
|
-
remote_blob_id?: string;
|
|
40
|
-
is_primary: boolean;
|
|
41
|
-
blob_type: "thumbnail" | "waveform" | "original" | "preview";
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
interface SpumePlaylist {
|
|
45
|
-
playlist_id: string;
|
|
46
|
-
title: string;
|
|
47
|
-
description?: string | null;
|
|
48
|
-
is_public?: boolean;
|
|
49
|
-
images?: SpumeImageMetadata[];
|
|
50
|
-
urls?: Array<{ id?: string; name?: string; url: string }>;
|
|
51
|
-
created_at?: number;
|
|
52
|
-
updated_at?: number;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
interface SpumeSong {
|
|
56
|
-
id: string;
|
|
57
|
-
sha256: string;
|
|
58
|
-
title: string;
|
|
59
|
-
artist_name?: string;
|
|
60
|
-
album_title?: string;
|
|
61
|
-
duration_seconds?: number;
|
|
62
|
-
mime_type?: string | null;
|
|
63
|
-
file_size?: number | null;
|
|
64
|
-
blake3?: string | null;
|
|
65
|
-
lyrics?: string | null;
|
|
66
|
-
images?: SpumeImageMetadata[];
|
|
67
|
-
urls?: Array<{ id?: string; name?: string; url: string }>;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// map SpumeImageMetadata -> ImageRef, skipping entries with no blob id
|
|
71
|
-
function mapSpumeImages(images?: SpumeImageMetadata[]): ImageRef[] {
|
|
72
|
-
if (!images) return [];
|
|
73
|
-
const result: ImageRef[] = [];
|
|
74
|
-
for (const img of images) {
|
|
75
|
-
const blobId = img.local_blob_id ?? img.remote_blob_id;
|
|
76
|
-
if (!blobId) continue;
|
|
77
|
-
result.push({
|
|
78
|
-
blobId,
|
|
79
|
-
isPrimary: img.is_primary,
|
|
80
|
-
blobType: img.blob_type,
|
|
81
|
-
});
|
|
82
|
-
}
|
|
83
|
-
return result;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// map freqhole wire image (is_primary as 0/1 number) -> ImageRef
|
|
87
|
-
function mapFreqholeImages(
|
|
88
|
-
images?: Array<{
|
|
89
|
-
blob_id: string;
|
|
90
|
-
is_primary: number;
|
|
91
|
-
blob_type: "original" | "thumbnail" | "waveform" | "preview";
|
|
92
|
-
}> | null,
|
|
93
|
-
): ImageRef[] {
|
|
94
|
-
if (!images) return [];
|
|
95
|
-
return images.map((img) => ({
|
|
96
|
-
blobId: img.blob_id,
|
|
97
|
-
isPrimary: img.is_primary !== 0,
|
|
98
|
-
blobType: img.blob_type,
|
|
99
|
-
}));
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// ---- converters ----
|
|
103
|
-
|
|
104
|
-
// convert a freqhole wire Playlist + Song[] to a PlaylistDoc.
|
|
105
|
-
// maps snake_case fields and is_primary number (0/1) -> boolean.
|
|
106
|
-
// note: the freqhole Song wire type does not carry album_title; that field defaults to "".
|
|
107
|
-
// media_blob_id is used as sha256 (it is the server's short blob id, not a true content hash).
|
|
108
|
-
export function freqholePlaylistToDoc(
|
|
109
|
-
playlist: FreqholePlaylist,
|
|
110
|
-
songs: FreqholeSong[],
|
|
111
|
-
): PlaylistDoc {
|
|
112
|
-
const createdAt = tsToIso(playlist.created_at);
|
|
113
|
-
const lastModified = tsToIso(playlist.updated_at);
|
|
114
|
-
|
|
115
|
-
const songEntries: Record<string, SongEntry> = {};
|
|
116
|
-
const order: string[] = [];
|
|
117
|
-
|
|
118
|
-
for (const song of songs) {
|
|
119
|
-
const entry: SongEntry = {
|
|
120
|
-
id: song.id,
|
|
121
|
-
title: song.title ?? "",
|
|
122
|
-
artist: song.track_artist ?? "",
|
|
123
|
-
album: "",
|
|
124
|
-
duration: song.duration ?? 0,
|
|
125
|
-
mimeType: "",
|
|
126
|
-
fileSize: 0,
|
|
127
|
-
sha256: song.media_blob_id,
|
|
128
|
-
images: mapFreqholeImages(song.images),
|
|
129
|
-
urls: mapEntityUrls(song.urls),
|
|
130
|
-
...(song.lyrics != null ? { lyrics: song.lyrics } : {}),
|
|
131
|
-
};
|
|
132
|
-
songEntries[song.id] = entry;
|
|
133
|
-
order.push(song.id);
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
return emptyPlaylistDoc({
|
|
137
|
-
title: playlist.title ?? "",
|
|
138
|
-
description: playlist.description ?? "",
|
|
139
|
-
createdAt,
|
|
140
|
-
lastModified,
|
|
141
|
-
images: mapFreqholeImages(playlist.images),
|
|
142
|
-
urls: mapEntityUrls(playlist.urls),
|
|
143
|
-
songs: songEntries,
|
|
144
|
-
order,
|
|
145
|
-
});
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// convert a spume idb Playlist + Song[] to a PlaylistDoc.
|
|
149
|
-
// maps spume's ImageMetadata (local_blob_id/remote_blob_id) to ImageRef.
|
|
150
|
-
// spume Song fields: sha256, artist_name, album_title, duration_seconds, mime_type, file_size, blake3.
|
|
151
|
-
export function spumePlaylistToDoc(
|
|
152
|
-
playlist: SpumePlaylist,
|
|
153
|
-
songs: SpumeSong[],
|
|
154
|
-
): PlaylistDoc {
|
|
155
|
-
const now = new Date().toISOString();
|
|
156
|
-
const createdAt = playlist.created_at ? tsToIso(playlist.created_at) : now;
|
|
157
|
-
const lastModified = playlist.updated_at ? tsToIso(playlist.updated_at) : now;
|
|
158
|
-
|
|
159
|
-
const songEntries: Record<string, SongEntry> = {};
|
|
160
|
-
const order: string[] = [];
|
|
161
|
-
|
|
162
|
-
for (const song of songs) {
|
|
163
|
-
const entry: SongEntry = {
|
|
164
|
-
id: song.id,
|
|
165
|
-
title: song.title ?? "",
|
|
166
|
-
artist: song.artist_name ?? "",
|
|
167
|
-
album: song.album_title ?? "",
|
|
168
|
-
duration: song.duration_seconds ?? 0,
|
|
169
|
-
mimeType: song.mime_type ?? "",
|
|
170
|
-
fileSize: song.file_size ?? 0,
|
|
171
|
-
sha256: song.sha256,
|
|
172
|
-
images: mapSpumeImages(song.images),
|
|
173
|
-
urls: mapEntityUrls(song.urls),
|
|
174
|
-
...(song.blake3 != null ? { blake3: song.blake3 } : {}),
|
|
175
|
-
...(song.lyrics != null ? { lyrics: song.lyrics } : {}),
|
|
176
|
-
};
|
|
177
|
-
songEntries[song.id] = entry;
|
|
178
|
-
order.push(song.id);
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
return emptyPlaylistDoc({
|
|
182
|
-
title: playlist.title ?? "",
|
|
183
|
-
description: playlist.description ?? "",
|
|
184
|
-
createdAt,
|
|
185
|
-
lastModified,
|
|
186
|
-
images: mapSpumeImages(playlist.images),
|
|
187
|
-
urls: mapEntityUrls(playlist.urls),
|
|
188
|
-
songs: songEntries,
|
|
189
|
-
order,
|
|
190
|
-
});
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
// convert a PlaylistDoc back to the freqhole wire format where lossless.
|
|
194
|
-
// id is not stored in the doc - defaults to "". caller must supply the real
|
|
195
|
-
// playlist id if needed (e.g. from the docIndex entry).
|
|
196
|
-
// note: album_title, mime_type, file_size are not in the freqhole Song wire type
|
|
197
|
-
// and are dropped. sha256 is mapped back to media_blob_id.
|
|
198
|
-
export function docToFreqholePlaylist(
|
|
199
|
-
doc: PlaylistDoc,
|
|
200
|
-
): { playlist: FreqholePlaylist; songs: FreqholeSong[] } {
|
|
201
|
-
const createdAt = doc.createdAt ? new Date(doc.createdAt).getTime() : 0;
|
|
202
|
-
const updatedAt = doc.lastModified ? new Date(doc.lastModified).getTime() : 0;
|
|
203
|
-
|
|
204
|
-
const playlist: FreqholePlaylist = {
|
|
205
|
-
id: "",
|
|
206
|
-
title: doc.title,
|
|
207
|
-
description: doc.description || null,
|
|
208
|
-
is_public: 0,
|
|
209
|
-
images: doc.images.map((img) => ({
|
|
210
|
-
blob_id: img.blobId,
|
|
211
|
-
is_primary: img.isPrimary ? 1 : 0,
|
|
212
|
-
blob_type: img.blobType,
|
|
213
|
-
})),
|
|
214
|
-
urls: doc.urls.map((u) => ({
|
|
215
|
-
url: u.url,
|
|
216
|
-
id: u.id ?? null,
|
|
217
|
-
name: u.name ?? null,
|
|
218
|
-
})),
|
|
219
|
-
created_by_id: null,
|
|
220
|
-
created_at: createdAt,
|
|
221
|
-
updated_at: updatedAt,
|
|
222
|
-
deleted_at: null,
|
|
223
|
-
deleted_by: null,
|
|
224
|
-
created_by: null,
|
|
225
|
-
updated_by: null,
|
|
226
|
-
song_count: doc.order.length,
|
|
227
|
-
};
|
|
228
|
-
|
|
229
|
-
const songs: FreqholeSong[] = doc.order
|
|
230
|
-
.map((id) => doc.songs[id])
|
|
231
|
-
.filter((s): s is SongEntry => s != null)
|
|
232
|
-
.map((s) => {
|
|
233
|
-
return {
|
|
234
|
-
id: s.id,
|
|
235
|
-
media_blob_id: s.sha256,
|
|
236
|
-
title: s.title,
|
|
237
|
-
track_number: 1,
|
|
238
|
-
disc_number: 1,
|
|
239
|
-
duration: s.duration,
|
|
240
|
-
track_artist: s.artist || null,
|
|
241
|
-
images: s.images.map((img) => ({
|
|
242
|
-
blob_id: img.blobId,
|
|
243
|
-
is_primary: img.isPrimary ? 1 : 0,
|
|
244
|
-
blob_type: img.blobType,
|
|
245
|
-
})),
|
|
246
|
-
urls: s.urls.map((u) => ({
|
|
247
|
-
url: u.url,
|
|
248
|
-
id: u.id ?? null,
|
|
249
|
-
name: u.name ?? null,
|
|
250
|
-
})),
|
|
251
|
-
metadata: null,
|
|
252
|
-
lyrics: s.lyrics ?? null,
|
|
253
|
-
created_at: createdAt,
|
|
254
|
-
updated_at: updatedAt,
|
|
255
|
-
deleted_at: null,
|
|
256
|
-
deleted_by: null,
|
|
257
|
-
created_by: null,
|
|
258
|
-
updated_by: null,
|
|
259
|
-
created_by_username: null,
|
|
260
|
-
updated_by_username: null,
|
|
261
|
-
bpm: null,
|
|
262
|
-
play_count: null,
|
|
263
|
-
};
|
|
264
|
-
});
|
|
265
|
-
|
|
266
|
-
return { playlist, songs };
|
|
267
|
-
}
|
package/src/playlistz/index.ts
DELETED
|
@@ -1,181 +0,0 @@
|
|
|
1
|
-
// pure mutation helpers for PlaylistDoc.
|
|
2
|
-
// each function mutates the doc draft in place - safe to call inside handle.change().
|
|
3
|
-
// no automerge imports: these are plain object mutations over plain objects.
|
|
4
|
-
import type { PlaylistDoc, SongEntry, ImageRef } from "./schema.js";
|
|
5
|
-
|
|
6
|
-
function nowIso(): string {
|
|
7
|
-
return new Date().toISOString();
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
// bump lastModified to now, optionally setting lastModifiedBy
|
|
11
|
-
function bumpModified(doc: PlaylistDoc, by?: string): void {
|
|
12
|
-
doc.lastModified = nowIso();
|
|
13
|
-
if (by !== undefined) {
|
|
14
|
-
doc.lastModifiedBy = by;
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
// insert or replace a song in the doc, appending its id to order if not already present
|
|
19
|
-
export function upsertSong(doc: PlaylistDoc, song: SongEntry, by?: string): void {
|
|
20
|
-
doc.songs[song.id] = song;
|
|
21
|
-
if (!doc.order.includes(song.id)) {
|
|
22
|
-
doc.order.push(song.id);
|
|
23
|
-
}
|
|
24
|
-
bumpModified(doc, by);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
// remove a song from songs and order
|
|
28
|
-
export function removeSong(doc: PlaylistDoc, songId: string, by?: string): void {
|
|
29
|
-
delete doc.songs[songId];
|
|
30
|
-
const idx = doc.order.indexOf(songId);
|
|
31
|
-
if (idx !== -1) {
|
|
32
|
-
doc.order.splice(idx, 1);
|
|
33
|
-
}
|
|
34
|
-
bumpModified(doc, by);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
// move a single song to a target index within order.
|
|
38
|
-
// single-item splice keeps automerge merge semantics sane.
|
|
39
|
-
export function reorderSongs(
|
|
40
|
-
doc: PlaylistDoc,
|
|
41
|
-
songId: string,
|
|
42
|
-
toIndex: number,
|
|
43
|
-
by?: string,
|
|
44
|
-
): void {
|
|
45
|
-
const fromIdx = doc.order.indexOf(songId);
|
|
46
|
-
if (fromIdx === -1) return;
|
|
47
|
-
doc.order.splice(fromIdx, 1);
|
|
48
|
-
const clamped = Math.max(0, Math.min(toIndex, doc.order.length));
|
|
49
|
-
doc.order.splice(clamped, 0, songId);
|
|
50
|
-
bumpModified(doc, by);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// playlist metadata fields settable via setMetadata
|
|
54
|
-
type MetadataFields = Partial<
|
|
55
|
-
Pick<
|
|
56
|
-
PlaylistDoc,
|
|
57
|
-
| "title"
|
|
58
|
-
| "description"
|
|
59
|
-
| "bgFilterEnabled"
|
|
60
|
-
| "bgFilterBlur"
|
|
61
|
-
| "bgFilterContrast"
|
|
62
|
-
| "bgFilterBrightness"
|
|
63
|
-
| "coverFilterEnabled"
|
|
64
|
-
| "coverFilterBlur"
|
|
65
|
-
| "bgSize"
|
|
66
|
-
| "bgPosition"
|
|
67
|
-
| "bgRepeat"
|
|
68
|
-
>
|
|
69
|
-
>;
|
|
70
|
-
|
|
71
|
-
// update playlist-level metadata fields
|
|
72
|
-
export function setMetadata(
|
|
73
|
-
doc: PlaylistDoc,
|
|
74
|
-
fields: MetadataFields,
|
|
75
|
-
by?: string,
|
|
76
|
-
): void {
|
|
77
|
-
if (fields.title !== undefined) doc.title = fields.title;
|
|
78
|
-
if (fields.description !== undefined) doc.description = fields.description;
|
|
79
|
-
for (const key of [
|
|
80
|
-
"bgFilterEnabled",
|
|
81
|
-
"bgFilterBlur",
|
|
82
|
-
"bgFilterContrast",
|
|
83
|
-
"bgFilterBrightness",
|
|
84
|
-
"coverFilterEnabled",
|
|
85
|
-
"coverFilterBlur",
|
|
86
|
-
"bgSize",
|
|
87
|
-
"bgPosition",
|
|
88
|
-
"bgRepeat",
|
|
89
|
-
] as const) {
|
|
90
|
-
if (fields[key] !== undefined) {
|
|
91
|
-
// assignment is type-safe per key; typescript can't narrow the loop var
|
|
92
|
-
(doc as Record<string, unknown>)[key] = fields[key];
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
bumpModified(doc, by);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// add an image ref to the playlist or to a specific song.
|
|
99
|
-
// if ref.isPrimary is true, clears isPrimary on all sibling images first.
|
|
100
|
-
export function addImage(
|
|
101
|
-
doc: PlaylistDoc,
|
|
102
|
-
ref: ImageRef,
|
|
103
|
-
target?: { songId?: string },
|
|
104
|
-
by?: string,
|
|
105
|
-
): void {
|
|
106
|
-
const images =
|
|
107
|
-
target?.songId != null
|
|
108
|
-
? doc.songs[target.songId]?.images
|
|
109
|
-
: doc.images;
|
|
110
|
-
|
|
111
|
-
if (!images) return;
|
|
112
|
-
|
|
113
|
-
if (ref.isPrimary) {
|
|
114
|
-
for (const img of images) {
|
|
115
|
-
img.isPrimary = false;
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
images.push(ref);
|
|
119
|
-
bumpModified(doc, by);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// set the primary image by blobId, clearing isPrimary on all others.
|
|
123
|
-
// operates on playlist images or a specific song's images.
|
|
124
|
-
export function setPrimaryImage(
|
|
125
|
-
doc: PlaylistDoc,
|
|
126
|
-
blobId: string,
|
|
127
|
-
target?: { songId?: string },
|
|
128
|
-
by?: string,
|
|
129
|
-
): void {
|
|
130
|
-
const images =
|
|
131
|
-
target?.songId != null
|
|
132
|
-
? doc.songs[target.songId]?.images
|
|
133
|
-
: doc.images;
|
|
134
|
-
|
|
135
|
-
if (!images) return;
|
|
136
|
-
|
|
137
|
-
for (const img of images) {
|
|
138
|
-
img.isPrimary = img.blobId === blobId;
|
|
139
|
-
}
|
|
140
|
-
bumpModified(doc, by);
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// record a peer joining this doc. idempotent - no-op if peer is already present.
|
|
144
|
-
// does not bump lastModified (peer bookkeeping is not a content edit).
|
|
145
|
-
export function addPeer(doc: PlaylistDoc, nodeId: string): void {
|
|
146
|
-
if (doc.peers[nodeId]) return;
|
|
147
|
-
doc.peers[nodeId] = {
|
|
148
|
-
nodeId,
|
|
149
|
-
joinedAt: nowIso(),
|
|
150
|
-
};
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// update lastSeenAt for an existing peer entry.
|
|
154
|
-
// does not bump lastModified (ephemeral presence, not a content edit).
|
|
155
|
-
export function stampLastSeen(doc: PlaylistDoc, nodeId: string): void {
|
|
156
|
-
const peer = doc.peers[nodeId];
|
|
157
|
-
if (peer) {
|
|
158
|
-
peer.lastSeenAt = nowIso();
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
// set or update the access role for a node in the acl
|
|
163
|
-
export function setAclRole(
|
|
164
|
-
doc: PlaylistDoc,
|
|
165
|
-
nodeId: string,
|
|
166
|
-
role: "owner" | "editor" | "viewer",
|
|
167
|
-
by?: string,
|
|
168
|
-
): void {
|
|
169
|
-
if (!doc.acl) {
|
|
170
|
-
doc.acl = {};
|
|
171
|
-
}
|
|
172
|
-
doc.acl[nodeId] = { role };
|
|
173
|
-
bumpModified(doc, by);
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
// mark this playlist as deleted (tombstone).
|
|
177
|
-
// the doc remains in the repo; consumers check deleted before rendering.
|
|
178
|
-
export function tombstone(doc: PlaylistDoc, by?: string): void {
|
|
179
|
-
doc.deleted = true;
|
|
180
|
-
bumpModified(doc, by);
|
|
181
|
-
}
|
|
@@ -1,222 +0,0 @@
|
|
|
1
|
-
// freqhole-playlistz/1 discovery and knock protocol.
|
|
2
|
-
// zod-validated message envelopes + BiStream encode/decode helpers.
|
|
3
|
-
// no midden or automerge imports.
|
|
4
|
-
import * as z from "zod";
|
|
5
|
-
|
|
6
|
-
// ALPN strings used when registering iroh endpoints
|
|
7
|
-
export const PLAYLISTZ_ALPN = "freqhole-playlistz/1";
|
|
8
|
-
export const AUTOMERGE_ALPN = "iroh/automerge-repo/1";
|
|
9
|
-
|
|
10
|
-
// ---- message schemas ----
|
|
11
|
-
|
|
12
|
-
const HelloSchema = z.object({
|
|
13
|
-
v: z.literal(1),
|
|
14
|
-
type: z.literal("hello"),
|
|
15
|
-
nodeId: z.string(),
|
|
16
|
-
name: z.string().optional(),
|
|
17
|
-
avatarBlobId: z.string().optional(),
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
const HelloOkSchema = z.object({
|
|
21
|
-
v: z.literal(1),
|
|
22
|
-
type: z.literal("hello_ok"),
|
|
23
|
-
nodeId: z.string(),
|
|
24
|
-
name: z.string().optional(),
|
|
25
|
-
avatarDataUrl: z.string().optional(),
|
|
26
|
-
public: z.boolean(),
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
const ListPlaylistsSchema = z.object({
|
|
30
|
-
v: z.literal(1),
|
|
31
|
-
type: z.literal("list_playlists"),
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
const PlaylistItemSchema = z.object({
|
|
35
|
-
docId: z.string(),
|
|
36
|
-
title: z.string(),
|
|
37
|
-
songCount: z.number(),
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
const PlaylistsSchema = z.object({
|
|
41
|
-
v: z.literal(1),
|
|
42
|
-
type: z.literal("playlists"),
|
|
43
|
-
items: z.array(PlaylistItemSchema),
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
const KnockSchema = z.object({
|
|
47
|
-
v: z.literal(1),
|
|
48
|
-
type: z.literal("knock"),
|
|
49
|
-
nodeId: z.string(),
|
|
50
|
-
name: z.string().optional(),
|
|
51
|
-
message: z.string().optional(),
|
|
52
|
-
knockType: z.enum(["browse", "doc_access"]).optional(), // default: "browse"
|
|
53
|
-
docId: z.string().optional(), // present when knockType is "doc_access"
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
const KnockStatusSchema = z.object({
|
|
57
|
-
v: z.literal(1),
|
|
58
|
-
type: z.literal("knock_status"),
|
|
59
|
-
status: z.union([
|
|
60
|
-
z.literal("pending"),
|
|
61
|
-
z.literal("accepted"),
|
|
62
|
-
z.literal("denied"),
|
|
63
|
-
]),
|
|
64
|
-
grantedDocIds: z.array(z.string()).optional(),
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
// ask a peer to make a blob available for iroh-blobs verified download.
|
|
68
|
-
// sha256 is the blob store key carried in playlist docs.
|
|
69
|
-
const BlobRequestSchema = z.object({
|
|
70
|
-
v: z.literal(1),
|
|
71
|
-
type: z.literal("blob_request"),
|
|
72
|
-
sha256: z.string(),
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
// reply: the peer imported the blob into its iroh-blobs store.
|
|
76
|
-
// blake3 + size are what download_verified_streaming needs.
|
|
77
|
-
const BlobReadySchema = z.object({
|
|
78
|
-
v: z.literal(1),
|
|
79
|
-
type: z.literal("blob_ready"),
|
|
80
|
-
sha256: z.string(),
|
|
81
|
-
blake3: z.string(),
|
|
82
|
-
size: z.number(),
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
const ErrorSchema = z.object({
|
|
86
|
-
v: z.literal(1),
|
|
87
|
-
type: z.literal("error"),
|
|
88
|
-
code: z.string(),
|
|
89
|
-
message: z.string(),
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
// proactive acceptance notification: owner opens a stream to the peer and
|
|
93
|
-
// sends this after accepting their knock, so the peer doesn't have to poll.
|
|
94
|
-
const KnockNotifySchema = z.object({
|
|
95
|
-
v: z.literal(1),
|
|
96
|
-
type: z.literal("knock_notify"),
|
|
97
|
-
status: z.literal("accepted"),
|
|
98
|
-
docIds: z.array(z.string()),
|
|
99
|
-
ownerNodeId: z.string(),
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
// proactive identity update: a peer broadcasts their current name/avatar
|
|
103
|
-
// to all peers they have established connections with.
|
|
104
|
-
const IdentityUpdateSchema = z.object({
|
|
105
|
-
v: z.literal(1),
|
|
106
|
-
type: z.literal("identity_update"),
|
|
107
|
-
name: z.string().optional(),
|
|
108
|
-
avatarDataUrl: z.string().optional(),
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
// discriminated union of all protocol messages
|
|
112
|
-
export const MessageSchema = z.discriminatedUnion("type", [
|
|
113
|
-
HelloSchema,
|
|
114
|
-
HelloOkSchema,
|
|
115
|
-
ListPlaylistsSchema,
|
|
116
|
-
PlaylistsSchema,
|
|
117
|
-
KnockSchema,
|
|
118
|
-
KnockStatusSchema,
|
|
119
|
-
BlobRequestSchema,
|
|
120
|
-
BlobReadySchema,
|
|
121
|
-
ErrorSchema,
|
|
122
|
-
KnockNotifySchema,
|
|
123
|
-
IdentityUpdateSchema,
|
|
124
|
-
]);
|
|
125
|
-
export type Message = z.infer<typeof MessageSchema>;
|
|
126
|
-
|
|
127
|
-
// convenience inferred types per message kind
|
|
128
|
-
export type HelloMessage = z.infer<typeof HelloSchema>;
|
|
129
|
-
export type HelloOkMessage = z.infer<typeof HelloOkSchema>;
|
|
130
|
-
export type ListPlaylistsMessage = z.infer<typeof ListPlaylistsSchema>;
|
|
131
|
-
export type PlaylistsMessage = z.infer<typeof PlaylistsSchema>;
|
|
132
|
-
export type KnockMessage = z.infer<typeof KnockSchema>;
|
|
133
|
-
export type KnockStatusMessage = z.infer<typeof KnockStatusSchema>;
|
|
134
|
-
export type BlobRequestMessage = z.infer<typeof BlobRequestSchema>;
|
|
135
|
-
export type BlobReadyMessage = z.infer<typeof BlobReadySchema>;
|
|
136
|
-
export type ErrorMessage = z.infer<typeof ErrorSchema>;
|
|
137
|
-
export type KnockNotifyMessage = z.infer<typeof KnockNotifySchema>;
|
|
138
|
-
|
|
139
|
-
// ---- BiStream structural interface ----
|
|
140
|
-
// matches the shape provided by midden without importing it
|
|
141
|
-
|
|
142
|
-
export interface BiStreamLike {
|
|
143
|
-
write_message(data: Uint8Array): Promise<void>;
|
|
144
|
-
read_message(): Promise<Uint8Array | null>;
|
|
145
|
-
close(): void;
|
|
146
|
-
peer_node_id(): string;
|
|
147
|
-
alpn(): string;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// ---- codec ----
|
|
151
|
-
|
|
152
|
-
// error thrown when decoding an invalid or unrecognized message
|
|
153
|
-
export class ProtocolError extends Error {
|
|
154
|
-
readonly code: string;
|
|
155
|
-
|
|
156
|
-
constructor(code: string, message: string) {
|
|
157
|
-
super(message);
|
|
158
|
-
this.name = "ProtocolError";
|
|
159
|
-
this.code = code;
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
const encoder = new TextEncoder();
|
|
164
|
-
const decoder = new TextDecoder();
|
|
165
|
-
|
|
166
|
-
// serialize a message to utf-8 JSON bytes
|
|
167
|
-
export function encodeMessage(msg: Message): Uint8Array {
|
|
168
|
-
return encoder.encode(JSON.stringify(msg));
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
// parse utf-8 JSON bytes back to a Message.
|
|
172
|
-
// throws ProtocolError with a descriptive code on invalid input.
|
|
173
|
-
export function decodeMessage(bytes: Uint8Array): Message {
|
|
174
|
-
let json: string;
|
|
175
|
-
try {
|
|
176
|
-
json = decoder.decode(bytes);
|
|
177
|
-
} catch (err) {
|
|
178
|
-
throw new ProtocolError(
|
|
179
|
-
"decode_error",
|
|
180
|
-
`failed to decode utf-8: ${err instanceof Error ? err.message : String(err)}`,
|
|
181
|
-
);
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
let raw: unknown;
|
|
185
|
-
try {
|
|
186
|
-
raw = JSON.parse(json);
|
|
187
|
-
} catch (err) {
|
|
188
|
-
throw new ProtocolError(
|
|
189
|
-
"parse_error",
|
|
190
|
-
`failed to parse json: ${err instanceof Error ? err.message : String(err)}`,
|
|
191
|
-
);
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
const result = MessageSchema.safeParse(raw);
|
|
195
|
-
if (!result.success) {
|
|
196
|
-
const firstIssue = result.error.issues[0];
|
|
197
|
-
throw new ProtocolError(
|
|
198
|
-
"schema_error",
|
|
199
|
-
`invalid message: ${firstIssue?.message ?? "unknown"}`,
|
|
200
|
-
);
|
|
201
|
-
}
|
|
202
|
-
return result.data;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
// write a message to a BiStream
|
|
206
|
-
export async function sendMessage(
|
|
207
|
-
stream: BiStreamLike,
|
|
208
|
-
msg: Message,
|
|
209
|
-
): Promise<void> {
|
|
210
|
-
await stream.write_message(encodeMessage(msg));
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
// read the next message from a BiStream.
|
|
214
|
-
// returns null on clean EOF (read_message returned null).
|
|
215
|
-
// throws ProtocolError on invalid data.
|
|
216
|
-
export async function readMessage(
|
|
217
|
-
stream: BiStreamLike,
|
|
218
|
-
): Promise<Message | null> {
|
|
219
|
-
const bytes = await stream.read_message();
|
|
220
|
-
if (bytes === null) return null;
|
|
221
|
-
return decodeMessage(bytes);
|
|
222
|
-
}
|
package/src/playlistz/schema.ts
DELETED
|
@@ -1,138 +0,0 @@
|
|
|
1
|
-
// zod schemas for the playlistz automerge document model.
|
|
2
|
-
// plain objects + zod only - no automerge imports.
|
|
3
|
-
import * as z from "zod";
|
|
4
|
-
|
|
5
|
-
// the set of blob types a stored binary can represent
|
|
6
|
-
export const BlobKindSchema = z.union([
|
|
7
|
-
z.literal("original"),
|
|
8
|
-
z.literal("thumbnail"),
|
|
9
|
-
z.literal("waveform"),
|
|
10
|
-
z.literal("preview"),
|
|
11
|
-
]);
|
|
12
|
-
export type BlobKind = z.infer<typeof BlobKindSchema>;
|
|
13
|
-
|
|
14
|
-
// a reference to a binary stored in the blob store, keyed by sha256
|
|
15
|
-
export const ImageRefSchema = z.object({
|
|
16
|
-
blobId: z.string(),
|
|
17
|
-
isPrimary: z.boolean(),
|
|
18
|
-
blobType: BlobKindSchema,
|
|
19
|
-
});
|
|
20
|
-
export type ImageRef = z.infer<typeof ImageRefSchema>;
|
|
21
|
-
|
|
22
|
-
// a named url associated with a playlist or song
|
|
23
|
-
export const EntityUrlSchema = z.object({
|
|
24
|
-
id: z.string().optional(),
|
|
25
|
-
name: z.string().optional(),
|
|
26
|
-
url: z.string(),
|
|
27
|
-
});
|
|
28
|
-
export type EntityUrl = z.infer<typeof EntityUrlSchema>;
|
|
29
|
-
|
|
30
|
-
// a single song as stored in the playlist doc
|
|
31
|
-
export const SongEntrySchema = z.object({
|
|
32
|
-
id: z.string(),
|
|
33
|
-
title: z.string(),
|
|
34
|
-
artist: z.string(),
|
|
35
|
-
album: z.string(),
|
|
36
|
-
duration: z.number(),
|
|
37
|
-
mimeType: z.string(),
|
|
38
|
-
fileSize: z.number(),
|
|
39
|
-
sha256: z.string(),
|
|
40
|
-
blake3: z.string().optional(),
|
|
41
|
-
images: z.array(ImageRefSchema).default([]),
|
|
42
|
-
urls: z.array(EntityUrlSchema).default([]),
|
|
43
|
-
lyrics: z.string().optional(),
|
|
44
|
-
});
|
|
45
|
-
export type SongEntry = z.infer<typeof SongEntrySchema>;
|
|
46
|
-
|
|
47
|
-
// the top-level automerge document for a playlist.
|
|
48
|
-
// all fields have defaults so schema.parse({}) succeeds for graceful degradation.
|
|
49
|
-
export const PlaylistDocSchema = z.object({
|
|
50
|
-
version: z.literal(1).default(1),
|
|
51
|
-
title: z.string().default(""),
|
|
52
|
-
description: z.string().default(""),
|
|
53
|
-
createdAt: z.string().default(() => new Date().toISOString()),
|
|
54
|
-
lastModified: z.string().default(() => new Date().toISOString()),
|
|
55
|
-
lastModifiedBy: z.string().default(""),
|
|
56
|
-
images: z.array(ImageRefSchema).default([]),
|
|
57
|
-
urls: z.array(EntityUrlSchema).default([]),
|
|
58
|
-
songs: z.record(z.string(), SongEntrySchema).default({}),
|
|
59
|
-
order: z.array(z.string()).default([]),
|
|
60
|
-
peers: z
|
|
61
|
-
.record(
|
|
62
|
-
z.string(),
|
|
63
|
-
z.object({
|
|
64
|
-
nodeId: z.string(),
|
|
65
|
-
joinedAt: z.string(),
|
|
66
|
-
lastSeenAt: z.string().optional(),
|
|
67
|
-
}),
|
|
68
|
-
)
|
|
69
|
-
.default({}),
|
|
70
|
-
acl: z
|
|
71
|
-
.record(
|
|
72
|
-
z.string(),
|
|
73
|
-
z.object({
|
|
74
|
-
role: z.union([
|
|
75
|
-
z.literal("owner"),
|
|
76
|
-
z.literal("editor"),
|
|
77
|
-
z.literal("viewer"),
|
|
78
|
-
]),
|
|
79
|
-
}),
|
|
80
|
-
)
|
|
81
|
-
.optional(),
|
|
82
|
-
deleted: z.boolean().optional(),
|
|
83
|
-
// per-playlist sharing mode. unset = private (not shared).
|
|
84
|
-
sharingMode: z.union([z.literal("public"), z.literal("knock")]).optional(),
|
|
85
|
-
// display filter settings for the playlist background and cover
|
|
86
|
-
bgFilterEnabled: z.boolean().optional(),
|
|
87
|
-
bgFilterBlur: z.number().optional(),
|
|
88
|
-
bgFilterContrast: z.number().optional(),
|
|
89
|
-
bgFilterBrightness: z.number().optional(),
|
|
90
|
-
coverFilterEnabled: z.boolean().optional(),
|
|
91
|
-
coverFilterBlur: z.number().optional(),
|
|
92
|
-
// background image layout
|
|
93
|
-
bgSize: z.string().optional(), // css background-size, default: "cover"
|
|
94
|
-
bgPosition: z.string().optional(), // css background-position, default: "top"
|
|
95
|
-
bgRepeat: z.string().optional(), // css background-repeat, default: "no-repeat"
|
|
96
|
-
});
|
|
97
|
-
export type PlaylistDoc = z.infer<typeof PlaylistDocSchema>;
|
|
98
|
-
|
|
99
|
-
// recursively copy an object keeping only string-keyed enumerable props.
|
|
100
|
-
// automerge doc proxies carry symbol keys (_am_objectId, _am_datatype_) that
|
|
101
|
-
// zod records reject, so raw docs are plainified before parsing.
|
|
102
|
-
function plainify(value: unknown): unknown {
|
|
103
|
-
if (Array.isArray(value)) return value.map(plainify);
|
|
104
|
-
if (value && typeof value === "object") {
|
|
105
|
-
const out: Record<string, unknown> = {};
|
|
106
|
-
for (const key of Object.keys(value)) {
|
|
107
|
-
out[key] = plainify((value as Record<string, unknown>)[key]);
|
|
108
|
-
}
|
|
109
|
-
return out;
|
|
110
|
-
}
|
|
111
|
-
return value;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// parse raw unknown data into a PlaylistDoc.
|
|
115
|
-
// on failure, logs a warning and returns schema defaults (graceful degradation).
|
|
116
|
-
export function parsePlaylistDoc(raw: unknown): PlaylistDoc {
|
|
117
|
-
try {
|
|
118
|
-
return PlaylistDocSchema.parse(plainify(raw ?? {}));
|
|
119
|
-
} catch (err) {
|
|
120
|
-
console.warn(
|
|
121
|
-
"[playlistz:schema] parse failed - falling back to defaults. error:",
|
|
122
|
-
err instanceof z.ZodError ? err.issues : err,
|
|
123
|
-
"raw keys:",
|
|
124
|
-
raw && typeof raw === "object" ? Object.keys(raw) : "null",
|
|
125
|
-
);
|
|
126
|
-
return PlaylistDocSchema.parse({});
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// create an empty PlaylistDoc, optionally merged with caller-supplied fields.
|
|
131
|
-
export function emptyPlaylistDoc(init?: Partial<PlaylistDoc>): PlaylistDoc {
|
|
132
|
-
const now = new Date().toISOString();
|
|
133
|
-
return PlaylistDocSchema.parse({
|
|
134
|
-
createdAt: now,
|
|
135
|
-
lastModified: now,
|
|
136
|
-
...init,
|
|
137
|
-
});
|
|
138
|
-
}
|
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
// share token encoding/decoding for playlistz p2p sharing.
|
|
2
|
-
// versioned base64url payload, no padding.
|
|
3
|
-
// format: base64url({ v: 1, n: nodeId, d: docId, t?: titleHint })
|
|
4
|
-
// url fragment: #share/<token>
|
|
5
|
-
import * as z from "zod";
|
|
6
|
-
|
|
7
|
-
// v1 share payload: node id, doc id, optional title hint, optional mode hint
|
|
8
|
-
const SharePayloadV1Schema = z.object({
|
|
9
|
-
v: z.literal(1),
|
|
10
|
-
n: z.string().min(1),
|
|
11
|
-
d: z.string().min(1),
|
|
12
|
-
t: z.string().optional(),
|
|
13
|
-
m: z.enum(["public", "knock"]).optional(), // sharing mode at link-creation time
|
|
14
|
-
});
|
|
15
|
-
export type SharePayloadV1 = z.infer<typeof SharePayloadV1Schema>;
|
|
16
|
-
|
|
17
|
-
// convert standard base64 to base64url (no padding)
|
|
18
|
-
function toBase64Url(b64: string): string {
|
|
19
|
-
return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
// normalize base64url back to standard base64 for decoding
|
|
23
|
-
function fromBase64Url(b64url: string): string {
|
|
24
|
-
let s = b64url.replace(/-/g, "+").replace(/_/g, "/");
|
|
25
|
-
// re-add padding
|
|
26
|
-
while (s.length % 4 !== 0) {
|
|
27
|
-
s += "=";
|
|
28
|
-
}
|
|
29
|
-
return s;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
// encode a share payload to a base64url token (no padding)
|
|
33
|
-
export function encodeShareToken(payload: SharePayloadV1): string {
|
|
34
|
-
const json = JSON.stringify(payload);
|
|
35
|
-
const b64 = btoa(json);
|
|
36
|
-
return toBase64Url(b64);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
// decode a share token back to a SharePayloadV1.
|
|
40
|
-
// forgiving: strips whitespace, full urls, "#share/" and "share/" prefixes.
|
|
41
|
-
// accepts both base64 and base64url.
|
|
42
|
-
// returns null on anything invalid.
|
|
43
|
-
export function decodeShareToken(input: string): SharePayloadV1 | null {
|
|
44
|
-
try {
|
|
45
|
-
let raw = input.trim();
|
|
46
|
-
|
|
47
|
-
// strip full url up to and including the #share/ fragment
|
|
48
|
-
const hashIdx = raw.indexOf("#share/");
|
|
49
|
-
if (hashIdx !== -1) {
|
|
50
|
-
raw = raw.slice(hashIdx + 7);
|
|
51
|
-
} else if (raw.startsWith("share/")) {
|
|
52
|
-
raw = raw.slice(6);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// strip any trailing fragment or query that may have been appended
|
|
56
|
-
const ampIdx = raw.indexOf("&");
|
|
57
|
-
if (ampIdx !== -1) raw = raw.slice(0, ampIdx);
|
|
58
|
-
|
|
59
|
-
if (!raw) return null;
|
|
60
|
-
|
|
61
|
-
const b64 = fromBase64Url(raw);
|
|
62
|
-
const json = atob(b64);
|
|
63
|
-
const parsed: unknown = JSON.parse(json);
|
|
64
|
-
|
|
65
|
-
const result = SharePayloadV1Schema.safeParse(parsed);
|
|
66
|
-
if (!result.success) return null;
|
|
67
|
-
return result.data;
|
|
68
|
-
} catch {
|
|
69
|
-
return null;
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// build a url fragment for embedding in window.location.hash
|
|
74
|
-
export function shareFragment(payload: SharePayloadV1): string {
|
|
75
|
-
return `#share/${encodeShareToken(payload)}`;
|
|
76
|
-
}
|