diff --git a/Cargo.lock b/Cargo.lock --- a/Cargo.lock +++ b/Cargo.lock @@ -496,6 +496,12 @@ ] [[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + +[[package]] name = "borsh" version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1156,6 +1162,33 @@ ] [[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.116", +] + +[[package]] name = "darling" version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1452,6 +1485,31 @@ ] [[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] name = "elliptic-curve" version = "0.13.8" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1463,6 +1521,7 @@ "ff", "generic-array", "group", + "hkdf", "pem-rfc7468", "pkcs8", "rand_core 0.6.4", @@ -1702,6 +1761,12 @@ ] [[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] name = "field-offset" version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1736,6 +1801,17 @@ dependencies = [ "crc32fast", "miniz_oxide", +] + +[[package]] +name = "fluent-uri" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", ] [[package]] @@ -2521,6 +2597,15 @@ ] [[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] name = "hmac" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2979,8 +3064,9 @@ [[package]] name = "jacquard" -version = "0.9.5" -source = "git+https://tangled.org/nonbinary.computer/jacquard#298b03bb6ed9b2a308a82eed83fd497622042e70" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85cff3260f67179f334c3d0bfd77841966c83e44dba30ee6b1160cd3083f3c4b" dependencies = [ "bytes", "getrandom 0.2.17", @@ -3003,33 +3089,28 @@ "thiserror 2.0.18", "tokio", "trait-variant", - "url", "webpage", ] [[package]] name = "jacquard-api" -version = "0.9.5" -source = "git+https://tangled.org/nonbinary.computer/jacquard#298b03bb6ed9b2a308a82eed83fd497622042e70" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "813e126804cfe687179c9ac7ed5262df21cd6b570e9faa723bab0b0c1f8b9b03" dependencies = [ - "bon", - "bytes", "jacquard-common", "jacquard-derive", "jacquard-lexicon", "miette", - "rustversion", "serde", - "serde_bytes", - "serde_ipld_dagcbor", "thiserror 2.0.18", - "unicode-segmentation", ] [[package]] name = "jacquard-common" -version = "0.9.5" -source = "git+https://tangled.org/nonbinary.computer/jacquard#298b03bb6ed9b2a308a82eed83fd497622042e70" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e8585bd5afd53ba5b16a2b4b57bfa7d574051ced2b16eb72d0b42104192fd8" dependencies = [ "base64 0.22.1", "bon", @@ -3038,6 +3119,7 @@ "ciborium", "ciborium-io", "cid", + "fluent-uri", "getrandom 0.2.17", "getrandom 0.3.4", "hashbrown 0.15.5", @@ -3051,12 +3133,14 @@ "ouroboros", "oxilangtag", "p256", + "phf 0.11.3", "postcard", "rand 0.9.2", "regex", "regex-automata", "regex-lite", "reqwest 0.12.28", + "rustversion", "serde", "serde_bytes", "serde_html_form", @@ -3069,13 +3153,14 @@ "tokio", "tokio-util", "trait-variant", - "url", + "unicode-segmentation", ] [[package]] name = "jacquard-derive" -version = "0.9.5" -source = "git+https://tangled.org/nonbinary.computer/jacquard#298b03bb6ed9b2a308a82eed83fd497622042e70" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50298fde40f5b0294f8c9705beeef447a47af75f7031c9bff21ff39e60da6b4c" dependencies = [ "heck 0.5.0", "jacquard-lexicon", @@ -3086,8 +3171,9 @@ [[package]] name = "jacquard-identity" -version = "0.9.5" -source = "git+https://tangled.org/nonbinary.computer/jacquard#298b03bb6ed9b2a308a82eed83fd497622042e70" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62ce48854a335671a5c0155664d2e124774b07055914b0afa62d61f55c726052" dependencies = [ "bon", "bytes", @@ -3099,7 +3185,6 @@ "miette", "mini-moka-wasm", "n0-future", - "percent-encoding", "reqwest 0.12.28", "serde", "serde_html_form", @@ -3107,14 +3192,13 @@ "thiserror 2.0.18", "tokio", "trait-variant", - "url", - "urlencoding", ] [[package]] name = "jacquard-lexicon" -version = "0.9.5" -source = "git+https://tangled.org/nonbinary.computer/jacquard#298b03bb6ed9b2a308a82eed83fd497622042e70" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a68cba8def6994db89125262a5e9bc5ceaa967733b128c12ef95071a4aea4e9e" dependencies = [ "cid", "dashmap", @@ -3140,21 +3224,25 @@ [[package]] name = "jacquard-oauth" -version = "0.9.6" -source = "git+https://tangled.org/nonbinary.computer/jacquard#298b03bb6ed9b2a308a82eed83fd497622042e70" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d6fe5659dbefd2153022ad9bbc6ddeefb0130b9e18519896c93f6abf5c96af1" dependencies = [ "base64 0.22.1", "bytes", "chrono", "dashmap", + "ed25519-dalek", "elliptic-curve", "http", "jacquard-common", "jacquard-identity", "jose-jwa", "jose-jwk", + "k256", "miette", "p256", + "p384", "rand 0.8.5", "rouille", "serde", @@ -3165,7 +3253,6 @@ "thiserror 2.0.18", "tokio", "trait-variant", - "url", "webbrowser", ] @@ -3346,7 +3433,9 @@ "cfg-if", "ecdsa", "elliptic-curve", + "once_cell", "sha2", + "signature", ] [[package]] @@ -3691,7 +3780,8 @@ [[package]] name = "mini-moka-wasm" version = "0.10.99" -source = "git+https://tangled.org/nonbinary.computer/jacquard#298b03bb6ed9b2a308a82eed83fd497622042e70" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0102b9a2ad50fa47ca89eead2316c8222285ecfbd3f69ce99564fbe4253866e8" dependencies = [ "crossbeam-channel", "crossbeam-utils", @@ -4380,8 +4470,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" dependencies = [ + "ecdsa", "elliptic-curve", "primeorder", + "sha2", ] [[package]] @@ -7405,12 +7497,6 @@ "serde", "serde_derive", ] - -[[package]] -name = "urlencoding" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" [[package]] name = "urlpattern" diff --git a/src/App.tsx b/src/App.tsx --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,7 @@ import { AtProtoAuthSheet } from "./components/AtProto/AtProtoAuthSheet"; import { ExportDialog } from "./components/export/ExportDialog/ExportDialog"; import { HelpSheet } from "./components/HelpSheet"; +import { PostImportSheet } from "./components/StandardSite/PostImportSheet"; import { Toaster } from "./components/Toaster"; import { useAppChromeController } from "./hooks/controllers/useAppChromeController"; import { @@ -25,6 +26,7 @@ editorFontFamily, editorText, atProto, + standardSite, } = view; const { isOpen: isHelpSheetOpen, setOpen: setHelpSheetOpen } = useHelpSheetState(); const closeHelpSheet = useCallback(() => setHelpSheetOpen(false), [setHelpSheetOpen]); @@ -33,7 +35,12 @@ return ( <> - + + @@ -47,8 +54,14 @@ atProtoSession={atProto.session} atProtoPending={atProto.isPending} onOpenAtProtoAuth={atProto.openAuthSheet} + onOpenStandardSiteImport={standardSite.openImportSheet} onLogoutAtProto={atProto.handleLogout} /> - + + ); diff --git a/src/types.ts b/src/types.ts --- a/src/types.ts +++ b/src/types.ts @@ -89,6 +89,32 @@ createdAt: string; }; +/** + * A Standard.Site publication — a named blog/site container owned by a DID. + * @see https://standard.site + * @see https://tangled.org/standard.site/lexicons/tree/main/src/lexicons (`site.standard.publication`) + */ +export type PublicationRecord = { uri: string; tid: string; name: string; description: string; url: string }; +export type PublicationListResult = { publications: PublicationRecord[]; skippedInvalidCount: number }; + +/** + * A Standard.Site post (`site.standard.document`) belonging to a publication. + * Content is stored in the Leaflet block format inside the open `content` union. + * @see https://standard.site + * @see https://tangled.org/standard.site/lexicons/tree/main/src/lexicons (`site.standard.document`) + */ +export type PostRecord = { + uri: string; + tid: string; + title: string; + description: string; + textContent: string; + publishedAt: string; + updatedAt: string; + tags: string[]; + publicationUri: string; +}; + export type FocusDimmingMode = "off" | "sentence" | "paragraph"; export type MarkdownPreviewStyle = "github" | "pdf"; diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -9,7 +9,7 @@ chrono = { version = "0.4", features = ["serde"] } aho-corasick = "1.1.4" serde_json = "1" -jacquard = { git = "https://tangled.org/nonbinary.computer/jacquard" } +jacquard = "0.10" unicode-segmentation = "1.12.0" comrak = "0.50" reqwest = "0.12" diff --git a/docs/specs/at-proto.md b/docs/specs/at-proto.md --- a/docs/specs/at-proto.md +++ b/docs/specs/at-proto.md @@ -360,7 +360,7 @@ | Command | Args | Returns | Auth | | ------------------- | ---------------------------------------------- | ----------------------------------------- | -------- | -| `publication_list` | `did_or_handle` | `CommandResponse>` | No | +| `publication_list` | `did_or_handle` | `CommandResponse` | No | | `publication_get` | `did_or_handle, tid` | `CommandResponse` | No | | `post_list` | `did_or_handle, publication_tid?` | `CommandResponse>` | No | | `post_get` | `did_or_handle, tid` | `CommandResponse` | No | @@ -370,6 +370,7 @@ | `post_delete` | `tid` | `CommandResponse<()>` | Required | `PublicationRecord`: `uri`, `tid`, `name`, `description`, `url`. +`PublicationListResult`: `publications`, `skipped_invalid_count` (`skippedInvalidCount` in the frontend) so malformed publication records can be skipped without failing the whole browse. `PostRecord`: `uri`, `tid`, `title`, `description`, `text_content`, `published_at`, `updated_at`, `tags`, `publication_uri`. `post_get_markdown` should perform Leaflet→Markdown conversion in `writer_core::atproto::leaflet`, with the Tauri command acting as a transport wrapper so the frontend receives ready-to-use content. @@ -392,10 +393,11 @@ ### Import Flow (Pull) 1. User opens import sheet from toolbar or settings panel. -2. Enter a handle or DID → call `publication_list` to list their publications. -3. Select a publication → call `post_list` filtered by that publication. -4. Select a post → call `post_get_markdown` to preview the converted Markdown. -5. Choose a Writer location + relative path → import with `doc_exists` guard + `doc_save`. +2. Standard.Site post import should remain available without an authenticated Writer/Tangled session because it only reads public records. +3. Enter a handle or DID → call `publication_list` to list their publications and report any skipped malformed records. +4. Select a publication → call `post_list` filtered by that publication. +5. Select a post → call `post_get_markdown` to preview the converted Markdown. +6. Choose a Writer location + relative path → import with `doc_exists` guard + `doc_save`. ### Publish Flow (Push) diff --git a/src/__tests__/LayoutSettingsRoute.test.tsx b/src/__tests__/LayoutSettingsRoute.test.tsx --- a/src/__tests__/LayoutSettingsRoute.test.tsx +++ b/src/__tests__/LayoutSettingsRoute.test.tsx @@ -73,4 +73,18 @@ expect(screen.getByText("Adverb")).toBeInTheDocument(); expect(screen.getByText("Conjunction")).toBeInTheDocument(); }); + + it("shows Standard.Site import from settings without requiring a session", () => { + globalThis.history.replaceState(null, "", "#/settings"); + + render( + + + , + ); + + // FIXME: wtf? + fireEvent.click(screen.getByRole("button", { name: "TangledManage your AT Protocol session." })); + expect(screen.getByText("Not connected")).toBeInTheDocument(); + }); }); diff --git a/src/__tests__/Sidebar.test.tsx b/src/__tests__/Sidebar.test.tsx --- a/src/__tests__/Sidebar.test.tsx +++ b/src/__tests__/Sidebar.test.tsx @@ -127,6 +127,22 @@ expect(handleRefreshSidebar).toHaveBeenCalledWith(1); }); + it("shows import actions for Tangled strings and Standard.Site posts", () => { + vi.mocked(useSidebarState).mockReturnValue(createSidebarState()); + const onOpenImportSheet = vi.fn(); + const onOpenStandardSiteImportSheet = vi.fn(); + + render( + , + ); + + fireEvent.click(screen.getByTitle("Import Tangled strings")); + fireEvent.click(screen.getByTitle("Import Standard.Site posts")); + + expect(onOpenImportSheet).toHaveBeenCalledOnce(); + expect(onOpenStandardSiteImportSheet).toHaveBeenCalledOnce(); + }); + it("shows refresh feedback for the selected location and disables refresh action", () => { vi.mocked(useSidebarState).mockReturnValue( createSidebarState({ refreshingLocationId: 1, sidebarRefreshReason: "external" }), diff --git a/src/__tests__/ports.test.ts b/src/__tests__/ports.test.ts --- a/src/__tests__/ports.test.ts +++ b/src/__tests__/ports.test.ts @@ -28,6 +28,7 @@ none, noSub, ok, + publicationList, renderMarkdown, renderMarkdownForPdf, runCmd, @@ -549,6 +550,39 @@ contents: "print('hi')", createdAt: "2026-03-19T10:00:00Z", }]); + expect(onErr).not.toHaveBeenCalled(); + }); + + it("normalizes Standard.Site publication list results", async () => { + const onOk = vi.fn(); + const onErr = vi.fn(); + + vi.mocked(invoke).mockResolvedValueOnce({ + type: "ok", + value: { + publications: [{ + uri: "at://did:plc:alice/site.standard.publication/3lxyz", + tid: "3lxyz", + name: "Example", + description: "Site", + url: "https://example.com", + }], + skipped_invalid_count: 1, + }, + }); + + await runCmd(publicationList("alice.bsky.social", onOk, onErr)); + + expect(onOk).toHaveBeenCalledWith({ + publications: [{ + uri: "at://did:plc:alice/site.standard.publication/3lxyz", + tid: "3lxyz", + name: "Example", + description: "Site", + url: "https://example.com", + }], + skippedInvalidCount: 1, + }); expect(onErr).not.toHaveBeenCalled(); }); }); diff --git a/src/__tests__/useStandardSiteController.test.ts b/src/__tests__/useStandardSiteController.test.ts new file mode 100644 --- /dev/null +++ b/src/__tests__/useStandardSiteController.test.ts @@ -0,0 +1,46 @@ +import { postGetMarkdown, postList, publicationList } from "$ports"; +import { describe, expect, it } from "vitest"; + +const noop = () => {}; + +describe("publicationList command builder", () => { + it("builds an invoke command with didOrHandle", () => { + const cmd = publicationList("alice.bsky.social", noop, noop); + expect(cmd.type).toBe("Invoke"); + if (cmd.type === "Invoke") { + expect(cmd.command).toBe("publication_list"); + expect(cmd.payload).toEqual({ didOrHandle: "alice.bsky.social" }); + } + }); +}); + +describe("postList command builder", () => { + it("builds an invoke command with didOrHandle and publicationTid", () => { + const cmd = postList("alice.bsky.social", "3jzfcijpj2z2a", noop, noop); + expect(cmd.type).toBe("Invoke"); + if (cmd.type === "Invoke") { + expect(cmd.command).toBe("post_list"); + expect(cmd.payload).toEqual({ didOrHandle: "alice.bsky.social", publicationTid: "3jzfcijpj2z2a" }); + } + }); + + it("omits publicationTid when undefined", () => { + const cmd = postList("alice.bsky.social", undefined, noop, noop); + expect(cmd.type).toBe("Invoke"); + if (cmd.type === "Invoke") { + expect(cmd.command).toBe("post_list"); + expect(cmd.payload).toEqual({ didOrHandle: "alice.bsky.social", publicationTid: undefined }); + } + }); +}); + +describe("postGetMarkdown command builder", () => { + it("builds an invoke command with didOrHandle and tid", () => { + const cmd = postGetMarkdown("alice.bsky.social", "3jzfcijpj2z2a", noop, noop); + expect(cmd.type).toBe("Invoke"); + if (cmd.type === "Invoke") { + expect(cmd.command).toBe("post_get_markdown"); + expect(cmd.payload).toEqual({ didOrHandle: "alice.bsky.social", tid: "3jzfcijpj2z2a" }); + } + }); +}); diff --git a/src/__tests__/useStandardSiteControllerHook.test.tsx b/src/__tests__/useStandardSiteControllerHook.test.tsx new file mode 100644 --- /dev/null +++ b/src/__tests__/useStandardSiteControllerHook.test.tsx @@ -0,0 +1,156 @@ +import { useStandardSiteController } from "$hooks/controllers/useStandardSiteController"; +import type { Cmd } from "$ports"; +import type { LocationDescriptor, PostRecord, PublicationListResult, PublicationRecord } from "$types"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockRunCmd, mockPublicationList, mockPostList, mockPostGetMarkdown } = vi.hoisted(() => ({ + mockRunCmd: vi.fn<(cmd: Cmd) => Promise>(), + mockPublicationList: vi.fn(), + mockPostList: vi.fn(), + mockPostGetMarkdown: vi.fn(), +})); + +vi.mock( + "$ports", + () => ({ + docExists: vi.fn(), + docSave: vi.fn(), + postGetMarkdown: mockPostGetMarkdown, + postList: mockPostList, + publicationList: mockPublicationList, + runCmd: mockRunCmd, + }), +); + +vi.mock( + "$state/selectors", + () => ({ + useStandardSiteUiState: vi.fn(() => ({ sheetMode: "closed", openPostImportSheet: vi.fn(), closeSheet: vi.fn() })), + }), +); + +const LOCATIONS: LocationDescriptor[] = [{ + id: 1, + name: "Notes", + root_path: "/tmp/notes", + added_at: "2026-03-20T00:00:00Z", +}]; + +const PUBLICATIONS: PublicationRecord[] = [{ + uri: "at://did:plc:alice/site.standard.publication/3pub", + tid: "3pub", + name: "Example Publication", + description: "", + url: "https://example.com", +}]; + +const POSTS: PostRecord[] = [{ + uri: "at://did:plc:alice/site.standard.document/3post1", + tid: "3post1", + title: "First Post", + description: "", + textContent: "", + publishedAt: "2026-03-20T00:00:00Z", + updatedAt: "", + tags: [], + publicationUri: PUBLICATIONS[0].uri, +}, { + uri: "at://did:plc:alice/site.standard.document/3post2", + tid: "3post2", + title: "Second Post", + description: "", + textContent: "", + publishedAt: "2026-03-20T00:00:00Z", + updatedAt: "", + tags: [], + publicationUri: PUBLICATIONS[0].uri, +}]; + +function publicationListResult(): PublicationListResult { + return { publications: PUBLICATIONS, skippedInvalidCount: 0 }; +} + +describe("useStandardSiteController", () => { + beforeEach(() => { + vi.clearAllMocks(); + + mockPublicationList.mockImplementation((didOrHandle, onOk: (value: PublicationListResult) => void) => ({ + type: "Invoke", + command: "publication_list", + payload: { didOrHandle }, + onOk, + onErr: vi.fn(), + })); + + mockPostList.mockImplementation((didOrHandle, publicationTid, onOk: (value: PostRecord[]) => void) => ({ + type: "Invoke", + command: "post_list", + payload: { didOrHandle, publicationTid }, + onOk, + onErr: vi.fn(), + })); + + mockPostGetMarkdown.mockImplementation((didOrHandle, tid, onOk: (value: string) => void) => ({ + type: "Invoke", + command: "post_get_markdown", + payload: { didOrHandle, tid }, + onOk, + onErr: vi.fn(), + })); + + mockRunCmd.mockImplementation(async (cmd) => { + if (cmd.type !== "Invoke") { + return; + } + + if (cmd.command === "publication_list") { + await Promise.resolve(); + cmd.onOk(publicationListResult()); + return; + } + + if (cmd.command === "post_list") { + await Promise.resolve(); + cmd.onOk(POSTS); + return; + } + + if (cmd.command === "post_get_markdown") { + await Promise.resolve(); + const payload = cmd.payload as { tid?: string }; + cmd.onOk(`# ${payload.tid ?? ""}`); + } + }); + }); + + it("updates the destination path when selecting a different post", async () => { + 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"); + }); + + expect(result.current.importState.destinationRelPath).toBe("first-post.md"); + + act(() => { + result.current.handleSelectPost("3post2"); + }); + + await waitFor(() => { + expect(result.current.importState.selectedPostTid).toBe("3post2"); + }); + + expect(result.current.importState.destinationRelPath).toBe("second-post.md"); + }); +}); diff --git a/src/assets/standard-site.svg b/src/assets/standard-site.svg new file mode 100644 --- /dev/null +++ b/src/assets/standard-site.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/icons.tsx b/src/components/icons.tsx --- a/src/components/icons.tsx +++ b/src/components/icons.tsx @@ -238,6 +238,22 @@ ); +export const StandardSiteIcon = ({ className = "", style }: Omit) => ( + +); + /** * @see assets/dolly.svg */ diff --git a/src/ports/commands.ts b/src/ports/commands.ts --- a/src/ports/commands.ts +++ b/src/ports/commands.ts @@ -9,6 +9,8 @@ LocationDescriptor, LocationId, MarkdownProfile, + PostRecord, + PublicationListResult, RenderResult, SearchHit, SessionState, @@ -42,6 +44,9 @@ GlobalCaptureValidateShortcutParams, LocParams, PersistedStyleCheckSettings, + PostGetMarkdownParams, + PostListParams, + PublicationListParams, RenderMarkdownForDocxParams, RenderMarkdownForPdfParams, RenderMarkdownForTextParams, @@ -172,6 +177,18 @@ export function stringDelete(...[tid, onOk, onErr]: StringDeleteParams): Cmd { return invokeCmd("string_delete", { tid }, onOk, onErr); +} + +export function publicationList(...[didOrHandle, onOk, onErr]: PublicationListParams): Cmd { + return invokeCmd("publication_list", { didOrHandle }, onOk, onErr); +} + +export function postList(...[didOrHandle, publicationTid, onOk, onErr]: PostListParams): Cmd { + return invokeCmd("post_list", { didOrHandle, publicationTid }, onOk, onErr); +} + +export function postGetMarkdown(...[didOrHandle, tid, onOk, onErr]: PostGetMarkdownParams): Cmd { + return invokeCmd("post_get_markdown", { didOrHandle, tid }, onOk, onErr); } export function locationAddViaDialog(...[onOk, onErr]: LocParams): Cmd { diff --git a/src/ports/invoke.ts b/src/ports/invoke.ts --- a/src/ports/invoke.ts +++ b/src/ports/invoke.ts @@ -8,6 +8,8 @@ DocRef, ErrorCode, GlobalCaptureSettings, + PublicationListResult, + PublicationRecord, SearchHit, SessionState, TangledStringRecord, @@ -276,6 +278,37 @@ }; } +function normalizePublicationRecord(value: unknown): PublicationRecord { + if (!isRecord(value)) { + return { uri: "", tid: "", name: "", description: "", url: "" }; + } + + return { + uri: typeof value.uri === "string" ? value.uri : "", + tid: typeof value.tid === "string" ? value.tid : "", + name: typeof value.name === "string" ? value.name : "", + description: typeof value.description === "string" ? value.description : "", + url: typeof value.url === "string" ? value.url : "", + }; +} + +function normalizePublicationListResult(value: unknown): PublicationListResult { + if (!isRecord(value)) { + return { publications: [], skippedInvalidCount: 0 }; + } + + return { + publications: Array.isArray(value.publications) + ? value.publications.map((publication) => normalizePublicationRecord(publication)) + : [], + skippedInvalidCount: typeof value.skippedInvalidCount === "number" + ? value.skippedInvalidCount + : typeof value.skipped_invalid_count === "number" + ? value.skipped_invalid_count + : 0, + }; +} + function normalizeSessionState(value: unknown): SessionState { if (!isRecord(value) || !Array.isArray(value.tabs)) { return { tabs: [], activeTabId: null }; @@ -360,6 +393,9 @@ } return value.map((record) => normalizeTangledStringRecord(record)); + } + case "publication_list": { + return normalizePublicationListResult(value); } case "style_check_scan": { if (!Array.isArray(value)) { diff --git a/src/ports/types.ts b/src/ports/types.ts --- a/src/ports/types.ts +++ b/src/ports/types.ts @@ -14,6 +14,8 @@ LocationId, MarkdownPreviewStyle, MarkdownProfile, + PostRecord, + PublicationListResult, RenderResult, SaveStatus, SearchHit, @@ -292,3 +294,20 @@ ) => void >; export type StringDeleteParams = Parameters<(tid: string, onOk: SuccessCallback, onErr: ErrorCallback) => void>; + +export type PublicationListParams = Parameters< + (didOrHandle: string, onOk: SuccessCallback, onErr: ErrorCallback) => void +>; + +export type PostListParams = Parameters< + ( + didOrHandle: string, + publicationTid: string | undefined, + onOk: SuccessCallback, + onErr: ErrorCallback, + ) => void +>; + +export type PostGetMarkdownParams = Parameters< + (didOrHandle: string, tid: string, onOk: SuccessCallback, onErr: ErrorCallback) => void +>; diff --git a/src/state/selectors.ts b/src/state/selectors.ts --- a/src/state/selectors.ts +++ b/src/state/selectors.ts @@ -515,6 +515,15 @@ })), ); +export const useStandardSiteUiState = () => + useUiStore( + useShallow((state) => ({ + sheetMode: state.standardSiteSheetMode, + openPostImportSheet: state.openStandardSitePostImportSheet, + closeSheet: state.closeStandardSiteSheet, + })), + ); + export type SidebarStateReturn = ReturnType; export type ToolbarStateReturn = ReturnType; export type EditorPresentationStateReturn = ReturnType; diff --git a/src/state/types.ts b/src/state/types.ts --- a/src/state/types.ts +++ b/src/state/types.ts @@ -220,6 +220,8 @@ export type AtProtoSheetMode = "closed" | "login" | "session" | "import" | "publish"; +export type StandardSiteSheetMode = "closed" | "postImport"; + export type AtProtoUiState = { atProtoSheetMode: AtProtoSheetMode; atProtoSession: AtProtoSession | null; @@ -238,34 +240,44 @@ setAtProtoPending: (value: boolean) => void; }; -export type UiState = { - layoutSettingsOpen: boolean; - pdfExportDialogOpen: boolean; - pdfExportOptions: PdfExportOptions; - globalCaptureSettings: GlobalCaptureSettings; - helpSheetOpen: boolean; - styleDiagnosticsOpen: boolean; -} & AtProtoUiState; +export type StandardSiteUiState = { standardSiteSheetMode: StandardSiteSheetMode }; -export type UiActions = { - setLayoutSettingsOpen: (value: boolean) => void; - setPdfExportDialogOpen: (value: boolean) => void; - setPdfExportOptions: (value: PdfExportOptions) => void; - resetPdfExportOptions: () => void; - setPdfPageSize: (value: PageSize) => void; - setPdfOrientation: (value: Orientation) => void; - setPdfFontSize: (value: number) => void; - setPdfMargin: (side: MarginSide, value: number) => void; - setPdfIncludeTitle: (value: boolean) => void; - setPdfIncludeHeader: (value: boolean) => void; - setPdfIncludeFooter: (value: boolean) => void; - setGlobalCaptureSettings: (value: GlobalCaptureSettings) => void; - setQuickCaptureEnabled: (enabled: boolean) => Promise; - setHelpSheetOpen: (value: boolean) => void; - toggleHelpSheet: () => void; - setStyleDiagnosticsOpen: (value: boolean) => void; - toggleStyleDiagnostics: () => void; -} & AtProtoUiActions; +export type StandardSiteUiActions = { openStandardSitePostImportSheet: () => void; closeStandardSiteSheet: () => void }; + +export type UiState = + & { + layoutSettingsOpen: boolean; + pdfExportDialogOpen: boolean; + pdfExportOptions: PdfExportOptions; + globalCaptureSettings: GlobalCaptureSettings; + helpSheetOpen: boolean; + styleDiagnosticsOpen: boolean; + } + & AtProtoUiState + & StandardSiteUiState; + +export type UiActions = + & { + setLayoutSettingsOpen: (value: boolean) => void; + setPdfExportDialogOpen: (value: boolean) => void; + setPdfExportOptions: (value: PdfExportOptions) => void; + resetPdfExportOptions: () => void; + setPdfPageSize: (value: PageSize) => void; + setPdfOrientation: (value: Orientation) => void; + setPdfFontSize: (value: number) => void; + setPdfMargin: (side: MarginSide, value: number) => void; + setPdfIncludeTitle: (value: boolean) => void; + setPdfIncludeHeader: (value: boolean) => void; + setPdfIncludeFooter: (value: boolean) => void; + setGlobalCaptureSettings: (value: GlobalCaptureSettings) => void; + setQuickCaptureEnabled: (enabled: boolean) => Promise; + setHelpSheetOpen: (value: boolean) => void; + toggleHelpSheet: () => void; + setStyleDiagnosticsOpen: (value: boolean) => void; + toggleStyleDiagnostics: () => void; + } + & AtProtoUiActions + & StandardSiteUiActions; export type AppStore = & LayoutState diff --git a/src-tauri/src/commands/standard_site.rs b/src-tauri/src/commands/standard_site.rs --- a/src-tauri/src/commands/standard_site.rs +++ b/src-tauri/src/commands/standard_site.rs @@ -3,14 +3,14 @@ use super::{AppState, CommandResponse}; use tauri::State; use writer_core::{ - atproto::{PostRecord, PublicationRecord}, + atproto::{PostRecord, PublicationListResult, PublicationRecord}, CommandResult, }; #[tauri::command] pub async fn publication_list( state: State<'_, AppState>, did_or_handle: String, -) -> CommandResponse> { +) -> CommandResponse { log::info!("Listing Standard.Site publications"); match state.atproto.publication_list(&did_or_handle).await { diff --git a/src/components/AppLayout/WorkspacePanel.tsx b/src/components/AppLayout/WorkspacePanel.tsx --- a/src/components/AppLayout/WorkspacePanel.tsx +++ b/src/components/AppLayout/WorkspacePanel.tsx @@ -59,6 +59,7 @@ | "onRefresh" >; onOpenImportSheet?: () => void; + onOpenStandardSiteImportSheet?: () => void; editor: WorkspaceEditorProps; preview: WorkspacePreviewProps; statusBar: StatusBarProps; @@ -158,7 +159,8 @@ } export function WorkspacePanel( - { toolbar, onOpenImportSheet, editor, preview, statusBar, diagnostics }: WorkspacePanelProps, + { toolbar, onOpenImportSheet, onOpenStandardSiteImportSheet, editor, preview, statusBar, diagnostics }: + WorkspacePanelProps, ) { const skipAnimation = useSkipAnimation(); const { viewportWidth } = useViewportTier(FALLBACK_VIEWPORT_WIDTH); @@ -276,7 +278,10 @@ {...sidebarMotionProps} className="relative flex h-full shrink-0" style={sidebarStyle}> - +
; -type AtProtoAuthSheetProps = { controller: Controller }; +type AtProtoAuthSheetProps = { controller: Controller; onOpenStandardSiteImport?: () => void }; function AuthSheetHeader( { title, description, mode, onBack }: { @@ -111,7 +111,27 @@ ); } -function SessionView({ controller }: { controller: Controller }) { +function SessionActions( + { controller, onOpenStandardSiteImport }: { controller: Controller; onOpenStandardSiteImport?: () => void }, +) { + return ( +
+
+ + {onOpenStandardSiteImport && ( + + )} +
+ +
+ ); +} + +function SessionView( + { controller, onOpenStandardSiteImport }: { controller: Controller; onOpenStandardSiteImport?: () => void }, +) { const endpoint = useMemo(() => controller.session?.endpoint ?? "Unknown endpoint", [controller.session]); return ( <> @@ -125,18 +145,13 @@
PDS endpoint
{endpoint}
-
- - -
+ ); } -export function AtProtoAuthSheet({ controller }: AtProtoAuthSheetProps) { +export function AtProtoAuthSheet({ controller, onOpenStandardSiteImport }: AtProtoAuthSheetProps) { return ( <>
- {controller.sheetMode === "session" && } + {controller.sheetMode === "session" && ( + + )} {controller.sheetMode === "login" && }
diff --git a/src/components/Sidebar/Sidebar.tsx b/src/components/Sidebar/Sidebar.tsx --- a/src/components/Sidebar/Sidebar.tsx +++ b/src/components/Sidebar/Sidebar.tsx @@ -8,6 +8,7 @@ FolderIcon, GithubIcon, RefreshIcon, + StandardSiteIcon, Tangled, } from "$icons"; import { useSidebarState } from "$state/selectors"; @@ -30,7 +31,11 @@ const EMPTY_DOCUMENTS: DocMeta[] = []; const EMPTY_DIRECTORIES: string[] = []; -export type SidebarProps = { onNewDocument?: (locationId?: number) => void; onOpenImportSheet?: () => void }; +export type SidebarProps = { + onNewDocument?: (locationId?: number) => void; + onOpenImportSheet?: () => void; + onOpenStandardSiteImportSheet?: () => void; +}; type SidebarActionsProps = { onAddLocation: () => void; @@ -86,14 +91,25 @@ ); -const ImportButton = ({ onOpenImportSheet, gh = false }: { onOpenImportSheet: () => void; gh?: boolean }) => ( - ); -export function Sidebar({ onNewDocument, onOpenImportSheet }: SidebarProps) { +export function Sidebar({ onNewDocument, onOpenImportSheet, onOpenStandardSiteImportSheet }: SidebarProps) { const { handleAddLocation, handleRemoveLocation, @@ -323,7 +339,17 @@ - {onOpenImportSheet && } +
+ {onOpenStandardSiteImportSheet && ( + + )} + {onOpenImportSheet && ( + + )} +
; + +type PostImportSheetProps = { controller: Controller; isOpen: boolean; onClose: () => void; onBack?: () => void }; + +function SheetTitle() { + return

Import from Standard.Site

; +} + +function SheetHeader({ onBack }: { onBack?: () => void }) { + return ( +
+
+
+ +

+ Browse any AT Protocol handle, select a publication and post, preview the converted Markdown, and save it + into one of your locations. +

+
+ {onBack && } +
+
+ ); +} + +function BrowseHandleForm({ controller }: { controller: Controller }) { + const handleChange = useCallback>((event) => { + controller.setHandle(event.target.value); + }, [controller]); + + const handleKeyDown = useCallback((event: React.KeyboardEvent) => { + if ( + event.key === "Enter" && !controller.importState.isListingPublications && controller.importState.handle.trim() + ) { + controller.handleBrowsePublications(); + } + }, [controller]); + + return ( +
+ +
+ ); +} + +type PublicationRowProps = { publication: PublicationRecord; isSelected: boolean; onSelect: (tid: string) => void }; + +function PublicationRow({ publication, isSelected, onSelect }: PublicationRowProps) { + const handleClick = useCallback(() => { + onSelect(publication.tid); + }, [onSelect, publication.tid]); + + return ( + + ); +} + +function PublicationsPanel({ controller }: { controller: Controller }) { + const handleSelect = useCallback((tid: string) => { + controller.handleSelectPublication(tid); + }, [controller]); + const skippedCount = controller.importState.skippedInvalidPublicationCount; + + return ( +
+
+ {controller.importState.browseHandle + ? `Publications for ${controller.importState.browseHandle}` + : "Publications"} +
+ {skippedCount > 0 && ( +
+ Skipped {skippedCount} invalid publication{skippedCount === 1 ? "" : "s"} while loading this account. +
+ )} +
+ {controller.importState.publications.length === 0 + ? ( +
+ {controller.importState.isListingPublications + ? "Loading publications..." + : skippedCount > 0 + ? "No valid publications loaded for this account." + : "No publications loaded. Enter a handle and browse."} +
+ ) + : controller.importState.publications.map((pub) => ( + + ))} +
+
+ ); +} + +function PostRow( + { post, isSelected, onSelect }: { post: PostRecord; isSelected: boolean; onSelect: (tid: string) => void }, +) { + const handleClick = useCallback(() => { + onSelect(post.tid); + }, [onSelect, post.tid]); + + return ( + + ); +} + +function PostsPanel({ controller }: { controller: Controller }) { + const handleSelect = useCallback((tid: string) => { + controller.handleSelectPost(tid); + }, [controller]); + + return ( +
+
+ Posts +
+
+ {controller.importState.posts.length === 0 + ? ( +
+ {controller.importState.isListingPosts + ? "Loading posts..." + : controller.importState.selectedPublicationTid + ? "No posts in this publication." + : "Select a publication to see its posts."} +
+ ) + : controller.importState.posts.map((post) => ( + + ))} +
+
+ ); +} + +function SelectedPostSummary({ controller }: { controller: Controller }) { + const title = useMemo(() => controller.importState.selectedPost?.title ?? "Nothing selected", [ + controller.importState.selectedPost, + ]); + + return ( +
+
{title}
+

+ {controller.importState.selectedPost?.description || "Select a post to preview its content."} +

+ {controller.importState.selectedPost && controller.importState.selectedPost.tags.length > 0 && ( +
+ {controller.importState.selectedPost.tags.map((tag) => ( + + {tag} + + ))} +
+ )} +
+ ); +} + +type LocationSelectProps = { + value: number | null; + hasLocations: boolean; + locations: Controller["locations"]; + onChange: ChangeEventHandler; +}; + +function LocationSelect({ value, hasLocations, locations, onChange }: LocationSelectProps) { + return ( + + ); +} + +function ImportDestinationForm({ controller }: { controller: Controller }) { + const handleLocationChange = useCallback>((event) => { + controller.setDestinationLocationId(Number(event.target.value) || null); + }, [controller]); + + const handlePathChange = useCallback>((event) => { + controller.setDestinationRelPath(event.target.value); + }, [controller]); + + const handleImport = useCallback(() => { + void controller.handleImport(); + }, [controller]); + + const importDisabled = useMemo( + () => + controller.importState.isSaving + || !controller.importState.selectedPost + || !controller.importState.destinationLocationId + || !controller.importState.destinationRelPath.trim() + || !controller.hasLocations + || !controller.importState.previewMarkdown, + [ + controller.importState.isSaving, + controller.importState.selectedPost, + controller.importState.destinationLocationId, + controller.importState.destinationRelPath, + controller.hasLocations, + controller.importState.previewMarkdown, + ], + ); + + return ( +
+
+ + +
+
+

Leaflet content is converted to Markdown on import.

+ +
+
+ ); +} + +function PreviewPanel({ controller }: { controller: Controller }) { + return ( +
+
+ Preview +
+
+        {controller.importState.isFetchingPreview
+          ? "Loading post preview..."
+          : controller.importState.previewMarkdown || "Select a post to preview the imported Markdown."}
+      
+
+ ); +} + +function SheetBody({ controller }: { controller: Controller }) { + return ( +
+
+
+ + + +
+
+ + + +
+
+
+ ); +} + +export function PostImportSheet({ controller, isOpen, onClose, onBack }: PostImportSheetProps) { + return ( + +
+ + +
+
+ ); +} diff --git a/src/hooks/controllers/useStandardSiteController.ts b/src/hooks/controllers/useStandardSiteController.ts new file mode 100644 --- /dev/null +++ b/src/hooks/controllers/useStandardSiteController.ts @@ -0,0 +1,343 @@ +import { 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"; +import { f } from "$utils/serialize"; +import * as logger from "@tauri-apps/plugin-log"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +type UseStandardSiteControllerOptions = { + locations: LocationDescriptor[]; + selectedLocationId?: number; + refreshSidebar: (locationId?: number) => void; +}; + +type ImportState = { + handle: string; + browseHandle: string; + publications: PublicationRecord[]; + skippedInvalidPublicationCount: number; + selectedPublicationTid: string | null; + posts: PostRecord[]; + selectedPostTid: string | null; + selectedPost: PostRecord | null; + previewMarkdown: string; + destinationLocationId: number | null; + destinationRelPath: string; + isListingPublications: boolean; + isListingPosts: boolean; + isFetchingPreview: boolean; + isSaving: boolean; +}; + +function getDefaultLocationId(locations: LocationDescriptor[], selectedLocationId?: number): number | null { + if (selectedLocationId && locations.some((location) => location.id === selectedLocationId)) { + return selectedLocationId; + } + + return locations[0]?.id ?? null; +} + +function normalizeImportPath(value: string): string { + return value.trim().replaceAll("\\", "/").replace(/^\/+/, ""); +} + +function deriveDefaultRelPath(post: PostRecord): string { + const slug = post.title.toLowerCase().trim().replaceAll(/[^a-z0-9]+/g, "-").replaceAll(/^-+|-+$/g, ""); + return slug ? `${slug}.md` : "post.md"; +} + +export function useStandardSiteController( + { locations, selectedLocationId, refreshSidebar }: UseStandardSiteControllerOptions, +) { + const { sheetMode, openPostImportSheet, closeSheet } = useStandardSiteUiState(); + + const [handle, setHandle] = useState(""); + const [browseHandle, setBrowseHandle] = useState(""); + const [publications, setPublications] = useState([]); + const [skippedInvalidPublicationCount, setSkippedInvalidPublicationCount] = useState(0); + const [selectedPublicationTid, setSelectedPublicationTid] = useState(null); + const [posts, setPosts] = useState([]); + const [selectedPostTid, setSelectedPostTid] = useState(null); + const [selectedPost, setSelectedPost] = useState(null); + const [previewMarkdown, setPreviewMarkdown] = useState(""); + const [destinationLocationId, setDestinationLocationId] = useState(null); + const [destinationRelPath, setDestinationRelPath] = useState(""); + const [isListingPublications, setIsListingPublications] = useState(false); + const [isListingPosts, setIsListingPosts] = useState(false); + const [isFetchingPreview, setIsFetchingPreview] = useState(false); + const [isSaving, setIsSaving] = useState(false); + + const pubListRequestIdRef = useRef(0); + const postListRequestIdRef = useRef(0); + const previewRequestIdRef = useRef(0); + + useEffect(() => { + if (destinationLocationId !== null && locations.some((location) => location.id === destinationLocationId)) { + return; + } + + setDestinationLocationId(getDefaultLocationId(locations, selectedLocationId)); + }, [destinationLocationId, locations, selectedLocationId]); + + const openImportSheet = useCallback(() => { + setDestinationLocationId((current) => current ?? getDefaultLocationId(locations, selectedLocationId)); + openPostImportSheet(); + }, [locations, openPostImportSheet, selectedLocationId]); + + const handleSelectPost = useCallback((tid: string, ownerHandle = browseHandle) => { + const trimmedTid = tid.trim(); + const trimmedOwner = ownerHandle.trim(); + if (!trimmedTid || !trimmedOwner) { + return; + } + + setSelectedPostTid(trimmedTid); + setIsFetchingPreview(true); + setPreviewMarkdown(""); + const requestId = previewRequestIdRef.current + 1; + previewRequestIdRef.current = requestId; + + void runCmd(postGetMarkdown(trimmedOwner, trimmedTid, (markdown) => { + if (previewRequestIdRef.current !== requestId) { + return; + } + + setIsFetchingPreview(false); + setPreviewMarkdown(markdown); + }, (error) => { + if (previewRequestIdRef.current !== requestId) { + return; + } + + setIsFetchingPreview(false); + logger.error(f("Failed to fetch post markdown", { owner: trimmedOwner, tid: trimmedTid, error })); + showErrorToast(error.message); + })); + }, [browseHandle]); + + const handleSelectPostWithRecord = useCallback((tid: string, allPosts: PostRecord[], ownerHandle = browseHandle) => { + const post = allPosts.find((p) => p.tid === tid) ?? null; + setSelectedPost(post); + if (post) { + setDestinationRelPath(deriveDefaultRelPath(post)); + } + handleSelectPost(tid, ownerHandle); + }, [browseHandle, handleSelectPost]); + + const handleSelectPublication = useCallback((publicationTid: string, ownerHandle = browseHandle) => { + const trimmedHandle = ownerHandle.trim(); + if (!trimmedHandle || isListingPosts) { + return; + } + + setSelectedPublicationTid(publicationTid); + setPosts([]); + setSelectedPostTid(null); + setSelectedPost(null); + setPreviewMarkdown(""); + setDestinationRelPath(""); + setIsListingPosts(true); + const requestId = postListRequestIdRef.current + 1; + postListRequestIdRef.current = requestId; + + void runCmd(postList(trimmedHandle, publicationTid, (nextPosts) => { + if (postListRequestIdRef.current !== requestId) { + return; + } + + setIsListingPosts(false); + setPosts(nextPosts); + + if (nextPosts.length === 0) { + return; + } + + const firstPost = nextPosts[0]; + if (!firstPost) { + return; + } + + handleSelectPostWithRecord(firstPost.tid, nextPosts, trimmedHandle); + }, (error) => { + if (postListRequestIdRef.current !== requestId) { + return; + } + + setIsListingPosts(false); + logger.error(f("Failed to list posts", { handle: trimmedHandle, publicationTid, error })); + showErrorToast(error.message); + })); + }, [browseHandle, handleSelectPostWithRecord, isListingPosts]); + + const handleBrowsePublications = useCallback(() => { + const trimmedHandle = handle.trim(); + if (!trimmedHandle || isListingPublications) { + return; + } + + setIsListingPublications(true); + setBrowseHandle(trimmedHandle); + setPublications([]); + setSkippedInvalidPublicationCount(0); + setSelectedPublicationTid(null); + setPosts([]); + setSelectedPostTid(null); + setSelectedPost(null); + setPreviewMarkdown(""); + setDestinationRelPath(""); + const requestId = pubListRequestIdRef.current + 1; + pubListRequestIdRef.current = requestId; + + void runCmd(publicationList(trimmedHandle, (result) => { + if (pubListRequestIdRef.current !== requestId) { + return; + } + + const nextPublications = result.publications; + setIsListingPublications(false); + setPublications(nextPublications); + setSkippedInvalidPublicationCount(result.skippedInvalidCount); + + if (result.skippedInvalidCount > 0) { + showWarnToast( + `Skipped ${result.skippedInvalidCount} invalid publication${result.skippedInvalidCount === 1 ? "" : "s"}.`, + ); + } + + if (nextPublications.length === 0) { + return; + } + + const firstPub = nextPublications[0]; + if (!firstPub) { + return; + } + + handleSelectPublication(firstPub.tid, trimmedHandle); + }, (error) => { + if (pubListRequestIdRef.current !== requestId) { + return; + } + + setIsListingPublications(false); + setSkippedInvalidPublicationCount(0); + logger.error(f("Failed to list publications", { handle: trimmedHandle, error })); + showErrorToast(error.message); + })); + }, [handle, handleSelectPublication, isListingPublications]); + + const handleImport = useCallback(async () => { + if (isSaving || !selectedPost || !destinationLocationId) { + return; + } + + const relPath = normalizeImportPath(destinationRelPath); + if (!relPath) { + showErrorToast("Choose a destination path before importing."); + return; + } + + if (!previewMarkdown) { + showErrorToast("No post content loaded. Select a post first."); + return; + } + + setIsSaving(true); + + const alreadyExists = await new Promise((resolve) => { + void runCmd(docExists(destinationLocationId, relPath, (exists) => { + resolve(exists); + }, (error) => { + logger.error(f("Failed to check import destination", { destinationLocationId, relPath, error })); + showErrorToast(error.message); + resolve(true); + })); + }); + + if (alreadyExists) { + setIsSaving(false); + showErrorToast(`A document already exists at ${relPath}. Choose a different path.`); + return; + } + + const saved = await new Promise((resolve) => { + void runCmd(docSave(destinationLocationId, relPath, previewMarkdown, (result) => { + resolve(result.success); + }, (error) => { + logger.error(f("Failed to import post", { destinationLocationId, relPath, tid: selectedPost.tid, error })); + showErrorToast(error.message); + resolve(false); + })); + }); + + setIsSaving(false); + if (!saved) { + return; + } + + refreshSidebar(destinationLocationId); + closeSheet(); + showSuccessToast(`Imported "${selectedPost.title}" into ${relPath}`); + }, [closeSheet, destinationLocationId, destinationRelPath, isSaving, previewMarkdown, refreshSidebar, selectedPost]); + + const importState = useMemo( + () => ({ + handle, + browseHandle, + publications, + skippedInvalidPublicationCount, + selectedPublicationTid, + posts, + selectedPostTid, + selectedPost, + previewMarkdown, + destinationLocationId, + destinationRelPath, + isListingPublications, + isListingPosts, + isFetchingPreview, + isSaving, + }), + [ + browseHandle, + destinationLocationId, + destinationRelPath, + handle, + isFetchingPreview, + isListingPosts, + isListingPublications, + isSaving, + posts, + previewMarkdown, + publications, + skippedInvalidPublicationCount, + selectedPost, + selectedPostTid, + selectedPublicationTid, + ], + ); + + return { + sheetMode, + importState, + openImportSheet, + closeSheet, + setHandle, + setDestinationLocationId, + setDestinationRelPath, + handleBrowsePublications, + handleSelectPublication: (publicationTid: string) => handleSelectPublication(publicationTid), + handleSelectPost: (tid: string) => { + const post = posts.find((p) => p.tid === tid) ?? null; + setSelectedPost(post); + if (post) { + setDestinationRelPath(deriveDefaultRelPath(post)); + } + handleSelectPost(tid); + }, + handleImport, + locations, + hasLocations: locations.length > 0, + }; +} diff --git a/src/hooks/controllers/useWorkspaceViewController.ts b/src/hooks/controllers/useWorkspaceViewController.ts --- a/src/hooks/controllers/useWorkspaceViewController.ts +++ b/src/hooks/controllers/useWorkspaceViewController.ts @@ -5,6 +5,7 @@ import { useEditorPreviewEffects } from "$hooks/app/useEditorPreviewEffects"; import { useSettingsSync } from "$hooks/app/useSettingsSync"; import { useAtProtoController } from "$hooks/controllers/useAtProtoController"; +import { useStandardSiteController } from "$hooks/controllers/useStandardSiteController"; import { useWorkspaceController } from "$hooks/controllers/useWorkspaceController"; import { useDocumentActions } from "$hooks/useDocumentActions"; import { useEditor } from "$hooks/useEditor"; @@ -36,6 +37,7 @@ editorFontFamily: EditorFontFamily; editorText: string; atProto: ReturnType; + standardSite: ReturnType; }; export function deriveWordCount(text: string, renderWordCount: number | undefined): number { @@ -84,6 +86,11 @@ handleRefreshSidebar, } = useWorkspaceController(); const atProto = useAtProtoController({ locations, selectedLocationId, refreshSidebar: handleRefreshSidebar }); + const standardSite = useStandardSiteController({ + locations, + selectedLocationId, + refreshSidebar: handleRefreshSidebar, + }); const { handleSave, handleNewDocument } = useDocumentActions({ editorDocRef: editorModel.docRef, @@ -293,6 +300,7 @@ () => ({ toolbar: toolbarProps, onOpenImportSheet: atProto.openImportSheet, + onOpenStandardSiteImportSheet: standardSite.openImportSheet, editor: editorProps, preview: previewProps, statusBar: statusBarProps, @@ -308,6 +316,7 @@ [ toolbarProps, atProto.openImportSheet, + standardSite.openImportSheet, editorProps, previewProps, statusBarProps, @@ -328,5 +337,6 @@ editorFontFamily: editorPresentation.fontFamily, editorText: editorModel.text, atProto, + standardSite, }; } diff --git a/src/state/stores/ui.ts b/src/state/stores/ui.ts --- a/src/state/stores/ui.ts +++ b/src/state/stores/ui.ts @@ -32,6 +32,7 @@ atProtoSession: null, atProtoHydrated: false, atProtoPending: false, + standardSiteSheetMode: "closed", }); export const useUiStore = create()((set, get) => ({ @@ -85,6 +86,8 @@ setAtProtoSession: (value) => set({ atProtoSession: value }), setAtProtoHydrated: (value) => set({ atProtoHydrated: value }), setAtProtoPending: (value) => set({ atProtoPending: value }), + openStandardSitePostImportSheet: () => set({ standardSiteSheetMode: "postImport" }), + closeStandardSiteSheet: () => set({ standardSiteSheetMode: "closed" }), })); export function resetUiStore(): void { diff --git a/crates/core/src/atproto/auth.rs b/crates/core/src/atproto/auth.rs --- a/crates/core/src/atproto/auth.rs +++ b/crates/core/src/atproto/auth.rs @@ -1,6 +1,7 @@ use crate::{AppError, ErrorCode}; use jacquard::IntoStatic; use jacquard::client::FileAuthStore; +use jacquard::deps::fluent_uri::Uri; use jacquard::identity::resolver::IdentityResolver; use jacquard::oauth::client::{OAuthClient, OAuthSession}; use jacquard::oauth::loopback::{LoopbackConfig, LoopbackPort}; @@ -276,7 +277,7 @@ pub(crate) async fn resolve_repo_and_pds( &self, did_or_handle: &str, - ) -> Result<(Did<'static>, reqwest::Url), AppError> { + ) -> Result<(Did<'static>, Uri), AppError> { let trimmed = did_or_handle.trim(); if trimmed.is_empty() { return Err(AppError::new( @@ -293,19 +294,18 @@ })?; match identifier { - AtIdentifier::Did(did) => { - let pds = self - .resolver + AtIdentifier::Did(did) => Ok(( + did.clone().into_static(), + self.resolver .pds_for_did(&did) .await - .map_err(|error| AppError::io(format!("Failed to resolve PDS for DID: {}", error)))?; - Ok((did.into_static(), pds)) - } + .map_err(|error| AppError::io(format!("Failed to resolve PDS for DID: {}", error)))?, + )), AtIdentifier::Handle(handle) => self .resolver .pds_for_handle(&handle) .await - .map_err(|error| AppError::io(format!("Failed to resolve handle: {}", error))), + .map_err(|error| AppError::io(format!("Failed to resolve PDS for handle: {}", error))), } } } diff --git a/crates/core/src/atproto/leaflet.rs b/crates/core/src/atproto/leaflet.rs --- a/crates/core/src/atproto/leaflet.rs +++ b/crates/core/src/atproto/leaflet.rs @@ -4,32 +4,35 @@ nodes::{AstNode, NodeCodeBlock, NodeHeading, NodeMath, NodeValue}, parse_document, }; -use jacquard::api::pub_leaflet::{ - blocks::{ - blockquote::Blockquote, - code::Code, - header::Header, - horizontal_rule::HorizontalRule, - image::{AspectRatio, Image}, - math::Math, - text::Text, - unordered_list::{ListItem, ListItemContent, UnorderedList}, - }, - document::{Document, DocumentPagesItem}, - pages::linear_document::{Block, BlockBlock, LinearDocument}, - richtext::facet::{ - AtMention, Bold, ByteSlice, Code as CodeFacet, DidMention, Facet, FacetFeaturesItem, Italic, Link, - Strikethrough, - }, -}; use jacquard::common::types::{ blob::{Blob, BlobRef, MimeType}, cid::CidLink, ident::AtIdentifier, - string::Uri, + string::AtUri, }; use jacquard::types::did::Did; use jacquard::{IntoStatic, api::pub_leaflet::pages::linear_document::BlockAlignment}; +use jacquard::{ + api::pub_leaflet::{ + blocks::{ + blockquote::Blockquote, + code::Code, + header::Header, + horizontal_rule::HorizontalRule, + image::{AspectRatio, Image}, + math::Math, + text::Text, + unordered_list::{ListItem, ListItemContent, UnorderedList}, + }, + document::{Document, DocumentPagesItem}, + pages::linear_document::{Block, BlockBlock, LinearDocument}, + richtext::facet::{ + AtMention, Bold, ByteSlice, Code as CodeFacet, DidMention, Facet, FacetFeaturesItem, Italic, Link, + Strikethrough, + }, + }, + types::uri::UriValue, +}; use std::cmp::Reverse; const CANVAS_PAGE_OMITTED: &str = ""; @@ -650,10 +653,10 @@ } if url.starts_with("at://") { - let uri = Uri::new_owned(url) + let uri = AtUri::new_owned(url) .map_err(|error| AppError::new(ErrorCode::Parse, format!("Invalid at:// mention URI: {}", error)))?; return Ok(FacetFeaturesItem::AtMention(Box::new( - AtMention::new().at_uri(uri).build(), + AtMention::new().at_uri(UriValue::At(uri)).build(), ))); } @@ -777,7 +780,10 @@ ))), wrap_block(BlockBlock::Iframe(Box::new( Iframe::new() - .url(Uri::new("https://example.com/embed").unwrap()) + .url(UriValue::Https( + jacquard::deps::fluent_uri::Uri::parse("https://example.com/embed".to_string()) + .expect("Invalid URI"), + )) .build(), ))), ]) diff --git a/crates/core/src/atproto/mod.rs b/crates/core/src/atproto/mod.rs --- a/crates/core/src/atproto/mod.rs +++ b/crates/core/src/atproto/mod.rs @@ -4,5 +4,5 @@ pub mod strings; pub use auth::{AtProtoState, SessionInfo}; -pub use standard_site::{PostRecord, PublicationRecord}; +pub use standard_site::{PostRecord, PublicationListResult, PublicationRecord}; pub use strings::StringRecord; diff --git a/crates/core/src/atproto/standard_site.rs b/crates/core/src/atproto/standard_site.rs --- a/crates/core/src/atproto/standard_site.rs +++ b/crates/core/src/atproto/standard_site.rs @@ -1,15 +1,13 @@ use crate::{AppError, ErrorCode}; use jacquard::IntoStatic; use jacquard::api::com_atproto::repo::{get_record::GetRecord, list_records::ListRecords}; -use jacquard::api::pub_leaflet::{ - content::{Content as LeafletContent, ContentPagesItem}, - document::{Document as LeafletDocument, DocumentPagesItem}, -}; +use jacquard::api::pub_leaflet::content::{Content as LeafletContent, ContentPagesItem}; +use jacquard::api::pub_leaflet::document::{Document as LeafletDocument, DocumentPagesItem}; use jacquard::api::site_standard::{document::Document as StandardSiteDocument, publication::Publication}; use jacquard::common::CowStr; use jacquard::common::types::value::{Data, from_data}; use jacquard::common::types::{collection::Collection, ident::AtIdentifier, recordkey::RecordKey, string::AtUri}; -use jacquard::types::did::Did; +use jacquard::types::{did::Did, uri::UriValue}; use jacquard::xrpc::XrpcExt; use serde::Serialize; @@ -23,6 +21,13 @@ pub name: String, pub description: String, pub url: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PublicationListResult { + pub publications: Vec, + pub skipped_invalid_count: usize, } impl PublicationRecord { @@ -73,23 +78,29 @@ } impl super::auth::AtProtoState { - pub async fn publication_list(&self, did_or_handle: &str) -> Result, AppError> { + pub async fn publication_list(&self, did_or_handle: &str) -> Result { let publications = self .list_records(did_or_handle, Publication::nsid(), "Standard.Site publications") .await?; - publications - .into_iter() - .map(|(uri, data)| { - let publication = parse_publication(&data)?; - PublicationRecord::from_publication(&uri, publication).ok_or_else(|| { - AppError::new( - ErrorCode::Parse, - "Failed to derive Standard.Site publication record key from URI", - ) - }) - }) - .collect() + let mut valid_publications = Vec::with_capacity(publications.len()); + let mut skipped_invalid_count = 0; + + for (uri, data) in publications { + match parse_publication_record(&uri, &data) { + Ok(publication) => valid_publications.push(publication), + Err(error) => { + skipped_invalid_count += 1; + log::warn!( + "Skipping invalid Standard.Site publication record: uri={} error={}", + uri, + error + ); + } + } + } + + Ok(PublicationListResult { publications: valid_publications, skipped_invalid_count }) } pub async fn publication_get(&self, did_or_handle: &str, tid: &str) -> Result { @@ -256,6 +267,17 @@ } } +fn parse_publication_record(uri: &str, data: &Data<'static>) -> Result { + let publication = parse_publication(data)?; + + PublicationRecord::from_publication(uri, publication).ok_or_else(|| { + AppError::new( + ErrorCode::Parse, + "Failed to derive Standard.Site publication record key from URI", + ) + }) +} + fn parse_publication(data: &Data<'static>) -> Result, AppError> { from_data::>(data) .map(|value| value.into_static()) @@ -292,7 +314,7 @@ publication_uri == filter || record_key_from_uri(publication_uri).as_deref() == Some(filter) } -fn site_as_at_uri(site: &jacquard::common::types::string::Uri<'_>) -> Result>, AppError> { +fn site_as_at_uri(site: &UriValue) -> Result>, AppError> { if !site.as_str().starts_with("at://") { return Ok(None); } diff --git a/src/components/AppLayout/LayoutSettingsPanel/AtProtoSection.tsx b/src/components/AppLayout/LayoutSettingsPanel/AtProtoSection.tsx --- a/src/components/AppLayout/LayoutSettingsPanel/AtProtoSection.tsx +++ b/src/components/AppLayout/LayoutSettingsPanel/AtProtoSection.tsx @@ -18,13 +18,15 @@

{session ? "Your Tangled session is stored locally and reused across app launches." - : "Connect an AT Protocol account to publish and import Tangled strings."} + : "Connect an AT Protocol account to publish and import Tangled strings. Standard.Site post import is available without connecting."}

- +
+ +
{session && (