|
|
@@ -0,0 +1,171 @@
|
|
|
+import type { AsyncStorage } from "@solid-primitives/storage"
|
|
|
+
|
|
|
+export type BlobReference = { id: string; url: string }
|
|
|
+
|
|
|
+type Driver = {
|
|
|
+ get(key: string): Promise<string | null>
|
|
|
+ set(key: string, value: string): Promise<void>
|
|
|
+ remove(key: string): Promise<void>
|
|
|
+ putBlob(blob: Blob): Promise<string>
|
|
|
+ getBlob(id: string): Promise<Blob | null>
|
|
|
+}
|
|
|
+
|
|
|
+export type DraftStore = AsyncStorage & { putBlob(blob: Blob): Promise<BlobReference> }
|
|
|
+const urls = new Map<string, string>()
|
|
|
+
|
|
|
+function blobUrl(id: string, blob: Blob) {
|
|
|
+ const existing = urls.get(id)
|
|
|
+ if (existing) return existing
|
|
|
+ const url = URL.createObjectURL(blob)
|
|
|
+ urls.set(id, url)
|
|
|
+ return url
|
|
|
+}
|
|
|
+
|
|
|
+async function blobID(blob: Blob) {
|
|
|
+ const id = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", await blob.arrayBuffer())))
|
|
|
+ .map((byte) => byte.toString(16).padStart(2, "0"))
|
|
|
+ .join("")
|
|
|
+ return id
|
|
|
+}
|
|
|
+
|
|
|
+export async function createBlobReference(blob: Blob): Promise<BlobReference> {
|
|
|
+ const id = await blobID(blob)
|
|
|
+ return { id, url: blobUrl(id, blob) }
|
|
|
+}
|
|
|
+
|
|
|
+export function createDraftStore(driver: Driver): DraftStore {
|
|
|
+ const versions = new Map<string, number>()
|
|
|
+ const putBlob = async (blob: Blob) => {
|
|
|
+ const id = await driver.putBlob(blob)
|
|
|
+ return { id, url: blobUrl(id, blob) }
|
|
|
+ }
|
|
|
+ const encode = async (value: unknown): Promise<unknown> => {
|
|
|
+ if (Array.isArray(value)) return Promise.all(value.map(encode))
|
|
|
+ if (!value || typeof value !== "object") return value
|
|
|
+ const item = value as Record<string, unknown>
|
|
|
+ if (item.type === "image" && typeof item.dataUrl === "string") {
|
|
|
+ const blob = await fetch(item.dataUrl).then((response) => response.blob())
|
|
|
+ const { dataUrl: _, ...rest } = item
|
|
|
+ return { ...rest, blob: { id: await driver.putBlob(blob) } }
|
|
|
+ }
|
|
|
+ if ("blob" in item && item.blob && typeof item.blob === "object") {
|
|
|
+ const blob = item.blob as Record<string, unknown>
|
|
|
+ if (typeof blob.id === "string" && blob.id.startsWith("data:")) {
|
|
|
+ const data = await fetch(blob.id).then((response) => response.blob())
|
|
|
+ return { ...item, blob: { id: await driver.putBlob(data) } }
|
|
|
+ }
|
|
|
+ return { ...item, blob: { id: blob.id } }
|
|
|
+ }
|
|
|
+ return Object.fromEntries(
|
|
|
+ await Promise.all(Object.entries(item).map(async ([key, entry]) => [key, await encode(entry)])),
|
|
|
+ )
|
|
|
+ }
|
|
|
+ const decode = async (value: unknown): Promise<unknown> => {
|
|
|
+ if (Array.isArray(value)) return Promise.all(value.map(decode))
|
|
|
+ if (!value || typeof value !== "object") return value
|
|
|
+ const item = value as Record<string, unknown>
|
|
|
+ if (item.blob && typeof item.blob === "object") {
|
|
|
+ const ref = item.blob as Record<string, unknown>
|
|
|
+ if (typeof ref.id === "string") {
|
|
|
+ const blob = await driver.getBlob(ref.id)
|
|
|
+ if (blob) return { ...item, blob: { id: ref.id, url: blobUrl(ref.id, blob) } }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return Object.fromEntries(
|
|
|
+ await Promise.all(Object.entries(item).map(async ([key, entry]) => [key, await decode(entry)])),
|
|
|
+ )
|
|
|
+ }
|
|
|
+ return {
|
|
|
+ getItem: async (key) => {
|
|
|
+ const value = await driver.get(key)
|
|
|
+ return value === null ? null : JSON.stringify(await decode(JSON.parse(value)))
|
|
|
+ },
|
|
|
+ setItem: async (key, value) => {
|
|
|
+ const version = (versions.get(key) ?? 0) + 1
|
|
|
+ versions.set(key, version)
|
|
|
+ const encoded = JSON.stringify(await encode(JSON.parse(value)))
|
|
|
+ if (versions.get(key) === version) await driver.set(key, encoded)
|
|
|
+ },
|
|
|
+ removeItem: async (key) => {
|
|
|
+ versions.set(key, (versions.get(key) ?? 0) + 1)
|
|
|
+ await driver.remove(key)
|
|
|
+ },
|
|
|
+ putBlob,
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+export function createBrowserDraftStore(): DraftStore {
|
|
|
+ const request = indexedDB.open("opencode-drafts", 1)
|
|
|
+ request.addEventListener("upgradeneeded", () => {
|
|
|
+ request.result.createObjectStore("documents")
|
|
|
+ request.result.createObjectStore("blobs")
|
|
|
+ })
|
|
|
+ const db = new Promise<IDBDatabase>((resolve, reject) => {
|
|
|
+ request.addEventListener("success", () => {
|
|
|
+ const database = request.result
|
|
|
+ const transaction = database.transaction(["documents", "blobs"], "readwrite")
|
|
|
+ const documents = transaction.objectStore("documents").getAll()
|
|
|
+ documents.addEventListener("success", () => {
|
|
|
+ const used = new Set<string>()
|
|
|
+ JSON.parse(`[${documents.result.join(",")}]`, (_key, item) => {
|
|
|
+ if (item?.blob && typeof item.blob.id === "string") used.add(item.blob.id)
|
|
|
+ return item
|
|
|
+ })
|
|
|
+ const blobs = transaction.objectStore("blobs").openKeyCursor()
|
|
|
+ blobs.addEventListener("success", () => {
|
|
|
+ const cursor = blobs.result
|
|
|
+ if (!cursor) return
|
|
|
+ if (!used.has(String(cursor.key))) cursor.delete()
|
|
|
+ cursor.continue()
|
|
|
+ })
|
|
|
+ })
|
|
|
+ transaction.addEventListener("complete", () => resolve(database))
|
|
|
+ transaction.addEventListener("abort", () => resolve(database))
|
|
|
+ })
|
|
|
+ request.addEventListener("error", () => reject(request.error))
|
|
|
+ })
|
|
|
+ const get = async (store: string, key: string) => {
|
|
|
+ const result = (await db).transaction(store).objectStore(store).get(key)
|
|
|
+ return new Promise<unknown>((resolve, reject) => {
|
|
|
+ result.addEventListener("success", () => resolve(result.result))
|
|
|
+ result.addEventListener("error", () => reject(result.error))
|
|
|
+ })
|
|
|
+ }
|
|
|
+ const write = async (store: string, key: string, value?: unknown) => {
|
|
|
+ const transaction = (await db).transaction(store, "readwrite")
|
|
|
+ if (value === undefined) transaction.objectStore(store).delete(key)
|
|
|
+ else transaction.objectStore(store).put(value, key)
|
|
|
+ return new Promise<void>((resolve, reject) => {
|
|
|
+ transaction.addEventListener("complete", () => resolve())
|
|
|
+ transaction.addEventListener("error", () => reject(transaction.error))
|
|
|
+ })
|
|
|
+ }
|
|
|
+ return createDraftStore({
|
|
|
+ get: async (key) => ((await get("documents", key)) as string | undefined) ?? null,
|
|
|
+ set: (key, value) => write("documents", key, value),
|
|
|
+ remove: (key) => write("documents", key),
|
|
|
+ putBlob: async (blob) => {
|
|
|
+ const id = await blobID(blob)
|
|
|
+ await write("blobs", id, blob)
|
|
|
+ return id
|
|
|
+ },
|
|
|
+ getBlob: async (id) => ((await get("blobs", id)) as Blob | undefined) ?? null,
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+export async function blobDataUrl(blob: BlobReference, mime: string) {
|
|
|
+ const data = await fetch(blob.url).then((response) => response.blob())
|
|
|
+ return new Promise<string>((resolve, reject) => {
|
|
|
+ const reader = new FileReader()
|
|
|
+ reader.addEventListener("error", () => reject(reader.error))
|
|
|
+ reader.addEventListener("load", () => {
|
|
|
+ const value = typeof reader.result === "string" ? reader.result : ""
|
|
|
+ resolve(`data:${mime};base64,${value.slice(value.indexOf(",") + 1)}`)
|
|
|
+ })
|
|
|
+ reader.readAsDataURL(data)
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+export function createLegacyBlobReference(dataUrl: string): BlobReference {
|
|
|
+ return { id: dataUrl, url: dataUrl }
|
|
|
+}
|