diff --git a/crates/core/src/atproto/auth.rs b/crates/core/src/atproto/auth.rs index d1ccdd1..0d7e0ea 100644 --- a/crates/core/src/atproto/auth.rs +++ b/crates/core/src/atproto/auth.rs @@ -8,6 +8,7 @@ use jacquard::oauth::loopback::{LoopbackConfig, LoopbackPort}; use jacquard::types::did::Did; use jacquard::types::ident::AtIdentifier; use reqwest::Client as HttpClient; +use reqwest::header::CONTENT_TYPE; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fs; @@ -30,6 +31,12 @@ pub struct SessionInfo { pub endpoint: String, } +#[derive(Debug, Clone)] +pub struct BlobDownloadResult { + pub bytes: Vec, + pub content_type: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] struct PersistedSessionMeta { did: String, @@ -308,6 +315,51 @@ impl AtProtoState { .map_err(|error| AppError::io(format!("Failed to resolve PDS for handle: {}", error))), } } + + pub async fn blob_download(&self, did_or_handle: &str, cid: &str) -> Result { + let trimmed_cid = cid.trim(); + if trimmed_cid.is_empty() { + return Err(AppError::new( + ErrorCode::InvalidPath, + "CID is required to download a blob", + )); + } + + let (repo_did, pds_url) = self.resolve_repo_and_pds(did_or_handle).await?; + let endpoint = format!( + "{}/xrpc/com.atproto.sync.getBlob", + pds_url.to_string().trim_end_matches('/') + ); + + let response = self + .http + .get(endpoint) + .query(&[("did", repo_did.to_string()), ("cid", trimmed_cid.to_string())]) + .send() + .await + .map_err(|error| AppError::io(format!("Failed to download blob: {}", error)))?; + + if !response.status().is_success() { + return Err(AppError::io(format!( + "Blob download failed with status {}", + response.status() + ))); + } + + let content_type = response + .headers() + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(|value| value.split(';').next().unwrap_or(value).trim().to_string()) + .filter(|value| !value.is_empty()); + + let bytes = response + .bytes() + .await + .map_err(|error| AppError::io(format!("Failed to read blob response bytes: {}", error)))?; + + Ok(BlobDownloadResult { bytes: bytes.to_vec(), content_type }) + } } fn reserve_loopback_port() -> Result { diff --git a/crates/store/src/lib.rs b/crates/store/src/lib.rs index ad26f51..d0a83dd 100644 --- a/crates/store/src/lib.rs +++ b/crates/store/src/lib.rs @@ -2250,16 +2250,37 @@ impl Store { pub fn image_import( &self, location_id: LocationId, source_path: &Path, target_dir: &str, ) -> Result { - let location = self - .location_get(location_id)? - .ok_or_else(|| AppError::not_found(format!("Location not found: {:?}", location_id)))?; - let ext = source_path .extension() .and_then(|e| e.to_str()) .map(|e| e.to_lowercase()) .ok_or_else(|| AppError::new(ErrorCode::InvalidPath, "File has no extension"))?; + let bytes = + std::fs::read(source_path).map_err(|e| AppError::io(format!("Failed to read source file: {}", e)))?; + let rel = self.image_import_bytes(location_id, &bytes, &ext, target_dir)?; + + log::info!("image_import: stored {} as {}", source_path.display(), rel); + Ok(rel) + } + + /// Imports in-memory image bytes into `target_dir` under the given location. + /// + /// Validates format and size, hashes bytes with blake3, and writes to + /// `/.`. Dedup within `target_dir`: if the hash file + /// already exists there the existing path is returned. + pub fn image_import_bytes( + &self, location_id: LocationId, source_bytes: &[u8], extension: &str, target_dir: &str, + ) -> Result { + let location = self + .location_get(location_id)? + .ok_or_else(|| AppError::not_found(format!("Location not found: {:?}", location_id)))?; + + let ext = extension.trim().to_lowercase(); + if ext.is_empty() { + return Err(AppError::new(ErrorCode::InvalidPath, "Image extension is required")); + } + if !writer_core::SUPPORTED_IMAGE_EXTENSIONS.contains(&ext.as_str()) { return Err(AppError::new( ErrorCode::InvalidPath, @@ -2271,23 +2292,19 @@ impl Store { )); } - let metadata = std::fs::metadata(source_path) - .map_err(|e| AppError::io(format!("Failed to read source file metadata: {}", e)))?; - - if metadata.len() > writer_core::IMAGE_SIZE_LIMIT_BYTES { + let bytes_len = source_bytes.len() as u64; + if bytes_len > writer_core::IMAGE_SIZE_LIMIT_BYTES { return Err(AppError::new( ErrorCode::InvalidPath, format!( "Image exceeds size limit ({} bytes > {} bytes)", - metadata.len(), + bytes_len, writer_core::IMAGE_SIZE_LIMIT_BYTES ), )); } - let bytes = - std::fs::read(source_path).map_err(|e| AppError::io(format!("Failed to read source file: {}", e)))?; - let hash = blake3::hash(&bytes); + let hash = blake3::hash(source_bytes); let hash_hex = hash.to_hex(); let dest_filename = format!("{}.{}", hash_hex, ext); @@ -2302,7 +2319,6 @@ impl Store { .map_err(|e| AppError::io(format!("Failed to create target directory: {}", e)))?; let dest_path = dest_dir.join(&dest_filename); - let rel = if target_dir.is_empty() { dest_filename.clone() } else { @@ -2314,9 +2330,9 @@ impl Store { return Ok(rel); } - std::fs::copy(source_path, &dest_path).map_err(|e| AppError::io(format!("Failed to copy image: {}", e)))?; + std::fs::write(&dest_path, source_bytes) + .map_err(|e| AppError::io(format!("Failed to write imported image: {}", e)))?; - log::info!("image_import: stored {} as {}", source_path.display(), rel); Ok(rel) } @@ -3454,6 +3470,20 @@ mod tests { assert_eq!(result.unwrap_err().code, ErrorCode::InvalidPath); } + #[test] + fn test_image_import_bytes_into_subdir() { + let (store, _tmp) = create_test_store(); + let (location_id, location_dir) = create_test_location(&store); + + let rel = store + .image_import_bytes(location_id, b"fakejpeg", "jpg", "images") + .unwrap(); + + assert!(rel.starts_with("images/")); + assert!(rel.ends_with(".jpg")); + assert!(location_dir.path().join(&rel).exists()); + } + #[test] fn test_image_delete_existing() { let (store, _tmp) = create_test_store(); diff --git a/docs/tasks/image-handling.md b/docs/tasks/image-handling.md index 8272817..23bd04d 100644 --- a/docs/tasks/image-handling.md +++ b/docs/tasks/image-handling.md @@ -11,7 +11,7 @@ updated: 2026-03-24 - Resolve `asset_rel_path` within location root, determine MIME type - Call `com.atproto.repo.uploadBlob` on user's PDS - Return `BlobRef` (CID, MIME type, size) -- [ ] Implement `blob_download` command +- [x] Implement `blob_download` command - Call `com.atproto.sync.getBlob` with DID + CID - Pipe response bytes through `image_import` with `target_dir` - Return local relative path @@ -19,6 +19,8 @@ updated: 2026-03-24 - Replace hardcoded `application/octet-stream` / size 0 with real `BlobRef` values - Thread metadata through publish pipeline into `Image` block construction - [ ] Register `blob_upload` and `blob_download` in `lib.rs` + - [x] Register `blob_download` in `lib.rs` + - [ ] Register `blob_upload` in `lib.rs` ### Publish Pipeline Integration @@ -30,7 +32,7 @@ updated: 2026-03-24 ### Import Pipeline Integration -- [ ] Add blob download step to Standard.Site post import +- [x] Add blob download step to Standard.Site post import - After `post_get_markdown`, scan for `at://blob/` image refs - For each, call `blob_download` with author DID + CID + target dir - Rewrite `at://blob/` → `.` in the markdown @@ -39,6 +41,6 @@ updated: 2026-03-24 ### Frontend - [ ] Add command builders in `src/ports/commands.ts` - - `blobUpload(locationId, assetRelPath, auth, onOk, onErr)` - - `blobDownload(locationId, did, cid, targetDir, onOk, onErr)` -- [ ] Update Standard.Site import controller to call blob download + rewrite + - [ ] `blobUpload(locationId, assetRelPath, auth, onOk, onErr)` + - [x] `blobDownload(locationId, did, cid, targetDir, onOk, onErr)` +- [x] Update Standard.Site import controller to call blob download + rewrite diff --git a/src-tauri/src/commands/atproto.rs b/src-tauri/src/commands/atproto.rs index c022106..0a61cf9 100644 --- a/src-tauri/src/commands/atproto.rs +++ b/src-tauri/src/commands/atproto.rs @@ -1,7 +1,7 @@ use super::{AppState, CommandResponse}; use tauri::State; use writer_core::atproto::SessionInfo; -use writer_core::CommandResult; +use writer_core::{AppError, CommandResult, ErrorCode, LocationId}; #[tauri::command] pub async fn atproto_login(state: State<'_, AppState>, handle: String) -> CommandResponse { @@ -33,3 +33,101 @@ pub async fn atproto_logout(state: State<'_, AppState>) -> CommandResponse<()> { pub async fn atproto_session_status(state: State<'_, AppState>) -> CommandResponse> { Ok(CommandResult::ok(state.atproto.session_status().await)) } + +#[tauri::command] +pub async fn blob_download( + state: State<'_, AppState>, location_id: i64, did: String, cid: String, target_dir: String, +) -> CommandResponse { + let location_id = LocationId(location_id); + log::info!( + "Downloading AT Protocol blob: did={}, cid={}, target_dir={}", + did, + cid, + target_dir + ); + + let downloaded = match state.atproto.blob_download(&did, &cid).await { + Ok(value) => value, + Err(error) => { + log::error!("AT Protocol blob download failed: {}", error); + return Ok(CommandResult::err(error)); + } + }; + + let extension = match infer_image_extension(downloaded.content_type.as_deref(), &downloaded.bytes) { + Some(value) => value, + None => { + let error = AppError::new( + ErrorCode::InvalidPath, + "Blob is not a supported image (png, jpg, jpeg, gif, webp, svg)", + ); + log::error!("Unsupported blob image format: did={}, cid={}", did, cid); + return Ok(CommandResult::err(error)); + } + }; + + match state + .store + .image_import_bytes(location_id, &downloaded.bytes, extension, &target_dir) + { + Ok(rel_path) => { + log::info!("blob_download: stored cid={} as {}", cid, rel_path); + Ok(CommandResult::ok(rel_path)) + } + Err(error) => { + log::error!("blob_download image import failed: {}", error); + Ok(CommandResult::err(error)) + } + } +} + +fn infer_image_extension(content_type: Option<&str>, bytes: &[u8]) -> Option<&'static str> { + if let Some(mime) = content_type { + let normalized = mime.trim().to_ascii_lowercase(); + if let Some(ext) = extension_from_content_type(&normalized) { + return Some(ext); + } + } + + extension_from_bytes(bytes) +} + +fn extension_from_content_type(mime: &str) -> Option<&'static str> { + match mime { + "image/png" => Some("png"), + "image/jpeg" => Some("jpg"), + "image/gif" => Some("gif"), + "image/webp" => Some("webp"), + "image/svg+xml" => Some("svg"), + _ => None, + } +} + +fn extension_from_bytes(bytes: &[u8]) -> Option<&'static str> { + if bytes.starts_with(&[0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n']) { + return Some("png"); + } + + if bytes.starts_with(&[0xff, 0xd8, 0xff]) { + return Some("jpg"); + } + + if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") { + return Some("gif"); + } + + if bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WEBP" { + return Some("webp"); + } + + let probe_len = bytes.len().min(512); + let probe = std::str::from_utf8(&bytes[..probe_len]) + .ok()? + .trim_start_matches('\u{feff}') + .trim_start(); + if probe.starts_with(" { }); describe("image commands", () => { + it("blobDownload builds correct InvokeCmd", () => { + const onOk = vi.fn(); + const onErr = vi.fn(); + const cmd = blobDownload(7, "did:plc:alice", "bafkreiabc", "drafts", onOk, onErr) as InvokeCmd; + + expect(cmd.type).toBe("Invoke"); + expect(cmd.command).toBe("blob_download"); + expect(cmd.payload).toStrictEqual({ + locationId: 7, + did: "did:plc:alice", + cid: "bafkreiabc", + targetDir: "drafts", + }); + }); + it("imageImport builds correct InvokeCmd", () => { const onOk = vi.fn(); const onErr = vi.fn(); diff --git a/src/__tests__/useStandardSiteControllerHook.test.tsx b/src/__tests__/useStandardSiteControllerHook.test.tsx index 1c08ee4..1b9ce70 100644 --- a/src/__tests__/useStandardSiteControllerHook.test.tsx +++ b/src/__tests__/useStandardSiteControllerHook.test.tsx @@ -4,18 +4,30 @@ import type { LocationDescriptor, PostRecord, PublicationListResult, Publication import { act, renderHook, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; -const { mockRunCmd, mockPublicationList, mockPostList, mockPostGetMarkdown } = vi.hoisted(() => ({ +const { + mockRunCmd, + mockPublicationList, + mockPostList, + mockPostGetMarkdown, + mockBlobDownload, + mockDocExists, + mockDocSave, +} = vi.hoisted(() => ({ mockRunCmd: vi.fn<(cmd: Cmd) => Promise>(), mockPublicationList: vi.fn(), mockPostList: vi.fn(), mockPostGetMarkdown: vi.fn(), + mockBlobDownload: vi.fn(), + mockDocExists: vi.fn(), + mockDocSave: vi.fn(), })); vi.mock( "$ports", () => ({ - docExists: vi.fn(), - docSave: vi.fn(), + blobDownload: mockBlobDownload, + docExists: mockDocExists, + docSave: mockDocSave, postGetMarkdown: mockPostGetMarkdown, postList: mockPostList, publicationList: mockPublicationList, @@ -75,28 +87,52 @@ describe("useStandardSiteController", () => { beforeEach(() => { vi.clearAllMocks(); - mockPublicationList.mockImplementation((didOrHandle, onOk: (value: PublicationListResult) => void) => ({ + mockPublicationList.mockImplementation((didOrHandle, onOk: (value: PublicationListResult) => void, onErr) => ({ type: "Invoke", command: "publication_list", payload: { didOrHandle }, onOk, - onErr: vi.fn(), + onErr, })); - mockPostList.mockImplementation((didOrHandle, publicationTid, onOk: (value: PostRecord[]) => void) => ({ + mockPostList.mockImplementation((didOrHandle, publicationTid, onOk: (value: PostRecord[]) => void, onErr) => ({ type: "Invoke", command: "post_list", payload: { didOrHandle, publicationTid }, onOk, - onErr: vi.fn(), + onErr, })); - mockPostGetMarkdown.mockImplementation((didOrHandle, tid, onOk: (value: string) => void) => ({ + mockPostGetMarkdown.mockImplementation((didOrHandle, tid, onOk: (value: string) => void, onErr) => ({ type: "Invoke", command: "post_get_markdown", payload: { didOrHandle, tid }, onOk, - onErr: vi.fn(), + onErr, + })); + + mockBlobDownload.mockImplementation((locationId, did, cid, targetDir, onOk: (value: string) => void, onErr) => ({ + type: "Invoke", + command: "blob_download", + payload: { locationId, did, cid, targetDir }, + onOk, + onErr, + })); + + mockDocExists.mockImplementation((locationId, relPath, onOk: (value: boolean) => void, onErr) => ({ + type: "Invoke", + command: "doc_exists", + payload: { locationId, relPath }, + onOk, + onErr, + })); + + mockDocSave.mockImplementation((locationId, relPath, text, onOk: (value: { success: boolean }) => void, onErr) => ({ + type: "Invoke", + command: "doc_save", + payload: { locationId, relPath, text }, + onOk, + onErr, })); mockRunCmd.mockImplementation(async (cmd) => { @@ -120,8 +156,29 @@ describe("useStandardSiteController", () => { await Promise.resolve(); const payload = cmd.payload as { tid?: string }; cmd.onOk(`# ${payload.tid ?? ""}`); + return; + } + + if (cmd.command === "doc_exists") { + await Promise.resolve(); + cmd.onOk(false); + return; + } + + if (cmd.command === "blob_download") { + await Promise.resolve(); + const payload = cmd.payload as { cid?: string }; + cmd.onOk(`${payload.cid ?? "blob"}.png`); + return; + } + + if (cmd.command === "doc_save") { + await Promise.resolve(); + cmd.onOk({ success: true }); } }); + + vi.stubGlobal("confirm", vi.fn(() => true)); }); it("updates the destination path when selecting a different post", async () => { @@ -153,4 +210,133 @@ describe("useStandardSiteController", () => { expect(result.current.importState.destinationRelPath).toBe("second-post.md"); }); + + it("downloads blob images and rewrites markdown before save", async () => { + mockRunCmd.mockImplementation(async (cmd) => { + if (cmd.type !== "Invoke") { + return; + } + + if (cmd.command === "publication_list") { + cmd.onOk(publicationListResult()); + return; + } + + if (cmd.command === "post_list") { + cmd.onOk(POSTS); + return; + } + + if (cmd.command === "post_get_markdown") { + cmd.onOk("![hero](at://blob/bafkrei123)"); + return; + } + + if (cmd.command === "doc_exists") { + cmd.onOk(false); + return; + } + + if (cmd.command === "blob_download") { + cmd.onOk("bafkrei123.png"); + return; + } + + if (cmd.command === "doc_save") { + cmd.onOk({ success: true }); + } + }); + + const refreshSidebar = vi.fn(); + const { result } = renderHook(() => + useStandardSiteController({ locations: LOCATIONS, selectedLocationId: 1, refreshSidebar }) + ); + + act(() => { + result.current.setHandle("alice.bsky.social"); + }); + + act(() => { + result.current.handleBrowsePublications(); + }); + + await waitFor(() => { + expect(result.current.importState.selectedPostTid).toBe("3post1"); + expect(result.current.importState.previewMarkdown).toContain("at://blob/bafkrei123"); + }); + + await act(async () => { + await result.current.handleImport(); + }); + + expect(mockBlobDownload).toHaveBeenCalledWith( + 1, + "did:plc:alice", + "bafkrei123", + "", + expect.any(Function), + expect.any(Function), + ); + + const docSaveCall = mockDocSave.mock.calls.at(-1); + expect(docSaveCall).toBeDefined(); + expect(docSaveCall?.[2]).toBe("![hero](bafkrei123.png)"); + expect(refreshSidebar).toHaveBeenCalledWith(1); + }); + + it("cancels import when blob download fails and user chooses stop", async () => { + vi.stubGlobal("confirm", vi.fn(() => false)); + + mockRunCmd.mockImplementation(async (cmd) => { + if (cmd.type !== "Invoke") { + return; + } + + if (cmd.command === "publication_list") { + cmd.onOk(publicationListResult()); + return; + } + + if (cmd.command === "post_list") { + cmd.onOk(POSTS); + return; + } + + if (cmd.command === "post_get_markdown") { + cmd.onOk("![hero](at://blob/bafkrei123)"); + return; + } + + if (cmd.command === "doc_exists") { + cmd.onOk(false); + return; + } + + if (cmd.command === "blob_download") { + cmd.onErr({ code: "IO_ERROR", message: "download failed" }); + } + }); + + const { result } = renderHook(() => + useStandardSiteController({ locations: LOCATIONS, selectedLocationId: 1, refreshSidebar: vi.fn() }) + ); + + act(() => { + result.current.setHandle("alice.bsky.social"); + }); + + act(() => { + result.current.handleBrowsePublications(); + }); + + await waitFor(() => { + expect(result.current.importState.selectedPostTid).toBe("3post1"); + }); + + await act(async () => { + await result.current.handleImport(); + }); + + expect(mockDocSave).not.toHaveBeenCalled(); + }); }); diff --git a/src/hooks/controllers/useStandardSiteController.ts b/src/hooks/controllers/useStandardSiteController.ts index 811de1e..61e0fda 100644 --- a/src/hooks/controllers/useStandardSiteController.ts +++ b/src/hooks/controllers/useStandardSiteController.ts @@ -1,4 +1,4 @@ -import { docExists, docSave, postGetMarkdown, postList, publicationList, runCmd } from "$ports"; +import { blobDownload, docExists, docSave, postGetMarkdown, postList, publicationList, runCmd } from "$ports"; import { useStandardSiteUiState } from "$state/selectors"; import { showErrorToast, showSuccessToast, showWarnToast } from "$state/stores/toasts"; import type { LocationDescriptor, PostRecord, PublicationRecord } from "$types"; @@ -47,6 +47,31 @@ function deriveDefaultRelPath(post: PostRecord): string { return slug ? `${slug}.md` : "post.md"; } +function extractBlobCids(markdown: string): string[] { + const cids = new Set(); + const matches = markdown.matchAll(/at:\/\/blob\/([A-Za-z0-9._:-]+)/g); + + for (const match of matches) { + const cid = match[1]?.trim(); + if (cid) { + cids.add(cid); + } + } + + return Array.from(cids); +} + +function deriveTargetDirFromRelPath(relPath: string): string { + const normalized = normalizeImportPath(relPath); + const lastSlashIndex = normalized.lastIndexOf("/"); + return lastSlashIndex === -1 ? "" : normalized.slice(0, lastSlashIndex); +} + +function didFromAtUri(uri: string): string | null { + const match = uri.trim().match(/^at:\/\/([^/]+)\//); + return match?.[1] ?? null; +} + export function useStandardSiteController( { locations, selectedLocationId, refreshSidebar }: UseStandardSiteControllerOptions, ) { @@ -261,8 +286,57 @@ export function useStandardSiteController( return; } + const blobCids = extractBlobCids(previewMarkdown); + let markdownToSave = previewMarkdown; + + if (blobCids.length > 0) { + const authorDid = didFromAtUri(selectedPost.uri); + if (!authorDid) { + setIsSaving(false); + showErrorToast("Failed to resolve post author DID for blob downloads."); + return; + } + + const targetDir = deriveTargetDirFromRelPath(relPath); + const cidToLocalPath = new Map(); + + for (const cid of blobCids) { + const localPath = await new Promise((resolve) => { + void runCmd(blobDownload(destinationLocationId, authorDid, cid, targetDir, (downloadedPath) => { + resolve(downloadedPath); + }, (error) => { + logger.error( + f("Failed to download post image blob", { destinationLocationId, authorDid, cid, targetDir, error }), + ); + resolve(null); + })); + }); + + if (localPath) { + cidToLocalPath.set(cid, localPath); + continue; + } + + const shouldContinue = typeof globalThis.confirm === "function" + ? globalThis.confirm(`Failed to download image blob ${cid}. Continue import without this image?`) + : false; + + if (!shouldContinue) { + setIsSaving(false); + showWarnToast("Import canceled after image blob download failure."); + return; + } + + showWarnToast(`Skipped image blob ${cid}.`); + } + + for (const [cid, localPath] of cidToLocalPath) { + markdownToSave = markdownToSave.replaceAll(`at://blob/${cid}`, localPath); + } + } + const saved = await new Promise((resolve) => { - void runCmd(docSave(destinationLocationId, relPath, previewMarkdown, (result) => { + void runCmd(docSave(destinationLocationId, relPath, markdownToSave, (result) => { resolve(result.success); }, (error) => { logger.error(f("Failed to import post", { destinationLocationId, relPath, tid: selectedPost.tid, error })); diff --git a/src/ports/commands.ts b/src/ports/commands.ts index 54e428d..b09eced 100644 --- a/src/ports/commands.ts +++ b/src/ports/commands.ts @@ -26,6 +26,7 @@ import type { BackendCaptureSubmitInput, BackendGlobalCaptureSettings, BackendStyleCheckScanMatch, + BlobDownloadParams, Cmd, DirCreateParams, DirDeleteParams, @@ -197,6 +198,10 @@ export function postGetMarkdown(...[didOrHandle, tid, onOk, onErr]: PostGetMarkd return invokeCmd("post_get_markdown", { didOrHandle, tid }, onOk, onErr); } +export function blobDownload(...[locationId, did, cid, targetDir, onOk, onErr]: BlobDownloadParams): Cmd { + return invokeCmd("blob_download", { locationId, did, cid, targetDir }, onOk, onErr); +} + export function locationAddViaDialog(...[onOk, onErr]: LocParams): Cmd { return invokeCmd("location_add_via_dialog", {}, onOk, onErr); } diff --git a/src/ports/types.ts b/src/ports/types.ts index 1c8eb30..9d78d23 100644 --- a/src/ports/types.ts +++ b/src/ports/types.ts @@ -331,6 +331,17 @@ export type PostGetMarkdownParams = Parameters< (didOrHandle: string, tid: string, onOk: SuccessCallback, onErr: ErrorCallback) => void >; +export type BlobDownloadParams = Parameters< + ( + locationId: LocationId, + did: string, + cid: string, + targetDir: string, + onOk: SuccessCallback, + onErr: ErrorCallback, + ) => void +>; + export type ImageImportParams = Parameters< ( locationId: LocationId,