Experiment to rebuild Diffuse using web applets.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor: native-fs input

+318 -237
+16 -237
src/pages/input/native-fs/_applet.astro
··· 16 16 </main> 17 17 18 18 <script> 19 - import { computed, effect, type Signal, signal } from "spellcaster"; 20 - import { repeat, tags, text } from "spellcaster/hyperscript.js"; 21 - import { type FileSystemDirectoryHandle, showDirectoryPicker } from "native-file-system-adapter"; 22 - import * as IDB from "idb-keyval"; 23 - import * as URI from "uri-js"; 24 - import QS from "query-string"; 25 - 19 + import type { Actions } from "@scripts/input/native-fs/worker"; 26 20 import type { Track } from "@applets/core/types.d.ts"; 27 - import { isAudioFile } from "@scripts/input/common"; 28 21 import { register } from "@scripts/applet/common"; 29 - 30 - import manifest from "./_manifest.json"; 31 - 32 - type Handles = Record<string, FileSystemDirectoryHandle>; 33 - 34 - // TODO: Add ability to list cached tracks from other devices (ie. unknown handle ids) 22 + import { endpoint, inIframe } from "@scripts/common"; 23 + import * as Mounting from "@scripts/input/native-fs/mounting"; 35 24 36 25 //////////////////////////////////////////// 37 26 // SETUP 38 27 //////////////////////////////////////////// 39 - const IDB_PREFIX = "@applets/input/native-fs"; 40 - const IDB_HANDLES = `${IDB_PREFIX}/handles`; 41 - const SCHEME = manifest.input_properties.scheme; 28 + const worker = endpoint<Actions>( 29 + new Worker("../../../scripts/input/native-fs/worker", { type: "module" }), 30 + ); 42 31 43 32 // Register applet 44 33 const context = register(); 45 34 46 35 //////////////////////////////////////////// 47 - // UI 48 - //////////////////////////////////////////// 49 - const [mounts, setMounts] = signal(await fetchHandlesList()); 50 - 51 - // Mount button 52 - document.getElementById("mount")?.addEventListener("click", () => mount()); 53 - 54 - // Directories 55 - const dirList = computed(() => { 56 - return new Map( 57 - mounts().map((mount) => { 58 - return [mount.id, mount]; 59 - }), 60 - ); 61 - }); 62 - 63 - const Item = (signal: Signal<{ id: string; handle: FileSystemDirectoryHandle }>) => { 64 - const { id, handle } = signal(); 65 - 66 - return tags.li({}, [ 67 - tags.span( 68 - { onclick: () => unmount(id), style: "cursor: pointer;", title: "Click/tap to delete" }, 69 - text(handle.name), 70 - ), 71 - ]); 72 - }; 73 - 74 - const Directories = computed(() => { 75 - if (mounts().length === 0) { 76 - return tags.p({ id: "directories" }, [ 77 - tags.small({}, [ 78 - tags.em({}, text("No audio added yet, click the button below to add some.")), 79 - ]), 80 - ]); 81 - } 82 - 83 - return tags.ul({ id: "directories" }, repeat(dirList, Item)); 84 - }); 85 - 86 - // Add to DOM 87 - effect(() => { 88 - document.getElementById("directories")?.replaceWith(Directories()); 89 - }); 90 - 91 - //////////////////////////////////////////// 92 36 // ACTIONS 93 37 //////////////////////////////////////////// 94 38 const consult = async (fileUriOrScheme: string) => { 95 - if (!isSupported()) { 96 - return { supported: false, reason: "File System Access API is not supported" }; 97 - } 98 - 99 - if (!fileUriOrScheme.includes(":")) { 100 - if (fileUriOrScheme !== SCHEME) return { supported: false, reason: "Scheme does not match" }; 101 - return { supported: true }; 102 - } 103 - 104 - const handles = await fetchHandles(); 105 - const uri = URI.parse(fileUriOrScheme); 106 - if (uri.scheme !== SCHEME) return { supported: false, reason: "Scheme does not match" }; 107 - return { supported: true, consultation: uri.host && !!handles[uri.host] }; 39 + return await worker.call.consult(fileUriOrScheme); 108 40 }; 109 41 110 - const contextualize = async (cachedTracks: Track[]) => {}; 42 + const contextualize = async (cachedTracks: Track[]) => { 43 + return await worker.call.contextualize(cachedTracks); 44 + }; 111 45 112 46 const list = async (cachedTracks: Track[] = []) => { 113 - if (!isSupported()) { 114 - return cachedTracks; 115 - } 116 - 117 - // Continue if supported 118 - const handles = await fetchHandlesList(); 119 - 120 - // Recursive listing of all tracks of available handles 121 - const processed: Track[][] = await Promise.all( 122 - handles.map(({ id, handle }) => { 123 - return recursiveList(handle, id, []); 124 - }), 125 - ); 126 - 127 - // Group tracks by handle id & index by track uri 128 - const cache = cachedTracks.reduce( 129 - (acc: Record<string, Record<string, Track>>, track: Track) => { 130 - const handleId = trackHandleId(track); 131 - if (!handleId) return acc; 132 - 133 - return { ...acc, [handleId]: { ...(acc[handleId] || {}), [track.uri]: track } }; 134 - }, 135 - {}, 136 - ); 137 - 138 - // Replace indexes in groups of which we have the handle. 139 - // Keeping around tracks with handles we don't have access to, 140 - // and removing tracks that are no longer available (for handles we do have access to). 141 - const groups = processed.flat(1).reduce( 142 - (acc, track) => { 143 - const handleId = trackHandleId(track); 144 - if (!handleId) throw new Error("New tracks are missing a handle id!"); 145 - 146 - return { ...acc, [handleId]: { ...acc[handleId], [track.uri]: track } }; 147 - }, 148 - handles.reduce((acc: Record<string, Record<string, Track>>, handle) => { 149 - return { ...acc, [handle.id]: {} }; 150 - }, cache), 151 - ); 152 - 153 - // Transform in track list and sort by uri 154 - const data = Object.values(groups) 155 - .map((tracks) => Object.values(tracks)) 156 - .flat(1) 157 - .sort((a: any, b: any) => { 158 - if (a.uri < b.uri) return -1; 159 - if (a.uri > b.uri) return 1; 160 - return 0; 161 - }); 162 - 163 - // Fin 164 - return data; 47 + return await worker.call.list(cachedTracks); 165 48 }; 166 49 167 50 const resolve = async (args: { uri: string }) => { 168 - const fileUri = args.uri; 169 - 170 - if (!isSupported()) { 171 - return undefined; 172 - } 173 - 174 - const uri = URI.parse(fileUri); 175 - if (uri.scheme !== SCHEME) return undefined; 176 - if (!uri.host || !uri.path) return undefined; 177 - 178 - const handles = await fetchHandles(); 179 - const handle = handles[uri.host]; 180 - if (!handle) return undefined; 181 - 182 - const path = URI.unescapeComponent(uri.path); 183 - const parts = (path.startsWith("/") ? path.slice(1) : path).split("/"); 184 - const filename = parts[parts.length - 1]; 185 - 186 - const dirHandle = await parts 187 - .slice(0, -1) 188 - .reduce( 189 - async ( 190 - acc: Promise<FileSystemDirectoryHandle>, 191 - part: string, 192 - ): Promise<FileSystemDirectoryHandle> => { 193 - const h = await acc; 194 - return await h.getDirectoryHandle(part); 195 - }, 196 - Promise.resolve(handle), 197 - ); 198 - 199 - const fileHandle = await dirHandle.getFileHandle(filename); 200 - const file = await fileHandle.getFile(); 201 - const url = URL.createObjectURL(file); 202 - 203 - return { expiresAt: Infinity, url }; 51 + return await worker.call.resolve(args); 204 52 }; 205 53 206 54 const mount = async () => { 207 - await showDirectoryPicker() 208 - .then(async (handle) => { 209 - const existingHandles = await fetchHandles(); 210 - const id = crypto.randomUUID(); 211 - 212 - await handle.requestPermission({ mode: "read" }); 213 - await IDB.set(IDB_HANDLES, { ...existingHandles, [id]: handle }); 214 - setMounts(await fetchHandlesList()); 215 - }) 216 - .catch(() => {}); 55 + return Mounting.mount(); 217 56 }; 218 57 219 58 const unmount = async (handleId: string) => { 220 - const handles = await fetchHandles(); 221 - delete handles[handleId]; 222 - await IDB.set(IDB_HANDLES, { ...handles }); 223 - setMounts(await fetchHandlesList()); 59 + return Mounting.unmount(handleId); 224 60 }; 225 61 226 62 context.setActionHandler("consult", consult); ··· 231 67 context.setActionHandler("unmount", unmount); 232 68 233 69 //////////////////////////////////////////// 234 - // 🛠️ 70 + // UI 235 71 //////////////////////////////////////////// 236 - async function fetchHandles(): Promise<Handles> { 237 - return (await IDB.get(IDB_HANDLES)) ?? {}; 238 - } 239 - 240 - async function fetchHandlesList() { 241 - return Object.entries(await fetchHandles()).map(([id, handle]) => { 242 - return { id, handle }; 243 - }); 244 - } 245 - 246 - function isSupported() { 247 - return !!(globalThis as any).showDirectoryPicker; 248 - } 249 - 250 - function trackCid(track: Track): string | undefined { 251 - const a = URI.parse(track.uri); 252 - const cid = a.query ? QS.parse(a.query).cid || undefined : undefined; 253 - return Array.isArray(cid) && cid[0] ? cid[0] : typeof cid === "string" ? cid : undefined; 254 - } 255 - 256 - function trackHandleId(track: Track): string | undefined { 257 - const a = URI.parse(track.uri); 258 - return a.host; 259 - } 260 - 261 - async function recursiveList( 262 - dir: FileSystemDirectoryHandle, 263 - rootHandleId: string, 264 - path: string[], 265 - ): Promise<Track[]> { 266 - const tracks: Track[] = []; 267 - 268 - for await (const item of dir.values()) { 269 - if (item.kind === "file" && isAudioFile(item.name)) { 270 - const uri = URI.serialize({ 271 - scheme: SCHEME, 272 - host: rootHandleId, 273 - path: `${path.length ? "/" + path.join("/") : ""}/${item.name}`, 274 - }); 275 - 276 - const track: Track = { 277 - id: crypto.randomUUID(), 278 - uri, 279 - }; 280 - 281 - tracks.push(track); 282 - } else if (item.kind === "directory") { 283 - const nestedItems = await recursiveList(item as FileSystemDirectoryHandle, rootHandleId, [ 284 - ...path, 285 - item.name, 286 - ]); 287 - 288 - tracks.push(...nestedItems); 289 - } 290 - } 291 - 292 - return tracks; 293 - } 72 + const ui = inIframe() ? undefined : await import("@scripts/input/native-fs/ui"); 294 73 </script>
+4
src/pages/orchestrator/input-cache/_applet.astro
··· 43 43 const output = await configurator.output; 44 44 45 45 const cachedTracks = output.data.tracks.collection; 46 + 47 + // TODO: Is there a better time to do this? 48 + // Goal = figure out servers/buckets/context used, 49 + // which are then used in the following `list` action. 46 50 await input.sendAction("contextualize", cachedTracks, { 47 51 timeoutDuration: 60000 * 5, 48 52 });
+71
src/scripts/input/native-fs/common.ts
··· 1 + import { type FileSystemDirectoryHandle } from "native-file-system-adapter"; 2 + import * as IDB from "idb-keyval"; 3 + import * as URI from "uri-js"; 4 + import QS from "query-string"; 5 + 6 + import type { Track } from "@applets/core/types.d.ts"; 7 + import type { Handles } from "./types"; 8 + import { isAudioFile } from "@scripts/input/common"; 9 + import { IDB_HANDLES, SCHEME } from "./constants"; 10 + 11 + //////////////////////////////////////////// 12 + // 🛠️ 13 + //////////////////////////////////////////// 14 + export async function fetchHandles(): Promise<Handles> { 15 + return (await IDB.get(IDB_HANDLES)) ?? {}; 16 + } 17 + 18 + export async function fetchHandlesList() { 19 + return Object.entries(await fetchHandles()).map(([id, handle]) => { 20 + return { id, handle }; 21 + }); 22 + } 23 + 24 + export function isSupported() { 25 + return !!(globalThis as any).showDirectoryPicker; 26 + } 27 + 28 + export function trackCid(track: Track): string | undefined { 29 + const a = URI.parse(track.uri); 30 + const cid = a.query ? QS.parse(a.query).cid || undefined : undefined; 31 + return Array.isArray(cid) && cid[0] ? cid[0] : typeof cid === "string" ? cid : undefined; 32 + } 33 + 34 + export function trackHandleId(track: Track): string | undefined { 35 + const a = URI.parse(track.uri); 36 + return a.host; 37 + } 38 + 39 + export async function recursiveList( 40 + dir: FileSystemDirectoryHandle, 41 + rootHandleId: string, 42 + path: string[], 43 + ): Promise<Track[]> { 44 + const tracks: Track[] = []; 45 + 46 + for await (const item of dir.values()) { 47 + if (item.kind === "file" && isAudioFile(item.name)) { 48 + const uri = URI.serialize({ 49 + scheme: SCHEME, 50 + host: rootHandleId, 51 + path: `${path.length ? "/" + path.join("/") : ""}/${item.name}`, 52 + }); 53 + 54 + const track: Track = { 55 + id: crypto.randomUUID(), 56 + uri, 57 + }; 58 + 59 + tracks.push(track); 60 + } else if (item.kind === "directory") { 61 + const nestedItems = await recursiveList(item as FileSystemDirectoryHandle, rootHandleId, [ 62 + ...path, 63 + item.name, 64 + ]); 65 + 66 + tracks.push(...nestedItems); 67 + } 68 + } 69 + 70 + return tracks; 71 + }
+5
src/scripts/input/native-fs/constants.ts
··· 1 + import manifest from "../../../pages/input/native-fs/_manifest.json"; 2 + 3 + export const IDB_PREFIX = "@applets/input/native-fs"; 4 + export const IDB_HANDLES = `${IDB_PREFIX}/handles`; 5 + export const SCHEME = manifest.input_properties.scheme;
+33
src/scripts/input/native-fs/mounting.ts
··· 1 + import { signal } from "spellcaster"; 2 + import * as IDB from "idb-keyval"; 3 + 4 + import { fetchHandles, fetchHandlesList } from "./common"; 5 + import { IDB_HANDLES } from "./constants"; 6 + 7 + //////////////////////////////////////////// 8 + // SIGNALS 9 + //////////////////////////////////////////// 10 + export const [mounts, setMounts] = signal(await fetchHandlesList()); 11 + 12 + //////////////////////////////////////////// 13 + // ACTIONS 14 + //////////////////////////////////////////// 15 + export const mount = async () => { 16 + await showDirectoryPicker() 17 + .then(async (handle) => { 18 + const existingHandles = await fetchHandles(); 19 + const id = crypto.randomUUID(); 20 + 21 + await handle.requestPermission({ mode: "read" }); 22 + await IDB.set(IDB_HANDLES, { ...existingHandles, [id]: handle }); 23 + setMounts(await fetchHandlesList()); 24 + }) 25 + .catch(() => {}); 26 + }; 27 + 28 + export const unmount = async (handleId: string) => { 29 + const handles = await fetchHandles(); 30 + delete handles[handleId]; 31 + await IDB.set(IDB_HANDLES, { ...handles }); 32 + setMounts(await fetchHandlesList()); 33 + };
+3
src/scripts/input/native-fs/types.d.ts
··· 1 + import type { FileSystemDirectoryHandle } from "native-file-system-adapter"; 2 + 3 + export type Handles = Record<string, FileSystemDirectoryHandle>;
+50
src/scripts/input/native-fs/ui.ts
··· 1 + import { computed, effect, type Signal } from "spellcaster"; 2 + import { repeat, tags, text } from "spellcaster/hyperscript.js"; 3 + import { type FileSystemDirectoryHandle } from "native-file-system-adapter"; 4 + 5 + import { IDB_HANDLES } from "./constants"; 6 + import { mount, mounts, unmount } from "./mounting"; 7 + 8 + //////////////////////////////////////////// 9 + // SIGNALS 10 + //////////////////////////////////////////// 11 + 12 + // Mount button 13 + document.getElementById("mount")?.addEventListener("click", () => mount()); 14 + 15 + // Directories 16 + const dirList = computed(() => { 17 + return new Map( 18 + mounts().map((mount) => { 19 + return [mount.id, mount]; 20 + }), 21 + ); 22 + }); 23 + 24 + const Item = (signal: Signal<{ id: string; handle: FileSystemDirectoryHandle }>) => { 25 + const { id, handle } = signal(); 26 + 27 + return tags.li({}, [ 28 + tags.span( 29 + { onclick: () => unmount(id), style: "cursor: pointer;", title: "Click/tap to delete" }, 30 + text(handle.name), 31 + ), 32 + ]); 33 + }; 34 + 35 + const Directories = computed(() => { 36 + if (mounts().length === 0) { 37 + return tags.p({ id: "directories" }, [ 38 + tags.small({}, [ 39 + tags.em({}, text("No audio added yet, click the button below to add some.")), 40 + ]), 41 + ]); 42 + } 43 + 44 + return tags.ul({ id: "directories" }, repeat(dirList, Item)); 45 + }); 46 + 47 + // Add to DOM 48 + effect(() => { 49 + document.getElementById("directories")?.replaceWith(Directories()); 50 + });
+136
src/scripts/input/native-fs/worker.ts
··· 1 + import { type FileSystemDirectoryHandle } from "native-file-system-adapter"; 2 + import * as URI from "uri-js"; 3 + 4 + import type { Track } from "@applets/core/types.d.ts"; 5 + import { SCHEME } from "./constants"; 6 + import { 7 + fetchHandles, 8 + fetchHandlesList, 9 + isSupported, 10 + recursiveList, 11 + trackHandleId, 12 + } from "./common"; 13 + import { expose } from "@scripts/common"; 14 + 15 + //////////////////////////////////////////// 16 + // ACTIONS 17 + //////////////////////////////////////////// 18 + const actions = expose({ 19 + consult, 20 + contextualize, 21 + list, 22 + resolve, 23 + }); 24 + 25 + export type Actions = typeof actions; 26 + 27 + // Actions 28 + 29 + export async function consult(fileUriOrScheme: string) { 30 + if (!isSupported()) { 31 + return { supported: false, reason: "File System Access API is not supported" }; 32 + } 33 + 34 + if (!fileUriOrScheme.includes(":")) { 35 + if (fileUriOrScheme !== SCHEME) return { supported: false, reason: "Scheme does not match" }; 36 + return { supported: true }; 37 + } 38 + 39 + const handles = await fetchHandles(); 40 + const uri = URI.parse(fileUriOrScheme); 41 + if (uri.scheme !== SCHEME) return { supported: false, reason: "Scheme does not match" }; 42 + return { supported: true, consultation: uri.host && !!handles[uri.host] }; 43 + } 44 + 45 + export async function contextualize(cachedTracks: Track[]) {} 46 + 47 + export async function list(cachedTracks: Track[] = []) { 48 + if (!isSupported()) { 49 + return cachedTracks; 50 + } 51 + 52 + // Continue if supported 53 + const handles = await fetchHandlesList(); 54 + 55 + // Recursive listing of all tracks of available handles 56 + const processed: Track[][] = await Promise.all( 57 + handles.map(({ id, handle }) => { 58 + return recursiveList(handle, id, []); 59 + }), 60 + ); 61 + 62 + // Group tracks by handle id & index by track uri 63 + const cache = cachedTracks.reduce((acc: Record<string, Record<string, Track>>, track: Track) => { 64 + const handleId = trackHandleId(track); 65 + if (!handleId) return acc; 66 + 67 + return { ...acc, [handleId]: { ...(acc[handleId] || {}), [track.uri]: track } }; 68 + }, {}); 69 + 70 + // Replace indexes in groups of which we have the handle. 71 + // Keeping around tracks with handles we don't have access to, 72 + // and removing tracks that are no longer available (for handles we do have access to). 73 + const groups = processed.flat(1).reduce( 74 + (acc, track) => { 75 + const handleId = trackHandleId(track); 76 + if (!handleId) throw new Error("New tracks are missing a handle id!"); 77 + 78 + return { ...acc, [handleId]: { ...acc[handleId], [track.uri]: track } }; 79 + }, 80 + handles.reduce((acc: Record<string, Record<string, Track>>, handle) => { 81 + return { ...acc, [handle.id]: {} }; 82 + }, cache), 83 + ); 84 + 85 + // Transform in track list and sort by uri 86 + const data = Object.values(groups) 87 + .map((tracks) => Object.values(tracks)) 88 + .flat(1) 89 + .sort((a: any, b: any) => { 90 + if (a.uri < b.uri) return -1; 91 + if (a.uri > b.uri) return 1; 92 + return 0; 93 + }); 94 + 95 + // Fin 96 + return data; 97 + } 98 + 99 + export async function resolve(args: { uri: string }) { 100 + const fileUri = args.uri; 101 + 102 + if (!isSupported()) { 103 + return undefined; 104 + } 105 + 106 + const uri = URI.parse(fileUri); 107 + if (uri.scheme !== SCHEME) return undefined; 108 + if (!uri.host || !uri.path) return undefined; 109 + 110 + const handles = await fetchHandles(); 111 + const handle = handles[uri.host]; 112 + if (!handle) return undefined; 113 + 114 + const path = URI.unescapeComponent(uri.path); 115 + const parts = (path.startsWith("/") ? path.slice(1) : path).split("/"); 116 + const filename = parts[parts.length - 1]; 117 + 118 + const dirHandle = await parts 119 + .slice(0, -1) 120 + .reduce( 121 + async ( 122 + acc: Promise<FileSystemDirectoryHandle>, 123 + part: string, 124 + ): Promise<FileSystemDirectoryHandle> => { 125 + const h = await acc; 126 + return await h.getDirectoryHandle(part); 127 + }, 128 + Promise.resolve(handle), 129 + ); 130 + 131 + const fileHandle = await dirHandle.getFileHandle(filename); 132 + const file = await fileHandle.getFile(); 133 + const url = URL.createObjectURL(file); 134 + 135 + return { expiresAt: Infinity, url }; 136 + }