Markdown -> Semble importer

Add Semble import UI and ATProto client logic

oscillatory.net d8a1b9e6 50747fc5

verified
+2248 -134
+21 -35
README.md
··· 1 - # Svelte + TS + Vite 2 - 3 - This template should help get you started developing with Svelte and TypeScript in Vite. 4 - 5 - ## Recommended IDE Setup 6 - 7 - [VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode). 8 - 9 - ## Need an official Svelte framework? 10 - 11 - Check out [SvelteKit](https://github.com/sveltejs/kit#readme), which is also powered by Vite. Deploy anywhere with its serverless-first approach and adapt to various platforms, with out of the box support for TypeScript, SCSS, and Less, and easily-added support for mdsvex, GraphQL, PostCSS, Tailwind CSS, and more. 12 - 13 - ## Technical considerations 14 - 15 - **Why use this over SvelteKit?** 16 - 17 - - It brings its own routing solution which might not be preferable for some users. 18 - - It is first and foremost a framework that just happens to use Vite under the hood, not a Vite app. 19 - 20 - This template contains as little as possible to get started with Vite + TypeScript + Svelte, while taking into account the developer experience with regards to HMR and intellisense. It demonstrates capabilities on par with the other `create-vite` templates and is a good starting point for beginners dipping their toes into a Vite + Svelte project. 1 + # Semble Import Studio 21 2 22 - Should you later need the extended capabilities and extensibility provided by SvelteKit, the template has been structured similarly to SvelteKit so that it is easy to migrate. 3 + Static Svelte app to stage Semble bookmark imports from a URL or pasted markdown, enrich with Citoid metadata, compare with existing Semble records, and create only new cards/collections. 23 4 24 - **Why `global.d.ts` instead of `compilerOptions.types` inside `jsconfig.json` or `tsconfig.json`?** 5 + ## Local dev 25 6 26 - Setting `compilerOptions.types` shuts out all other types not explicitly listed in the configuration. Using triple-slash references keeps the default TypeScript setting of accepting type information from the entire workspace, while also adding `svelte` and `vite/client` type information. 7 + ```bash 8 + npm install 9 + npm run dev 10 + ``` 27 11 28 - **Why include `.vscode/extensions.json`?** 12 + ## OAuth setup 29 13 30 - Other templates indirectly recommend extensions via the README, but this file allows VS Code to prompt the user to install the recommended extension upon opening the project. 14 + This app uses OAuth PKCE against the ATProto authorization server (default `https://bsky.social`). 31 15 32 - **Why enable `allowJs` in the TS template?** 16 + 1. Update `public/client-metadata.json` with your deployed origin and redirect URI. 17 + 2. Deploy the static build to your subdomain. 18 + 3. Ensure the `Client metadata URL` and `Redirect URI` fields in the app match your deployed origin. 33 19 34 - While `allowJs: false` would indeed prevent the use of `.js` files in the project, it does not prevent the use of JavaScript syntax in `.svelte` files. In addition, it would force `checkJs: false`, bringing the worst of both worlds: not being able to guarantee the entire codebase is TypeScript, and also having worse typechecking for the existing JavaScript. In addition, there are valid use cases in which a mixed codebase may be relevant. 20 + For example, with `https://semble.example.com`: 35 21 36 - **Why is HMR not preserving my local component state?** 22 + - `client_id` should be `https://semble.example.com/client-metadata.json` 23 + - `redirect_uris` should include `https://semble.example.com/` 37 24 38 - HMR state preservation comes with a number of gotchas! It has been disabled by default in both `svelte-hmr` and `@sveltejs/vite-plugin-svelte` due to its often surprising behavior. You can read the details [here](https://github.com/rixo/svelte-hmr#svelte-hmr). 25 + If the OAuth server requires additional scopes or a different authorization server base URL, change them in the UI. 39 26 40 - If you have state that's important to retain within a component, consider creating an external store which would not be replaced by HMR. 27 + ## Source format 41 28 42 - ```ts 43 - // store.ts 44 - // An extremely simple external store 45 - import { writable } from 'svelte/store' 46 - export default writable(0) 29 + ```markdown 30 + # Collection name 31 + - https://example.com : optional note 32 + - [Optional title](https://example.com) 47 33 ```
+2 -2
index.html
··· 2 2 <html lang="en"> 3 3 <head> 4 4 <meta charset="UTF-8" /> 5 - <link rel="icon" type="image/svg+xml" href="/vite.svg" /> 5 + <link rel="icon" type="image/svg+xml" href="/favicon.svg" /> 6 6 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 7 - <title>md-semble-sync</title> 7 + <title>Semble Import Studio</title> 8 8 </head> 9 9 <body> 10 10 <div id="app"></div>
+4
package.json
··· 9 9 "preview": "vite preview", 10 10 "check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json" 11 11 }, 12 + "dependencies": { 13 + "@atproto/api": "^0.18.16", 14 + "microdiff": "^1.5.0" 15 + }, 12 16 "devDependencies": { 13 17 "@sveltejs/vite-plugin-svelte": "^6.2.1", 14 18 "@tsconfig/svelte": "^5.0.6",
+10
public/client-metadata.json
··· 1 + { 2 + "client_id": "https://example.com/client-metadata.json", 3 + "client_name": "Semble Import Studio", 4 + "redirect_uris": ["https://example.com/"], 5 + "token_endpoint_auth_method": "none", 6 + "scope": "atproto", 7 + "grant_types": ["authorization_code", "refresh_token"], 8 + "response_types": ["code"], 9 + "application_type": "web" 10 + }
+5
public/favicon.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"> 2 + <rect width="128" height="128" rx="28" fill="#1c1917" /> 3 + <path d="M32 88h64v12H32zM32 36h64v12H32z" fill="#f4f0e8" /> 4 + <circle cx="64" cy="64" r="18" fill="#f28c28" /> 5 + </svg>
-1
public/vite.svg
··· 1 - <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
+484 -37
src/App.svelte
··· 1 1 <script lang="ts"> 2 - import svelteLogo from './assets/svelte.svg' 3 - import viteLogo from '/vite.svg' 4 - import Counter from './lib/Counter.svelte' 5 - </script> 2 + import {onMount} from "svelte"; 3 + import type {AtpSessionData} from "@atproto/api"; 4 + import {loadStoredSession, saveStoredSession, buildAgent} from "./lib/atproto/session"; 5 + import {resolvePdsServiceUrl} from "./lib/atproto/identity"; 6 + import {listSembleRepoRecords} from "./lib/atproto/repo"; 7 + import {buildMockAtprotoRecords} from "./lib/atproto/record-builder"; 8 + import {createSembleRecords} from "./lib/atproto/create"; 9 + import {mapCitoidPayloadToSembleRecord} from "./lib/metadata/citoid-mapper"; 10 + import {UrlClassifier} from "./lib/metadata/url-classifier"; 11 + import type {SembleUrlMetadata} from "./lib/metadata/citoid"; 12 + import {parseMarkdownSource} from "./lib/parse/markdown"; 13 + import {normalizeSourceUrl, isProbablyUrl} from "./lib/utils/urls"; 14 + import type {SembleCard, SembleCollection, SembleCollectionLink} from "./lib/semble/types"; 15 + import type {RepoRecordGroups} from "./lib/atproto/diff"; 16 + import {beginOAuthLogin, completeOAuthLogin, type OAuthConfig} from "./lib/atproto/oauth"; 17 + 18 + let session: AtpSessionData | null = null; 19 + let serviceUrl: string | null = null; 20 + let authError = ""; 21 + let statusMessage = ""; 22 + 23 + let handle = ""; 24 + let authServer = "https://bsky.social"; 25 + let clientId = ""; 26 + let redirectUri = ""; 27 + let scope = "atproto"; 28 + 29 + let sourceUrl = ""; 30 + let sourceText = ""; 31 + let parsedCards: SembleCard[] = []; 32 + let parsedCollections: SembleCollection[] = []; 33 + 34 + let repoRecords: RepoRecordGroups | null = null; 35 + let repoCardUrls = new Set<string>(); 36 + let repoCollectionNames = new Set<string>(); 37 + 38 + let stagedCards: SembleCard[] = []; 39 + let stagedCollections: SembleCollection[] = []; 40 + let stagedLinks: SembleCollectionLink[] = []; 41 + let draftRecords: ReturnType<typeof buildMockAtprotoRecords> | null = null; 42 + 43 + let busy = false; 44 + 45 + onMount(async () => { 46 + const stored = loadStoredSession(); 47 + if (stored) { 48 + session = stored; 49 + handle = stored.handle ?? ""; 50 + } 51 + 52 + const origin = window.location.origin; 53 + clientId = `${origin}/client-metadata.json`; 54 + redirectUri = `${origin}/`; 55 + 56 + try { 57 + const completed = await completeOAuthLogin(new URL(window.location.href)); 58 + if (completed) { 59 + session = completed; 60 + saveStoredSession(completed); 61 + handle = completed.handle ?? handle; 62 + statusMessage = "OAuth login complete."; 63 + window.history.replaceState({}, "", window.location.pathname); 64 + } 65 + } catch (error) { 66 + authError = error instanceof Error ? error.message : String(error); 67 + } 68 + }); 69 + 70 + async function startOAuth() { 71 + authError = ""; 72 + statusMessage = ""; 73 + if (!handle.trim()) { 74 + authError = "Enter your handle first."; 75 + return; 76 + } 77 + const config: OAuthConfig = { 78 + authServer: authServer.trim(), 79 + clientId: clientId.trim(), 80 + redirectUri: redirectUri.trim(), 81 + scope: scope.trim() 82 + }; 83 + try { 84 + await beginOAuthLogin(config, handle.trim()); 85 + } catch (error) { 86 + authError = error instanceof Error ? error.message : String(error); 87 + } 88 + } 89 + 90 + function signOut() { 91 + session = null; 92 + serviceUrl = null; 93 + saveStoredSession(null); 94 + } 95 + 96 + async function fetchSource() { 97 + statusMessage = ""; 98 + authError = ""; 99 + parsedCards = []; 100 + parsedCollections = []; 101 + stagedCards = []; 102 + stagedCollections = []; 103 + stagedLinks = []; 104 + draftRecords = null; 105 + 106 + const trimmedUrl = sourceUrl.trim(); 107 + const hasUrl = trimmedUrl.length > 0 && isProbablyUrl(trimmedUrl); 108 + if (!hasUrl && !sourceText.trim()) { 109 + authError = "Provide a source URL or paste text."; 110 + return; 111 + } 112 + 113 + busy = true; 114 + try { 115 + let text: string; 116 + let sourceId = "manual-input"; 117 + if (hasUrl) { 118 + const normalized = normalizeSourceUrl(trimmedUrl); 119 + sourceId = normalized; 120 + const response = await fetch(normalized); 121 + if (!response.ok) { 122 + throw new Error(`Source fetch failed: ${response.status}`); 123 + } 124 + text = await response.text(); 125 + } else { 126 + text = sourceText; 127 + } 128 + 129 + const parsed = parseMarkdownSource(sourceId, text); 130 + parsedCards = parsed.cards; 131 + parsedCollections = parsed.collections; 132 + statusMessage = `Parsed ${parsedCards.length} cards.`; 133 + } catch (error) { 134 + authError = error instanceof Error ? error.message : String(error); 135 + } finally { 136 + busy = false; 137 + } 138 + } 139 + 140 + async function loadRepo() { 141 + authError = ""; 142 + statusMessage = ""; 143 + if (!session) { 144 + authError = "Sign in before loading your repo."; 145 + return; 146 + } 147 + 148 + busy = true; 149 + try { 150 + const did = session.did ?? handle.trim(); 151 + serviceUrl = await resolvePdsServiceUrl(did); 152 + const result = await listSembleRepoRecords({serviceUrl, session}); 153 + repoRecords = result.records; 154 + repoCardUrls = extractRepoCardUrls(result.records); 155 + repoCollectionNames = extractRepoCollectionNames(result.records); 156 + statusMessage = `Repo loaded: ${result.summary.cards} cards, ${result.summary.collections} collections.`; 157 + } catch (error) { 158 + authError = error instanceof Error ? error.message : String(error); 159 + } finally { 160 + busy = false; 161 + } 162 + } 163 + 164 + async function stageRecords() { 165 + authError = ""; 166 + statusMessage = ""; 167 + 168 + if (!parsedCards.length) { 169 + authError = "Parse a source first."; 170 + return; 171 + } 172 + if (!session || !serviceUrl) { 173 + authError = "Load your repo before staging records."; 174 + return; 175 + } 176 + 177 + busy = true; 178 + try { 179 + const newCards = parsedCards.filter(card => !repoCardUrls.has(card.url)); 180 + await enrichMetadata(newCards); 181 + 182 + const collectionLinks: SembleCollectionLink[] = []; 183 + for (const card of newCards) { 184 + if (!card.collection) continue; 185 + collectionLinks.push({ 186 + collectionName: card.collection, 187 + cardId: card.id 188 + }); 189 + } 190 + 191 + const usedCollectionNames = new Set( 192 + newCards 193 + .map(card => card.collection) 194 + .filter((name): name is string => Boolean(name)) 195 + ); 196 + const collectionList = parsedCollections.filter(collection => 197 + usedCollectionNames.has(collection.name) && !repoCollectionNames.has(collection.name) 198 + ); 199 + 200 + stagedCards = newCards; 201 + stagedCollections = collectionList; 202 + stagedLinks = collectionLinks; 203 + 204 + draftRecords = buildMockAtprotoRecords({ 205 + cards: stagedCards, 206 + collections: stagedCollections, 207 + collectionLinks: stagedLinks, 208 + did: session.did 209 + }); 210 + 211 + statusMessage = `Staged ${stagedCards.length} cards, ${stagedCollections.length} collections.`; 212 + } catch (error) { 213 + authError = error instanceof Error ? error.message : String(error); 214 + } finally { 215 + busy = false; 216 + } 217 + } 218 + 219 + async function createRecords() { 220 + authError = ""; 221 + statusMessage = ""; 222 + if (!session || !serviceUrl || !draftRecords) { 223 + authError = "Stage records before creating."; 224 + return; 225 + } 226 + 227 + busy = true; 228 + try { 229 + const agent = await buildAgent(serviceUrl, session); 230 + const result = await createSembleRecords(agent, session, draftRecords, repoRecords ?? undefined); 231 + if (result.failures.length > 0) { 232 + authError = `Created ${result.created} records, ${result.failures.length} failed.`; 233 + } else { 234 + statusMessage = `Created ${result.created} records in Semble.`; 235 + } 236 + } catch (error) { 237 + authError = error instanceof Error ? error.message : String(error); 238 + } finally { 239 + busy = false; 240 + } 241 + } 242 + 243 + async function enrichMetadata(cards: SembleCard[]) { 244 + for (const card of cards) { 245 + if (!card.title || !card.metadata) { 246 + const citoid = await fetchCitoidMetadata(card.url); 247 + if (citoid?.metadata) { 248 + card.metadata = mergeMetadata(card.metadata, citoid.metadata); 249 + } 250 + if (citoid?.title && !card.title) { 251 + card.title = citoid.title; 252 + } 253 + } 254 + 255 + applyUrlClassification(card); 256 + } 257 + } 258 + 259 + async function fetchCitoidMetadata( 260 + url: string 261 + ): Promise<{title?: string; metadata?: SembleUrlMetadata} | undefined> { 262 + const encoded = encodeURIComponent(url); 263 + const response = await fetch(`https://en.wikipedia.org/api/rest_v1/data/citation/zotero/${encoded}`); 264 + if (!response.ok) return undefined; 265 + const data = (await response.json()) as unknown; 266 + const record = mapCitoidPayloadToSembleRecord(data); 267 + if (!record) return undefined; 6 268 7 - <main> 8 - <div> 9 - <a href="https://vite.dev" target="_blank" rel="noreferrer"> 10 - <img src={viteLogo} class="logo" alt="Vite Logo" /> 11 - </a> 12 - <a href="https://svelte.dev" target="_blank" rel="noreferrer"> 13 - <img src={svelteLogo} class="logo svelte" alt="Svelte Logo" /> 14 - </a> 15 - </div> 16 - <h1>Vite + Svelte</h1> 269 + return { 270 + title: record.metadata.title?.trim(), 271 + metadata: record.metadata 272 + }; 273 + } 17 274 18 - <div class="card"> 19 - <Counter /> 20 - </div> 275 + function mergeMetadata( 276 + existing: SembleUrlMetadata | undefined, 277 + incoming: SembleUrlMetadata 278 + ): SembleUrlMetadata { 279 + if (!existing) return incoming; 280 + return { 281 + ...incoming, 282 + ...existing 283 + }; 284 + } 21 285 22 - <p> 23 - Check out <a href="https://github.com/sveltejs/kit#readme" target="_blank" rel="noreferrer">SvelteKit</a>, the official Svelte app framework powered by Vite! 24 - </p> 286 + function applyUrlClassification(card: SembleCard): void { 287 + const classified = UrlClassifier.classifyUrl(card.url); 288 + if (!classified) return; 25 289 26 - <p class="read-the-docs"> 27 - Click on the Vite and Svelte logos to learn more 28 - </p> 29 - </main> 290 + if (!card.metadata) { 291 + card.metadata = {type: classified}; 292 + return; 293 + } 294 + card.metadata.type = classified; 295 + } 30 296 31 - <style> 32 - .logo { 33 - height: 6em; 34 - padding: 1.5em; 35 - will-change: filter; 36 - transition: filter 300ms; 297 + function extractRepoCardUrls(existingRepo: RepoRecordGroups): Set<string> { 298 + const urls = new Set<string>(); 299 + for (const entry of existingRepo.cards) { 300 + const url = extractRepoUrl(entry.value); 301 + if (url) urls.add(url); 302 + } 303 + return urls; 37 304 } 38 - .logo:hover { 39 - filter: drop-shadow(0 0 2em #646cffaa); 305 + 306 + function extractRepoCollectionNames(existingRepo: RepoRecordGroups): Set<string> { 307 + const names = new Set<string>(); 308 + for (const entry of existingRepo.collections) { 309 + const name = extractRepoName(entry.value); 310 + if (name) names.add(name); 311 + } 312 + return names; 40 313 } 41 - .logo.svelte:hover { 42 - filter: drop-shadow(0 0 2em #ff3e00aa); 314 + 315 + function extractRepoUrl(value: unknown): string | undefined { 316 + if (!value || typeof value !== "object") return undefined; 317 + const record = value as {url?: unknown; content?: {url?: unknown}}; 318 + const direct = typeof record.url === "string" ? record.url : undefined; 319 + if (direct) return direct; 320 + const nested = record.content; 321 + if (nested && typeof nested === "object" && typeof nested.url === "string") { 322 + return nested.url; 323 + } 324 + return undefined; 43 325 } 44 - .read-the-docs { 45 - color: #888; 326 + 327 + function extractRepoName(value: unknown): string | undefined { 328 + if (!value || typeof value !== "object") return undefined; 329 + const record = value as {name?: unknown}; 330 + return typeof record.name === "string" ? record.name : undefined; 46 331 } 47 - </style> 332 + 333 + $: oauthReady = Boolean(handle.trim() && clientId.trim() && redirectUri.trim()); 334 + $: stagedReady = stagedCards.length > 0 || stagedCollections.length > 0; 335 + </script> 336 + 337 + <div class="app-shell fade-in"> 338 + <section class="hero"> 339 + <div class="badge">Semble Import Studio</div> 340 + <h1>Stage Semble bookmarks from anywhere.</h1> 341 + <p> 342 + Point at a gist or tangled.org string, enrich with Citoid metadata, compare against your 343 + existing Semble repo, then create only the new records. 344 + </p> 345 + </section> 346 + 347 + <section class="grid"> 348 + <div class="card"> 349 + <h2>1. Connect with ATProto</h2> 350 + <div class="field"> 351 + <label for="handle">Handle</label> 352 + <input id="handle" bind:value={handle} placeholder="alice.bsky.social" /> 353 + </div> 354 + <div class="field"> 355 + <label for="authServer">OAuth server</label> 356 + <input id="authServer" bind:value={authServer} /> 357 + </div> 358 + <div class="field"> 359 + <label for="clientId">Client metadata URL</label> 360 + <input id="clientId" bind:value={clientId} /> 361 + </div> 362 + <div class="field"> 363 + <label for="redirectUri">Redirect URI</label> 364 + <input id="redirectUri" bind:value={redirectUri} /> 365 + </div> 366 + <div class="field"> 367 + <label for="scope">Scopes</label> 368 + <input id="scope" bind:value={scope} /> 369 + </div> 370 + {#if session} 371 + <div class="notice">Signed in as {session.handle ?? session.did}.</div> 372 + <button class="secondary" on:click={signOut}>Sign out</button> 373 + {:else} 374 + <button on:click={startOAuth} disabled={!oauthReady || busy}>Start OAuth login</button> 375 + {/if} 376 + <p class="notice"> 377 + For static hosting, keep `client-metadata.json` on the same origin as this app. 378 + </p> 379 + </div> 380 + 381 + <div class="card"> 382 + <h2>2. Bring a source</h2> 383 + <div class="field"> 384 + <label for="sourceUrl">Source URL</label> 385 + <input id="sourceUrl" bind:value={sourceUrl} placeholder="https://gist.github.com/..." /> 386 + </div> 387 + <div class="field"> 388 + <label for="sourceText">Or paste markdown</label> 389 + <textarea id="sourceText" bind:value={sourceText}></textarea> 390 + </div> 391 + <button class="secondary" on:click={fetchSource} disabled={busy}>Parse source</button> 392 + {#if parsedCards.length > 0} 393 + <div class="notice"> 394 + Parsed {parsedCards.length} cards and {parsedCollections.length} collections. 395 + </div> 396 + {/if} 397 + </div> 398 + 399 + <div class="card"> 400 + <h2>3. Load Semble repo</h2> 401 + <p>Fetch existing records so we only create new URLs.</p> 402 + <button class="secondary" on:click={loadRepo} disabled={!session || busy}>Load repo</button> 403 + {#if repoRecords} 404 + <div class="notice"> 405 + Repo has {repoCardUrls.size} cards, {repoCollectionNames.size} collections. 406 + </div> 407 + {/if} 408 + </div> 409 + 410 + <div class="card"> 411 + <h2>4. Stage and create</h2> 412 + <button on:click={stageRecords} disabled={busy}>Stage records</button> 413 + <button class="secondary" on:click={createRecords} disabled={!stagedReady || busy}> 414 + Create records 415 + </button> 416 + {#if stagedReady} 417 + <div class="notice"> 418 + Staged {stagedCards.length} cards, {stagedCollections.length} collections, {stagedLinks.length} 419 + links. 420 + </div> 421 + {/if} 422 + </div> 423 + </section> 424 + 425 + {#if authError} 426 + <div class="notice">{authError}</div> 427 + {/if} 428 + {#if statusMessage} 429 + <div class="notice">{statusMessage}</div> 430 + {/if} 431 + 432 + {#if stagedReady} 433 + <section class="split"> 434 + <div class="card"> 435 + <h2>Staged cards</h2> 436 + <div class="list"> 437 + {#each stagedCards as card} 438 + <div class="list-item"> 439 + <strong>{card.title ?? card.url}</strong> 440 + <span>{card.url}</span> 441 + {#if card.metadata?.type} 442 + <span class="badge">{card.metadata.type}</span> 443 + {/if} 444 + {#if card.collection} 445 + <span>Collection: {card.collection}</span> 446 + {/if} 447 + {#if card.note} 448 + <em>Note: {card.note}</em> 449 + {/if} 450 + </div> 451 + {/each} 452 + </div> 453 + </div> 454 + <div class="card"> 455 + <h2>Staged collections</h2> 456 + <div class="list"> 457 + {#if stagedCollections.length === 0} 458 + <div class="list-item">No new collections.</div> 459 + {/if} 460 + {#each stagedCollections as collection} 461 + <div class="list-item"> 462 + <strong>{collection.name}</strong> 463 + <span>From line {collection.line}</span> 464 + </div> 465 + {/each} 466 + </div> 467 + <h2>Link preview</h2> 468 + <div class="list"> 469 + {#if stagedLinks.length === 0} 470 + <div class="list-item">No collection links to create.</div> 471 + {/if} 472 + {#each stagedLinks as link} 473 + <div class="list-item"> 474 + <strong>{link.collectionName}</strong> 475 + <span>Card: {link.cardId}</span> 476 + </div> 477 + {/each} 478 + </div> 479 + </div> 480 + </section> 481 + {/if} 482 + 483 + <section class="card"> 484 + <h2>Format reminder</h2> 485 + <p> 486 + Use headings for collections and list items for cards. Add a note with ` : ` after the URL. 487 + </p> 488 + <pre> 489 + # Control 490 + - https://arxiv.org/abs/1234.5678 : Classic paper 491 + - [A neat blog](https://example.com) : read later 492 + </pre> 493 + </section> 494 + </div>
+243 -48
src/app.css
··· 1 + @import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Newsreader:opsz,wght@6..72,300;6..72,500;6..72,700&display=swap"); 2 + 1 3 :root { 2 - font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; 3 - line-height: 1.5; 4 - font-weight: 400; 4 + color-scheme: light; 5 + font-family: "Newsreader", "Times New Roman", serif; 6 + --font-display: "Space Grotesk", "Segoe UI", sans-serif; 7 + --color-ink: #12100f; 8 + --color-ink-muted: #4c433f; 9 + --color-paper: #fdf7ef; 10 + --color-amber: #f28c28; 11 + --color-olive: #3d4a2f; 12 + --color-clay: #c9562a; 13 + --color-moss: #b7c59b; 14 + --color-cream: #f4f0e8; 15 + --color-border: rgba(18, 16, 15, 0.12); 16 + --shadow-soft: 0 20px 50px rgba(18, 16, 15, 0.12); 17 + } 5 18 6 - color-scheme: light dark; 7 - color: rgba(255, 255, 255, 0.87); 8 - background-color: #242424; 19 + * { 20 + box-sizing: border-box; 21 + } 9 22 10 - font-synthesis: none; 11 - text-rendering: optimizeLegibility; 12 - -webkit-font-smoothing: antialiased; 13 - -moz-osx-font-smoothing: grayscale; 23 + body { 24 + margin: 0; 25 + min-height: 100vh; 26 + background: 27 + radial-gradient(circle at 10% 10%, rgba(242, 140, 40, 0.2), transparent 45%), 28 + radial-gradient(circle at 90% 0%, rgba(61, 74, 47, 0.18), transparent 55%), 29 + linear-gradient(135deg, #fbf3e6 0%, #f7efe3 45%, #f4eadb 100%); 30 + color: var(--color-ink); 14 31 } 15 32 16 33 a { 17 - font-weight: 500; 18 - color: #646cff; 19 - text-decoration: inherit; 34 + color: inherit; 20 35 } 21 - a:hover { 22 - color: #535bf2; 36 + 37 + button, 38 + input, 39 + textarea, 40 + select { 41 + font: inherit; 23 42 } 24 43 25 - body { 26 - margin: 0; 27 - display: flex; 28 - place-items: center; 29 - min-width: 320px; 44 + #app { 30 45 min-height: 100vh; 46 + display: flex; 47 + justify-content: center; 31 48 } 32 49 33 - h1 { 34 - font-size: 3.2em; 50 + .app-shell { 51 + width: min(1200px, 100%); 52 + padding: 40px 24px 80px; 53 + display: grid; 54 + gap: 24px; 55 + position: relative; 56 + } 57 + 58 + .hero { 59 + display: grid; 60 + gap: 12px; 61 + padding: 32px 28px; 62 + border-radius: 28px; 63 + background: var(--color-paper); 64 + box-shadow: var(--shadow-soft); 65 + border: 1px solid var(--color-border); 66 + position: relative; 67 + overflow: hidden; 68 + } 69 + 70 + .hero::after { 71 + content: ""; 72 + position: absolute; 73 + inset: -40% 50% 30% -20%; 74 + background: radial-gradient(circle, rgba(242, 140, 40, 0.18), transparent 60%); 75 + transform: rotate(-6deg); 76 + } 77 + 78 + .hero h1 { 79 + font-family: var(--font-display); 80 + font-size: clamp(2.4rem, 4vw, 3.4rem); 35 81 line-height: 1.1; 82 + margin: 0; 83 + } 84 + 85 + .hero p { 86 + margin: 0; 87 + font-size: 1.05rem; 88 + color: var(--color-ink-muted); 89 + max-width: 680px; 90 + } 91 + 92 + .grid { 93 + display: grid; 94 + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); 95 + gap: 20px; 36 96 } 37 97 38 98 .card { 39 - padding: 2em; 99 + background: var(--color-paper); 100 + border-radius: 22px; 101 + padding: 20px; 102 + border: 1px solid var(--color-border); 103 + box-shadow: 0 16px 30px rgba(18, 16, 15, 0.08); 104 + display: grid; 105 + gap: 12px; 106 + } 107 + 108 + .card h2 { 109 + font-family: var(--font-display); 110 + margin: 0; 111 + font-size: 1.3rem; 112 + } 113 + 114 + .field { 115 + display: grid; 116 + gap: 6px; 117 + } 118 + 119 + label { 120 + font-family: var(--font-display); 121 + font-size: 0.85rem; 122 + text-transform: uppercase; 123 + letter-spacing: 0.08em; 124 + color: var(--color-ink-muted); 125 + } 126 + 127 + input, 128 + textarea, 129 + select { 130 + padding: 10px 12px; 131 + border-radius: 12px; 132 + border: 1px solid rgba(18, 16, 15, 0.2); 133 + background: #fff9f2; 134 + transition: border-color 0.2s ease, box-shadow 0.2s ease; 135 + } 136 + 137 + input:focus, 138 + textarea:focus, 139 + select:focus { 140 + outline: none; 141 + border-color: var(--color-amber); 142 + box-shadow: 0 0 0 3px rgba(242, 140, 40, 0.2); 40 143 } 41 144 42 - #app { 43 - max-width: 1280px; 44 - margin: 0 auto; 45 - padding: 2rem; 46 - text-align: center; 145 + textarea { 146 + min-height: 160px; 147 + resize: vertical; 47 148 } 48 149 49 150 button { 50 - border-radius: 8px; 51 - border: 1px solid transparent; 52 - padding: 0.6em 1.2em; 53 - font-size: 1em; 54 - font-weight: 500; 55 - font-family: inherit; 56 - background-color: #1a1a1a; 151 + border: none; 152 + padding: 12px 18px; 153 + border-radius: 999px; 154 + background: var(--color-olive); 155 + color: var(--color-cream); 156 + font-family: var(--font-display); 157 + font-weight: 600; 57 158 cursor: pointer; 58 - transition: border-color 0.25s; 159 + transition: transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease; 59 160 } 161 + 60 162 button:hover { 61 - border-color: #646cff; 163 + transform: translateY(-1px); 164 + box-shadow: 0 8px 18px rgba(61, 74, 47, 0.25); 165 + } 166 + 167 + button.secondary { 168 + background: transparent; 169 + color: var(--color-olive); 170 + border: 1px solid rgba(61, 74, 47, 0.35); 171 + } 172 + 173 + button.ghost { 174 + background: transparent; 175 + color: var(--color-ink); 176 + border: 1px dashed rgba(18, 16, 15, 0.2); 177 + } 178 + 179 + .badge { 180 + display: inline-flex; 181 + align-items: center; 182 + gap: 6px; 183 + border-radius: 999px; 184 + padding: 6px 12px; 185 + background: rgba(61, 74, 47, 0.1); 186 + color: var(--color-olive); 187 + font-family: var(--font-display); 188 + font-size: 0.8rem; 189 + } 190 + 191 + .list { 192 + display: grid; 193 + gap: 12px; 194 + } 195 + 196 + .list-item { 197 + padding: 12px; 198 + border-radius: 14px; 199 + background: #fff9f2; 200 + border: 1px solid rgba(18, 16, 15, 0.08); 201 + display: grid; 202 + gap: 6px; 203 + } 204 + 205 + .list-item strong { 206 + font-family: var(--font-display); 207 + } 208 + 209 + .notice { 210 + padding: 12px 14px; 211 + border-radius: 12px; 212 + background: rgba(242, 140, 40, 0.12); 213 + color: var(--color-ink); 62 214 } 63 - button:focus, 64 - button:focus-visible { 65 - outline: 4px auto -webkit-focus-ring-color; 215 + 216 + .split { 217 + display: grid; 218 + grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); 219 + gap: 20px; 66 220 } 67 221 68 - @media (prefers-color-scheme: light) { 69 - :root { 70 - color: #213547; 71 - background-color: #ffffff; 222 + @media (max-width: 900px) { 223 + .split { 224 + grid-template-columns: 1fr; 72 225 } 73 - a:hover { 74 - color: #747bff; 226 + } 227 + 228 + .fade-in { 229 + animation: fadeIn 0.8s ease forwards; 230 + } 231 + 232 + .stagger > * { 233 + opacity: 0; 234 + transform: translateY(6px); 235 + animation: fadeUp 0.6s ease forwards; 236 + } 237 + 238 + .stagger > *:nth-child(1) { 239 + animation-delay: 0.05s; 240 + } 241 + 242 + .stagger > *:nth-child(2) { 243 + animation-delay: 0.12s; 244 + } 245 + 246 + .stagger > *:nth-child(3) { 247 + animation-delay: 0.18s; 248 + } 249 + 250 + .stagger > *:nth-child(4) { 251 + animation-delay: 0.24s; 252 + } 253 + 254 + @keyframes fadeIn { 255 + from { 256 + opacity: 0; 257 + transform: translateY(4px); 75 258 } 76 - button { 77 - background-color: #f9f9f9; 259 + to { 260 + opacity: 1; 261 + transform: translateY(0); 262 + } 263 + } 264 + 265 + @keyframes fadeUp { 266 + from { 267 + opacity: 0; 268 + transform: translateY(8px); 269 + } 270 + to { 271 + opacity: 1; 272 + transform: translateY(0); 78 273 } 79 274 }
-1
src/assets/svelte.svg
··· 1 - <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="26.6" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 308"><path fill="#FF3E00" d="M239.682 40.707C211.113-.182 154.69-12.301 113.895 13.69L42.247 59.356a82.198 82.198 0 0 0-37.135 55.056a86.566 86.566 0 0 0 8.536 55.576a82.425 82.425 0 0 0-12.296 30.719a87.596 87.596 0 0 0 14.964 66.244c28.574 40.893 84.997 53.007 125.787 27.016l71.648-45.664a82.182 82.182 0 0 0 37.135-55.057a86.601 86.601 0 0 0-8.53-55.577a82.409 82.409 0 0 0 12.29-30.718a87.573 87.573 0 0 0-14.963-66.244"></path><path fill="#FFF" d="M106.889 270.841c-23.102 6.007-47.497-3.036-61.103-22.648a52.685 52.685 0 0 1-9.003-39.85a49.978 49.978 0 0 1 1.713-6.693l1.35-4.115l3.671 2.697a92.447 92.447 0 0 0 28.036 14.007l2.663.808l-.245 2.659a16.067 16.067 0 0 0 2.89 10.656a17.143 17.143 0 0 0 18.397 6.828a15.786 15.786 0 0 0 4.403-1.935l71.67-45.672a14.922 14.922 0 0 0 6.734-9.977a15.923 15.923 0 0 0-2.713-12.011a17.156 17.156 0 0 0-18.404-6.832a15.78 15.78 0 0 0-4.396 1.933l-27.35 17.434a52.298 52.298 0 0 1-14.553 6.391c-23.101 6.007-47.497-3.036-61.101-22.649a52.681 52.681 0 0 1-9.004-39.849a49.428 49.428 0 0 1 22.34-33.114l71.664-45.677a52.218 52.218 0 0 1 14.563-6.398c23.101-6.007 47.497 3.036 61.101 22.648a52.685 52.685 0 0 1 9.004 39.85a50.559 50.559 0 0 1-1.713 6.692l-1.35 4.116l-3.67-2.693a92.373 92.373 0 0 0-28.037-14.013l-2.664-.809l.246-2.658a16.099 16.099 0 0 0-2.89-10.656a17.143 17.143 0 0 0-18.398-6.828a15.786 15.786 0 0 0-4.402 1.935l-71.67 45.674a14.898 14.898 0 0 0-6.73 9.975a15.9 15.9 0 0 0 2.709 12.012a17.156 17.156 0 0 0 18.404 6.832a15.841 15.841 0 0 0 4.402-1.935l27.345-17.427a52.147 52.147 0 0 1 14.552-6.397c23.101-6.006 47.497 3.037 61.102 22.65a52.681 52.681 0 0 1 9.003 39.848a49.453 49.453 0 0 1-22.34 33.12l-71.664 45.673a52.218 52.218 0 0 1-14.563 6.398"></path></svg>
-10
src/lib/Counter.svelte
··· 1 - <script lang="ts"> 2 - let count: number = $state(0) 3 - const increment = () => { 4 - count += 1 5 - } 6 - </script> 7 - 8 - <button onclick={increment}> 9 - count is {count} 10 - </button>
+4
src/lib/atproto/collections.ts
··· 1 + export const BASE_COLLECTION = "network.cosmik"; 2 + export const CARD_COLLECTION = `${BASE_COLLECTION}.card`; 3 + export const COLLECTION_COLLECTION = `${BASE_COLLECTION}.collection`; 4 + export const COLLECTION_LINK_COLLECTION = `${BASE_COLLECTION}.collectionLink`;
+121
src/lib/atproto/create.ts
··· 1 + import type {AtpAgent, AtpSessionData} from "@atproto/api"; 2 + import type {RepoRecordGroups} from "./diff"; 3 + import type {AtprotoDraftRecords, MockStrongRef} from "./record-builder"; 4 + 5 + export async function createSembleRecords( 6 + agent: AtpAgent, 7 + session: AtpSessionData, 8 + drafts: AtprotoDraftRecords, 9 + existing?: RepoRecordGroups 10 + ): Promise<{created: number; failures: Array<{rkey: string; error: string}>}> { 11 + const failures: Array<{rkey: string; error: string}> = []; 12 + let created = 0; 13 + 14 + const collectionRefs = new Map<string, MockStrongRef>(); 15 + if (existing) { 16 + for (const entry of existing.collections) { 17 + const name = extractCollectionName(entry.value); 18 + if (!name || !entry.cid) continue; 19 + collectionRefs.set(name, {uri: entry.uri, cid: entry.cid}); 20 + } 21 + } 22 + 23 + const cardRefs = new Map<string, MockStrongRef>(); 24 + 25 + for (const envelope of drafts.collections) { 26 + try { 27 + const response = await agent.com.atproto.repo.createRecord({ 28 + repo: session.did, 29 + collection: envelope.collection, 30 + rkey: envelope.rkey, 31 + record: envelope.record 32 + }); 33 + if (envelope.recordId && response.data.cid) { 34 + collectionRefs.set(envelope.recordId, {uri: response.data.uri, cid: response.data.cid}); 35 + } 36 + created += 1; 37 + } catch (error) { 38 + failures.push({ 39 + rkey: envelope.rkey, 40 + error: error instanceof Error ? error.message : String(error) 41 + }); 42 + } 43 + } 44 + 45 + for (const envelope of drafts.cards) { 46 + try { 47 + const response = await agent.com.atproto.repo.createRecord({ 48 + repo: session.did, 49 + collection: envelope.collection, 50 + rkey: envelope.rkey, 51 + record: envelope.record 52 + }); 53 + if (envelope.recordId && response.data.cid) { 54 + cardRefs.set(envelope.recordId, {uri: response.data.uri, cid: response.data.cid}); 55 + } 56 + created += 1; 57 + } catch (error) { 58 + failures.push({ 59 + rkey: envelope.rkey, 60 + error: error instanceof Error ? error.message : String(error) 61 + }); 62 + } 63 + } 64 + 65 + for (const envelope of drafts.notes) { 66 + const cardRef = envelope.cardId ? cardRefs.get(envelope.cardId) : undefined; 67 + if (!cardRef) { 68 + failures.push({rkey: envelope.rkey, error: "Missing parent card ref for note."}); 69 + continue; 70 + } 71 + 72 + try { 73 + const record = {...envelope.record, parentCard: cardRef}; 74 + await agent.com.atproto.repo.createRecord({ 75 + repo: session.did, 76 + collection: envelope.collection, 77 + rkey: envelope.rkey, 78 + record 79 + }); 80 + created += 1; 81 + } catch (error) { 82 + failures.push({ 83 + rkey: envelope.rkey, 84 + error: error instanceof Error ? error.message : String(error) 85 + }); 86 + } 87 + } 88 + 89 + for (const envelope of drafts.collectionLinks) { 90 + const collectionRef = envelope.collectionName ? collectionRefs.get(envelope.collectionName) : undefined; 91 + const cardRef = envelope.cardId ? cardRefs.get(envelope.cardId) : undefined; 92 + if (!collectionRef || !cardRef) { 93 + failures.push({rkey: envelope.rkey, error: "Missing collection or card ref for link."}); 94 + continue; 95 + } 96 + 97 + try { 98 + const record = {...envelope.record, collection: collectionRef, card: cardRef}; 99 + await agent.com.atproto.repo.createRecord({ 100 + repo: session.did, 101 + collection: envelope.collection, 102 + rkey: envelope.rkey, 103 + record 104 + }); 105 + created += 1; 106 + } catch (error) { 107 + failures.push({ 108 + rkey: envelope.rkey, 109 + error: error instanceof Error ? error.message : String(error) 110 + }); 111 + } 112 + } 113 + 114 + return {created, failures}; 115 + } 116 + 117 + function extractCollectionName(value: unknown): string | undefined { 118 + if (!value || typeof value !== "object") return undefined; 119 + const record = value as {name?: unknown}; 120 + return typeof record.name === "string" ? record.name : undefined; 121 + }
+224
src/lib/atproto/diff.ts
··· 1 + import microdiff from "microdiff"; 2 + import type {AtprotoDraftRecords} from "./record-builder"; 3 + 4 + export interface RepoRecordEntry { 5 + uri: string; 6 + cid?: string; 7 + value: unknown; 8 + } 9 + 10 + export interface RepoRecordGroups { 11 + cards: RepoRecordEntry[]; 12 + collections: RepoRecordEntry[]; 13 + collectionLinks: RepoRecordEntry[]; 14 + notes: RepoRecordEntry[]; 15 + } 16 + 17 + export interface SembleRepoDiff { 18 + missingInRepo: string[]; 19 + extraInRepo: string[]; 20 + changed: Array<{ 21 + uri: string; 22 + changes: ReturnType<typeof microdiff>; 23 + }>; 24 + } 25 + 26 + export interface DiffOptions { 27 + ignoredFields?: string[]; 28 + } 29 + 30 + type RecordKind = "card" | "collection" | "collectionLink" | "note"; 31 + 32 + export function diffSembleRecords( 33 + existing: RepoRecordGroups, 34 + drafts: AtprotoDraftRecords, 35 + options: DiffOptions = {ignoredFields: ["createdAt"]} 36 + ): SembleRepoDiff { 37 + const ignored = new Set(options.ignoredFields ?? []); 38 + 39 + const repoCanonical = canonicalizeRecords(existing, ignored); 40 + const draftCanonical = canonicalizeRecords(normalizeDraftRecords(drafts), ignored); 41 + 42 + const repoMap = new Map(repoCanonical.map(entry => [entry.key, entry])); 43 + const draftMap = new Map(draftCanonical.map(entry => [entry.key, entry])); 44 + 45 + const missingInRepo: string[] = []; 46 + const extraInRepo: string[] = []; 47 + const changed: Array<{uri: string; changes: ReturnType<typeof microdiff>}> = []; 48 + 49 + for (const [key, draft] of draftMap.entries()) { 50 + const existingRecord = repoMap.get(key); 51 + if (!existingRecord) { 52 + missingInRepo.push(draft.uri); 53 + continue; 54 + } 55 + 56 + const left = existingRecord.value as Record<string, unknown> | Array<unknown>; 57 + const right = draft.value as Record<string, unknown> | Array<unknown>; 58 + const changes = microdiff(left, right); 59 + if (changes.length > 0) { 60 + changed.push({uri: existingRecord.uri, changes}); 61 + } 62 + } 63 + 64 + for (const [key, record] of repoMap.entries()) { 65 + if (!draftMap.has(key)) { 66 + extraInRepo.push(record.uri); 67 + } 68 + } 69 + 70 + return {missingInRepo, extraInRepo, changed}; 71 + } 72 + 73 + interface CanonicalRecord extends RepoRecordEntry { 74 + kind: RecordKind; 75 + key: string; 76 + } 77 + 78 + function canonicalizeRecords(records: RepoRecordGroups, ignored: Set<string>): CanonicalRecord[] { 79 + const result: CanonicalRecord[] = []; 80 + const uriToKey = new Map<string, string>(); 81 + 82 + for (const entry of records.cards) { 83 + const cleaned = scrubIgnoredFields(entry.value, ignored); 84 + const url = extractUrl(cleaned); 85 + const key = buildKey("card", url ?? entry.uri); 86 + result.push({kind: "card", key, uri: entry.uri, value: cleaned}); 87 + uriToKey.set(entry.uri, key); 88 + } 89 + 90 + for (const entry of records.collections) { 91 + const cleaned = scrubIgnoredFields(entry.value, ignored); 92 + const name = extractCollectionName(cleaned); 93 + const key = buildKey("collection", name ?? entry.uri); 94 + result.push({kind: "collection", key, uri: entry.uri, value: cleaned}); 95 + uriToKey.set(entry.uri, key); 96 + } 97 + 98 + for (const entry of records.notes) { 99 + const cleaned = scrubIgnoredFields(entry.value, ignored); 100 + const parentKey = extractParentKey(cleaned, uriToKey); 101 + const keySeed = extractUrl(cleaned) ?? parentKey ?? entry.uri; 102 + const key = buildKey("note", keySeed); 103 + const normalizedValue = normalizeNoteValue(cleaned, parentKey); 104 + result.push({kind: "note", key, uri: entry.uri, value: normalizedValue}); 105 + uriToKey.set(entry.uri, key); 106 + } 107 + 108 + for (const entry of records.collectionLinks) { 109 + const cleaned = scrubIgnoredFields(entry.value, ignored); 110 + const collectionKey = extractLinkedKey(cleaned, "collection", uriToKey); 111 + const cardKey = extractLinkedKey(cleaned, "card", uriToKey); 112 + const key = buildKey("collectionLink", `${collectionKey ?? "unknown"}->${cardKey ?? "unknown"}`); 113 + const normalizedValue = normalizeLinkValue(cleaned, collectionKey, cardKey); 114 + result.push({kind: "collectionLink", key, uri: entry.uri, value: normalizedValue}); 115 + uriToKey.set(entry.uri, key); 116 + } 117 + 118 + return result; 119 + } 120 + 121 + function buildKey(kind: RecordKind, seed: string): string { 122 + return `${kind}|${seed}`; 123 + } 124 + 125 + function scrubIgnoredFields(value: unknown, ignored: Set<string>): unknown { 126 + if (value === null || typeof value !== "object") return value; 127 + if (Array.isArray(value)) { 128 + return value.map(item => scrubIgnoredFields(item, ignored)); 129 + } 130 + const result: Record<string, unknown> = {}; 131 + for (const [key, val] of Object.entries(value)) { 132 + if (ignored.has(key)) continue; 133 + result[key] = scrubIgnoredFields(val, ignored); 134 + } 135 + return result; 136 + } 137 + 138 + function extractUrl(value: unknown): string | undefined { 139 + if (!value || typeof value !== "object") return undefined; 140 + const record = value as {url?: unknown; content?: {url?: unknown}}; 141 + const direct = typeof record.url === "string" ? record.url : undefined; 142 + if (direct) return direct; 143 + const nested = record.content; 144 + if (nested && typeof nested === "object" && typeof nested.url === "string") { 145 + return nested.url; 146 + } 147 + return undefined; 148 + } 149 + 150 + function extractCollectionName(value: unknown): string | undefined { 151 + if (!value || typeof value !== "object") return undefined; 152 + const record = value as {name?: unknown}; 153 + return typeof record.name === "string" ? record.name : undefined; 154 + } 155 + 156 + function extractParentKey(value: unknown, uriToKey: Map<string, string>): string | undefined { 157 + if (!value || typeof value !== "object") return undefined; 158 + const record = value as {parentCard?: {uri?: unknown}}; 159 + const parentUri = record.parentCard && typeof record.parentCard === "object" 160 + ? (record.parentCard as {uri?: unknown}).uri 161 + : undefined; 162 + if (typeof parentUri === "string") { 163 + return uriToKey.get(parentUri) ?? parentUri; 164 + } 165 + return undefined; 166 + } 167 + 168 + function extractLinkedKey( 169 + value: unknown, 170 + field: "collection" | "card", 171 + uriToKey: Map<string, string> 172 + ): string | undefined { 173 + if (!value || typeof value !== "object") return undefined; 174 + const record = value as Record<string, unknown>; 175 + const ref = record[field]; 176 + if (!ref || typeof ref !== "object") return undefined; 177 + const uri = (ref as {uri?: unknown}).uri; 178 + if (typeof uri === "string") { 179 + return uriToKey.get(uri) ?? uri; 180 + } 181 + return undefined; 182 + } 183 + 184 + function normalizeNoteValue(value: unknown, parentKey?: string): unknown { 185 + if (!value || typeof value !== "object") return value; 186 + const clone = {...(value as Record<string, unknown>)}; 187 + if (parentKey) { 188 + clone.parentCard = {key: parentKey}; 189 + } 190 + return clone; 191 + } 192 + 193 + function normalizeLinkValue(value: unknown, collectionKey?: string, cardKey?: string): unknown { 194 + if (!value || typeof value !== "object") return value; 195 + const clone = {...(value as Record<string, unknown>)}; 196 + if (collectionKey) { 197 + clone.collection = {key: collectionKey}; 198 + } 199 + if (cardKey) { 200 + clone.card = {key: cardKey}; 201 + } 202 + return clone; 203 + } 204 + 205 + export function normalizeDraftRecords(drafts: AtprotoDraftRecords): RepoRecordGroups { 206 + return { 207 + cards: drafts.cards.map(envelope => ({ 208 + uri: envelope.strongRef.uri, 209 + value: envelope.record 210 + })), 211 + collections: drafts.collections.map(envelope => ({ 212 + uri: envelope.strongRef.uri, 213 + value: envelope.record 214 + })), 215 + collectionLinks: drafts.collectionLinks.map(envelope => ({ 216 + uri: envelope.strongRef.uri, 217 + value: envelope.record 218 + })), 219 + notes: drafts.notes.map(envelope => ({ 220 + uri: envelope.strongRef.uri, 221 + value: envelope.record 222 + })) 223 + }; 224 + }
+83
src/lib/atproto/identity.ts
··· 1 + const DEFAULT_HANDLE_RESOLVER = "https://bsky.social"; 2 + const PLC_DIRECTORY = "https://plc.directory"; 3 + 4 + interface DidDocumentService { 5 + id?: string; 6 + type?: string; 7 + serviceEndpoint?: string | {uri?: string; url?: string}; 8 + } 9 + 10 + interface DidDocument { 11 + service?: DidDocumentService[]; 12 + } 13 + 14 + export async function resolvePdsServiceUrl(identifier: string): Promise<string> { 15 + const did = identifier.startsWith("did:") 16 + ? identifier 17 + : await resolveHandleToDid(identifier); 18 + 19 + const didDoc = await fetchDidDocument(did); 20 + const serviceUrl = findPdsEndpoint(didDoc); 21 + if (!serviceUrl) { 22 + throw new Error("PDS service endpoint not found in DID document."); 23 + } 24 + return serviceUrl; 25 + } 26 + 27 + export async function resolveHandleToDid(handle: string): Promise<string> { 28 + const url = `${DEFAULT_HANDLE_RESOLVER}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`; 29 + const response = await fetch(url); 30 + if (!response.ok) { 31 + throw new Error(`Handle resolution failed: ${response.status}`); 32 + } 33 + const data = (await response.json()) as {did?: string} | undefined; 34 + if (!data?.did) { 35 + throw new Error("Handle resolution failed."); 36 + } 37 + return data.did; 38 + } 39 + 40 + async function fetchDidDocument(did: string): Promise<DidDocument> { 41 + if (did.startsWith("did:plc:")) { 42 + const response = await fetch(`${PLC_DIRECTORY}/${did}`); 43 + if (!response.ok) { 44 + throw new Error(`PLC lookup failed: ${response.status}`); 45 + } 46 + return (await response.json()) as DidDocument; 47 + } 48 + 49 + if (did.startsWith("did:web:")) { 50 + const url = didWebToUrl(did); 51 + const response = await fetch(url); 52 + if (!response.ok) { 53 + throw new Error(`DID web lookup failed: ${response.status}`); 54 + } 55 + return (await response.json()) as DidDocument; 56 + } 57 + 58 + throw new Error(`Unsupported DID method: ${did}`); 59 + } 60 + 61 + function didWebToUrl(did: string): string { 62 + const parts = did.slice("did:web:".length).split(":"); 63 + if (parts.length === 1) { 64 + return `https://${parts[0]}/.well-known/did.json`; 65 + } 66 + return `https://${parts.join("/")}/did.json`; 67 + } 68 + 69 + function findPdsEndpoint(doc: DidDocument): string | undefined { 70 + for (const service of doc.service ?? []) { 71 + const type = service.type?.toLowerCase(); 72 + const id = service.id?.toLowerCase(); 73 + if (type === "atprotopersonaldataserver" || type === "atproto_pds" || id?.endsWith("#atproto_pds")) { 74 + const endpoint = service.serviceEndpoint; 75 + if (typeof endpoint === "string") { 76 + return endpoint; 77 + } 78 + if (endpoint?.uri) return endpoint.uri; 79 + if (endpoint?.url) return endpoint.url; 80 + } 81 + } 82 + return undefined; 83 + }
+160
src/lib/atproto/oauth.ts
··· 1 + import type {AtpSessionData} from "@atproto/api"; 2 + import {resolveHandleToDid} from "./identity"; 3 + 4 + const OAUTH_STATE_KEY = "semble:oauth-state"; 5 + 6 + export interface OAuthConfig { 7 + authServer: string; 8 + clientId: string; 9 + redirectUri: string; 10 + scope: string; 11 + } 12 + 13 + interface OAuthState { 14 + state: string; 15 + codeVerifier: string; 16 + handle: string; 17 + config: OAuthConfig; 18 + } 19 + 20 + interface OAuthServerMetadata { 21 + authorization_endpoint?: string; 22 + token_endpoint?: string; 23 + } 24 + 25 + export async function beginOAuthLogin(config: OAuthConfig, handle: string): Promise<void> { 26 + const metadata = await fetchAuthServerMetadata(config.authServer); 27 + if (!metadata.authorization_endpoint) { 28 + throw new Error("Authorization endpoint missing from OAuth server metadata."); 29 + } 30 + 31 + const state = randomString(24); 32 + const codeVerifier = await generateCodeVerifier(); 33 + const codeChallenge = await codeChallengeFromVerifier(codeVerifier); 34 + 35 + const payload: OAuthState = { 36 + state, 37 + codeVerifier, 38 + handle, 39 + config 40 + }; 41 + sessionStorage.setItem(OAUTH_STATE_KEY, JSON.stringify(payload)); 42 + 43 + const authUrl = new URL(metadata.authorization_endpoint); 44 + authUrl.searchParams.set("response_type", "code"); 45 + authUrl.searchParams.set("client_id", config.clientId); 46 + authUrl.searchParams.set("redirect_uri", config.redirectUri); 47 + authUrl.searchParams.set("scope", config.scope); 48 + authUrl.searchParams.set("state", state); 49 + authUrl.searchParams.set("code_challenge", codeChallenge); 50 + authUrl.searchParams.set("code_challenge_method", "S256"); 51 + authUrl.searchParams.set("login_hint", handle); 52 + 53 + window.location.assign(authUrl.toString()); 54 + } 55 + 56 + export async function completeOAuthLogin(currentUrl: URL): Promise<AtpSessionData | null> { 57 + const code = currentUrl.searchParams.get("code"); 58 + const state = currentUrl.searchParams.get("state"); 59 + if (!code || !state) return null; 60 + 61 + const storedRaw = sessionStorage.getItem(OAUTH_STATE_KEY); 62 + if (!storedRaw) throw new Error("OAuth state missing. Try signing in again."); 63 + 64 + const stored = JSON.parse(storedRaw) as OAuthState; 65 + if (stored.state !== state) { 66 + throw new Error("OAuth state mismatch. Try signing in again."); 67 + } 68 + 69 + const metadata = await fetchAuthServerMetadata(stored.config.authServer); 70 + if (!metadata.token_endpoint) { 71 + throw new Error("Token endpoint missing from OAuth server metadata."); 72 + } 73 + 74 + const body = new URLSearchParams({ 75 + grant_type: "authorization_code", 76 + code, 77 + client_id: stored.config.clientId, 78 + redirect_uri: stored.config.redirectUri, 79 + code_verifier: stored.codeVerifier 80 + }); 81 + 82 + const response = await fetch(metadata.token_endpoint, { 83 + method: "POST", 84 + headers: { 85 + "Content-Type": "application/x-www-form-urlencoded" 86 + }, 87 + body 88 + }); 89 + 90 + if (!response.ok) { 91 + throw new Error(`OAuth token exchange failed: ${response.status}`); 92 + } 93 + 94 + const data = (await response.json()) as { 95 + access_token?: string; 96 + refresh_token?: string; 97 + sub?: string; 98 + did?: string; 99 + }; 100 + 101 + const accessJwt = data.access_token; 102 + const refreshJwt = data.refresh_token; 103 + if (!accessJwt || !refreshJwt) { 104 + throw new Error("OAuth token response missing access or refresh token."); 105 + } 106 + 107 + const did = data.did || data.sub || (await resolveHandleToDid(stored.handle)); 108 + 109 + sessionStorage.removeItem(OAUTH_STATE_KEY); 110 + return { 111 + did, 112 + handle: stored.handle, 113 + accessJwt, 114 + refreshJwt 115 + } as AtpSessionData; 116 + } 117 + 118 + async function fetchAuthServerMetadata(authServer: string): Promise<OAuthServerMetadata> { 119 + const base = authServer.replace(/\/$/, ""); 120 + const wellKnown = `${base}/.well-known/oauth-authorization-server`; 121 + const response = await fetch(wellKnown); 122 + if (!response.ok) { 123 + throw new Error(`OAuth metadata fetch failed: ${response.status}`); 124 + } 125 + return (await response.json()) as OAuthServerMetadata; 126 + } 127 + 128 + async function generateCodeVerifier(): Promise<string> { 129 + const bytes = new Uint8Array(32); 130 + crypto.getRandomValues(bytes); 131 + return base64UrlEncode(bytes); 132 + } 133 + 134 + async function codeChallengeFromVerifier(verifier: string): Promise<string> { 135 + const data = new TextEncoder().encode(verifier); 136 + const digest = await crypto.subtle.digest("SHA-256", data); 137 + return base64UrlEncode(new Uint8Array(digest)); 138 + } 139 + 140 + function base64UrlEncode(bytes: Uint8Array): string { 141 + let binary = ""; 142 + for (const byte of bytes) { 143 + binary += String.fromCharCode(byte); 144 + } 145 + return btoa(binary) 146 + .replace(/\+/g, "-") 147 + .replace(/\//g, "_") 148 + .replace(/=+$/, ""); 149 + } 150 + 151 + function randomString(length: number): string { 152 + const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; 153 + const bytes = new Uint8Array(length); 154 + crypto.getRandomValues(bytes); 155 + let value = ""; 156 + for (const byte of bytes) { 157 + value += chars[byte % chars.length]; 158 + } 159 + return value; 160 + }
+264
src/lib/atproto/record-builder.ts
··· 1 + import type {SembleCard, SembleCollection, SembleCollectionLink} from "../semble/types"; 2 + import type {SembleUrlMetadata} from "../metadata/citoid"; 3 + import { 4 + CARD_COLLECTION, 5 + COLLECTION_COLLECTION, 6 + COLLECTION_LINK_COLLECTION 7 + } from "./collections"; 8 + 9 + const DEFAULT_DID = "did:example:semble"; 10 + 11 + export interface MockStrongRef { 12 + uri: string; 13 + cid: string; 14 + } 15 + 16 + export interface AtprotoRecordEnvelope { 17 + collection: string; 18 + rkey: string; 19 + record: Record<string, unknown>; 20 + strongRef: MockStrongRef; 21 + recordType: "card" | "collection" | "collectionLink" | "note"; 22 + recordId: string; 23 + cardId?: string; 24 + collectionName?: string; 25 + } 26 + 27 + export interface AtprotoDraftRecords { 28 + cards: AtprotoRecordEnvelope[]; 29 + collections: AtprotoRecordEnvelope[]; 30 + collectionLinks: AtprotoRecordEnvelope[]; 31 + notes: AtprotoRecordEnvelope[]; 32 + } 33 + 34 + export function buildMockAtprotoRecords(input: { 35 + cards: SembleCard[]; 36 + collections: SembleCollection[]; 37 + collectionLinks: SembleCollectionLink[]; 38 + now?: Date; 39 + did?: string; 40 + }): AtprotoDraftRecords { 41 + const now = input.now ?? new Date(); 42 + const did = input.did ?? DEFAULT_DID; 43 + 44 + const cardRecords = input.cards.map(card => buildCardRecord(card, now, did)); 45 + const collectionRecords = input.collections.map(collection => 46 + buildCollectionRecord(collection, now, did) 47 + ); 48 + 49 + const cardRefMap = new Map( 50 + cardRecords.map(record => [record.recordId, record.strongRef]) 51 + ); 52 + const collectionRefMap = new Map( 53 + collectionRecords.map(record => [record.recordId, record.strongRef]) 54 + ); 55 + 56 + const collectionLinkRecords = input.collectionLinks.map(link => { 57 + const collectionRef = collectionRefMap.get(link.collectionName); 58 + const cardRef = cardRefMap.get(link.cardId); 59 + 60 + if (!collectionRef || !cardRef) { 61 + return buildCollectionLinkRecord(link, now, did, undefined, undefined); 62 + } 63 + 64 + return buildCollectionLinkRecord(link, now, did, collectionRef, cardRef); 65 + }); 66 + 67 + const noteRecords = input.cards 68 + .filter(card => Boolean(card.note)) 69 + .map(card => { 70 + const parentRef = cardRefMap.get(card.id); 71 + return buildNoteRecord(card, now, did, parentRef); 72 + }); 73 + 74 + return { 75 + cards: cardRecords.map(record => record.envelope), 76 + collections: collectionRecords.map(record => record.envelope), 77 + collectionLinks: collectionLinkRecords.map(record => record.envelope), 78 + notes: noteRecords.map(record => record.envelope) 79 + }; 80 + } 81 + 82 + function buildCardRecord(card: SembleCard, now: Date, did: string): { 83 + recordId: string; 84 + envelope: AtprotoRecordEnvelope; 85 + strongRef: MockStrongRef; 86 + } { 87 + const rkey = stableRkey(`card:${card.id}`); 88 + const uri = `at://${did}/${CARD_COLLECTION}/${rkey}`; 89 + const strongRef = {uri, cid: `mock:${stableHash(uri)}`}; 90 + 91 + const record: Record<string, unknown> = { 92 + $type: CARD_COLLECTION, 93 + type: "URL", 94 + content: buildUrlContent(card), 95 + createdAt: now.toISOString() 96 + }; 97 + 98 + const envelope: AtprotoRecordEnvelope = { 99 + collection: CARD_COLLECTION, 100 + rkey, 101 + record, 102 + strongRef, 103 + recordType: "card", 104 + recordId: card.id 105 + }; 106 + 107 + return {recordId: card.id, envelope, strongRef}; 108 + } 109 + 110 + function buildUrlContent(card: SembleCard): Record<string, unknown> { 111 + const content: Record<string, unknown> = { 112 + $type: `${CARD_COLLECTION}#urlContent`, 113 + url: card.url 114 + }; 115 + 116 + const metadata = buildUrlMetadata(card.metadata); 117 + if (metadata) { 118 + content.metadata = metadata; 119 + } 120 + 121 + return content; 122 + } 123 + 124 + function buildUrlMetadata(metadata?: SembleUrlMetadata): Record<string, unknown> | undefined { 125 + if (!metadata) return undefined; 126 + 127 + const record: Record<string, unknown> = { 128 + $type: `${CARD_COLLECTION}#urlMetadata` 129 + }; 130 + 131 + if (metadata.title) record.title = metadata.title; 132 + if (metadata.description) record.description = metadata.description; 133 + if (metadata.author) record.author = metadata.author; 134 + if (metadata.publishedDate) { 135 + record.publishedDate = metadata.publishedDate.toISOString(); 136 + } 137 + if (metadata.siteName) record.siteName = metadata.siteName; 138 + if (metadata.type) record.type = metadata.type; 139 + if (metadata.doi) record.doi = metadata.doi; 140 + if (metadata.isbn) record.isbn = metadata.isbn; 141 + 142 + return record; 143 + } 144 + 145 + function buildCollectionRecord(collection: SembleCollection, now: Date, did: string): { 146 + recordId: string; 147 + envelope: AtprotoRecordEnvelope; 148 + strongRef: MockStrongRef; 149 + } { 150 + const rkey = stableRkey(`collection:${collection.name}`); 151 + const uri = `at://${did}/${COLLECTION_COLLECTION}/${rkey}`; 152 + const strongRef = {uri, cid: `mock:${stableHash(uri)}`}; 153 + 154 + const record: Record<string, unknown> = { 155 + $type: COLLECTION_COLLECTION, 156 + name: collection.name, 157 + accessType: "OPEN", 158 + createdAt: now.toISOString() 159 + }; 160 + 161 + const envelope: AtprotoRecordEnvelope = { 162 + collection: COLLECTION_COLLECTION, 163 + rkey, 164 + record, 165 + strongRef, 166 + recordType: "collection", 167 + recordId: collection.name 168 + }; 169 + 170 + return {recordId: collection.name, envelope, strongRef}; 171 + } 172 + 173 + function buildNoteRecord( 174 + card: SembleCard, 175 + now: Date, 176 + did: string, 177 + parentRef?: MockStrongRef 178 + ): { 179 + recordId: string; 180 + envelope: AtprotoRecordEnvelope; 181 + } { 182 + const rkey = stableRkey(`note:${card.id}`); 183 + const uri = `at://${did}/${CARD_COLLECTION}/${rkey}`; 184 + const strongRef = {uri, cid: `mock:${stableHash(uri)}`}; 185 + 186 + const record: Record<string, unknown> = { 187 + $type: CARD_COLLECTION, 188 + type: "NOTE", 189 + content: { 190 + $type: `${CARD_COLLECTION}#noteContent`, 191 + text: card.note 192 + }, 193 + createdAt: now.toISOString() 194 + }; 195 + 196 + if (card.url) { 197 + record.url = card.url; 198 + } 199 + 200 + if (parentRef) { 201 + record.parentCard = parentRef; 202 + } 203 + 204 + const envelope: AtprotoRecordEnvelope = { 205 + collection: CARD_COLLECTION, 206 + rkey, 207 + record, 208 + strongRef, 209 + recordType: "note", 210 + recordId: `${card.id}:note`, 211 + cardId: card.id 212 + }; 213 + 214 + return {recordId: `${card.id}:note`, envelope}; 215 + } 216 + 217 + function buildCollectionLinkRecord( 218 + link: SembleCollectionLink, 219 + now: Date, 220 + did: string, 221 + collectionRef?: MockStrongRef, 222 + cardRef?: MockStrongRef 223 + ): { 224 + recordId: string; 225 + envelope: AtprotoRecordEnvelope; 226 + } { 227 + const rkey = stableRkey(`link:${link.collectionName}:${link.cardId}`); 228 + const uri = `at://${did}/${COLLECTION_LINK_COLLECTION}/${rkey}`; 229 + const strongRef = {uri, cid: `mock:${stableHash(uri)}`}; 230 + 231 + const record: Record<string, unknown> = { 232 + $type: COLLECTION_LINK_COLLECTION, 233 + collection: collectionRef ?? {uri: "at://unknown/collection", cid: "mock:unknown"}, 234 + card: cardRef ?? {uri: "at://unknown/card", cid: "mock:unknown"}, 235 + addedBy: did, 236 + addedAt: now.toISOString(), 237 + createdAt: now.toISOString() 238 + }; 239 + 240 + const envelope: AtprotoRecordEnvelope = { 241 + collection: COLLECTION_LINK_COLLECTION, 242 + rkey, 243 + record, 244 + strongRef, 245 + recordType: "collectionLink", 246 + recordId: `${link.collectionName}:${link.cardId}`, 247 + cardId: link.cardId, 248 + collectionName: link.collectionName 249 + }; 250 + 251 + return {recordId: `${link.collectionName}:${link.cardId}`, envelope}; 252 + } 253 + 254 + export function stableRkey(seed: string): string { 255 + return `semble-${stableHash(seed)}`; 256 + } 257 + 258 + function stableHash(input: string): string { 259 + let hash = 0; 260 + for (let i = 0; i < input.length; i += 1) { 261 + hash = (hash * 31 + input.charCodeAt(i)) | 0; 262 + } 263 + return Math.abs(hash).toString(36); 264 + }
+91
src/lib/atproto/repo.ts
··· 1 + import {AtpAgent, type AtpSessionData} from "@atproto/api"; 2 + import { 3 + CARD_COLLECTION, 4 + COLLECTION_COLLECTION, 5 + COLLECTION_LINK_COLLECTION 6 + } from "./collections"; 7 + 8 + export interface SembleRepoSummary { 9 + cards: number; 10 + collections: number; 11 + collectionLinks: number; 12 + notes: number; 13 + } 14 + 15 + export interface SembleRepoSamples { 16 + cards: Array<{uri: string; cid?: string; value: unknown}>; 17 + collections: Array<{uri: string; cid?: string; value: unknown}>; 18 + collectionLinks: Array<{uri: string; cid?: string; value: unknown}>; 19 + notes: Array<{uri: string; cid?: string; value: unknown}>; 20 + } 21 + 22 + export async function listSembleRepoRecords(input: { 23 + serviceUrl: string; 24 + session: AtpSessionData; 25 + }): Promise<{summary: SembleRepoSummary; records: SembleRepoSamples}> { 26 + const agent = new AtpAgent({service: input.serviceUrl}); 27 + await agent.resumeSession(input.session); 28 + 29 + const repo = input.session.did; 30 + const [cards, collections, links, notes] = await Promise.all([ 31 + listRecords(agent, repo, CARD_COLLECTION, "URL"), 32 + listRecords(agent, repo, COLLECTION_COLLECTION), 33 + listRecords(agent, repo, COLLECTION_LINK_COLLECTION), 34 + listRecords(agent, repo, CARD_COLLECTION, "NOTE") 35 + ]); 36 + 37 + return { 38 + summary: { 39 + cards: cards.total, 40 + collections: collections.total, 41 + collectionLinks: links.total, 42 + notes: notes.total 43 + }, 44 + records: { 45 + cards: cards.records, 46 + collections: collections.records, 47 + collectionLinks: links.records, 48 + notes: notes.records 49 + } 50 + }; 51 + } 52 + 53 + async function listRecords( 54 + agent: AtpAgent, 55 + repo: string, 56 + collection: string, 57 + typeFilter?: string 58 + ): Promise<{total: number; records: Array<{uri: string; cid?: string; value: unknown}>}> { 59 + let total = 0; 60 + const records: Array<{uri: string; cid?: string; value: unknown}> = []; 61 + let cursor: string | undefined; 62 + 63 + do { 64 + const response = await agent.com.atproto.repo.listRecords({ 65 + repo, 66 + collection, 67 + limit: 100, 68 + cursor 69 + }); 70 + 71 + const pageRecords = response.data.records ?? []; 72 + if (typeFilter) { 73 + for (const record of pageRecords) { 74 + const value = record.value as {type?: string} | undefined; 75 + if (value?.type === typeFilter) { 76 + total += 1; 77 + records.push({uri: record.uri, cid: record.cid, value: record.value}); 78 + } 79 + } 80 + } else { 81 + total += pageRecords.length; 82 + for (const record of pageRecords) { 83 + records.push({uri: record.uri, cid: record.cid, value: record.value}); 84 + } 85 + } 86 + 87 + cursor = response.data.cursor; 88 + } while (cursor); 89 + 90 + return {total, records}; 91 + }
+29
src/lib/atproto/session.ts
··· 1 + import {AtpAgent, type AtpSessionData} from "@atproto/api"; 2 + 3 + const STORAGE_KEY = "semble:session"; 4 + 5 + export function loadStoredSession(): AtpSessionData | null { 6 + if (typeof localStorage === "undefined") return null; 7 + const raw = localStorage.getItem(STORAGE_KEY); 8 + if (!raw) return null; 9 + try { 10 + return JSON.parse(raw) as AtpSessionData; 11 + } catch { 12 + return null; 13 + } 14 + } 15 + 16 + export function saveStoredSession(session: AtpSessionData | null): void { 17 + if (typeof localStorage === "undefined") return; 18 + if (!session) { 19 + localStorage.removeItem(STORAGE_KEY); 20 + return; 21 + } 22 + localStorage.setItem(STORAGE_KEY, JSON.stringify(session)); 23 + } 24 + 25 + export async function buildAgent(serviceUrl: string, session: AtpSessionData): Promise<AtpAgent> { 26 + const agent = new AtpAgent({service: serviceUrl}); 27 + await agent.resumeSession(session); 28 + return agent; 29 + }
+27
src/lib/metadata/citoid-mapper.ts
··· 1 + import { 2 + buildSembleMetadataFromCitoid, 3 + type CitoidResponse, 4 + type SembleUrlMetadata 5 + } from "./citoid"; 6 + 7 + export interface SembleRecordFromCitoid { 8 + metadata: SembleUrlMetadata; 9 + raw: CitoidResponse; 10 + } 11 + 12 + export function mapCitoidPayloadToSembleRecord(payload: unknown): SembleRecordFromCitoid | undefined { 13 + const citoid = extractFirstCitoidResponse(payload); 14 + if (!citoid) return undefined; 15 + 16 + return { 17 + metadata: buildSembleMetadataFromCitoid(citoid), 18 + raw: citoid 19 + }; 20 + } 21 + 22 + function extractFirstCitoidResponse(payload: unknown): CitoidResponse | undefined { 23 + if (!Array.isArray(payload) || payload.length === 0) return undefined; 24 + const first: unknown = payload[0]; 25 + if (!first || typeof first !== "object") return undefined; 26 + return first as CitoidResponse; 27 + }
+291
src/lib/metadata/citoid.ts
··· 1 + // Isolated from Semble repo to keep Citoid mapping auditable and easy to update. 2 + 3 + export interface CitoidCreator { 4 + firstName?: string; 5 + lastName?: string; 6 + creatorType?: string; 7 + name?: string; 8 + } 9 + 10 + export interface CitoidResponse { 11 + itemType?: string; 12 + creators?: CitoidCreator[]; 13 + title?: string; 14 + date?: string; 15 + abstractNote?: string; 16 + publicationTitle?: string; 17 + websiteTitle?: string; 18 + blogTitle?: string; 19 + forumTitle?: string; 20 + repository?: string; 21 + libraryCatalog?: string; 22 + institution?: string; 23 + university?: string; 24 + publisher?: string; 25 + proceedingsTitle?: string; 26 + conferenceName?: string; 27 + series?: string; 28 + seriesTitle?: string; 29 + network?: string; 30 + programTitle?: string; 31 + distributor?: string; 32 + studio?: string; 33 + label?: string; 34 + DOI?: string; 35 + ISBN?: string; 36 + dateDecided?: string; 37 + dateEnacted?: string; 38 + issueDate?: string; 39 + filingDate?: string; 40 + } 41 + 42 + export enum SembleUrlType { 43 + LINK = "link", 44 + ARTICLE = "article", 45 + BOOK = "book", 46 + RESEARCH = "research", 47 + AUDIO = "audio", 48 + VIDEO = "video", 49 + SOCIAL = "social", 50 + SOFTWARE = "software" 51 + } 52 + 53 + export interface SembleUrlMetadata { 54 + title?: string; 55 + description?: string; 56 + author?: string; 57 + publishedDate?: Date; 58 + siteName?: string; 59 + type?: SembleUrlType; 60 + doi?: string; 61 + isbn?: string; 62 + } 63 + 64 + export enum CitoidUrlTypes { 65 + ARTWORK = "artwork", 66 + AUDIO_RECORDING = "audioRecording", 67 + BILL = "bill", 68 + BLOG_POST = "blogPost", 69 + BOOK = "book", 70 + BOOK_SECTION = "bookSection", 71 + CASE = "case", 72 + CONFERENCE_PAPER = "conferencePaper", 73 + DATASET = "dataset", 74 + DICTIONARY_ENTRY = "dictionaryEntry", 75 + DOCUMENT = "document", 76 + EMAIL = "email", 77 + ENCYCLOPEDIA_ARTICLE = "encyclopediaArticle", 78 + FILM = "film", 79 + FORUM_POST = "forumPost", 80 + HEARING = "hearing", 81 + INSTANT_MESSAGE = "instantMessage", 82 + INTERVIEW = "interview", 83 + JOURNAL_ARTICLE = "journalArticle", 84 + LETTER = "letter", 85 + MAGAZINE_ARTICLE = "magazineArticle", 86 + MANUSCRIPT = "manuscript", 87 + MAP = "map", 88 + NEWSPAPER_ARTICLE = "newspaperArticle", 89 + NOTE = "note", 90 + PATENT = "patent", 91 + PODCAST = "podcast", 92 + PREPRINT = "preprint", 93 + PRESENTATION = "presentation", 94 + RADIO_BROADCAST = "radioBroadcast", 95 + REPORT = "report", 96 + COMPUTER_PROGRAM = "computerProgram", 97 + STANDARD = "standard", 98 + STATUTE = "statute", 99 + THESIS = "thesis", 100 + TV_BROADCAST = "tvBroadcast", 101 + VIDEO_RECORDING = "videoRecording", 102 + WEBPAGE = "webpage" 103 + } 104 + 105 + const citoidToSembleType: Record<CitoidUrlTypes, SembleUrlType> = { 106 + [CitoidUrlTypes.ARTWORK]: SembleUrlType.LINK, 107 + [CitoidUrlTypes.AUDIO_RECORDING]: SembleUrlType.AUDIO, 108 + [CitoidUrlTypes.BILL]: SembleUrlType.LINK, 109 + [CitoidUrlTypes.BLOG_POST]: SembleUrlType.ARTICLE, 110 + [CitoidUrlTypes.BOOK]: SembleUrlType.BOOK, 111 + [CitoidUrlTypes.BOOK_SECTION]: SembleUrlType.BOOK, 112 + [CitoidUrlTypes.CASE]: SembleUrlType.LINK, 113 + [CitoidUrlTypes.CONFERENCE_PAPER]: SembleUrlType.RESEARCH, 114 + [CitoidUrlTypes.DATASET]: SembleUrlType.RESEARCH, 115 + [CitoidUrlTypes.DICTIONARY_ENTRY]: SembleUrlType.LINK, 116 + [CitoidUrlTypes.DOCUMENT]: SembleUrlType.LINK, 117 + [CitoidUrlTypes.EMAIL]: SembleUrlType.LINK, 118 + [CitoidUrlTypes.ENCYCLOPEDIA_ARTICLE]: SembleUrlType.ARTICLE, 119 + [CitoidUrlTypes.FILM]: SembleUrlType.VIDEO, 120 + [CitoidUrlTypes.FORUM_POST]: SembleUrlType.SOCIAL, 121 + [CitoidUrlTypes.HEARING]: SembleUrlType.LINK, 122 + [CitoidUrlTypes.INSTANT_MESSAGE]: SembleUrlType.LINK, 123 + [CitoidUrlTypes.INTERVIEW]: SembleUrlType.LINK, 124 + [CitoidUrlTypes.JOURNAL_ARTICLE]: SembleUrlType.RESEARCH, 125 + [CitoidUrlTypes.LETTER]: SembleUrlType.LINK, 126 + [CitoidUrlTypes.MAGAZINE_ARTICLE]: SembleUrlType.ARTICLE, 127 + [CitoidUrlTypes.MANUSCRIPT]: SembleUrlType.RESEARCH, 128 + [CitoidUrlTypes.MAP]: SembleUrlType.LINK, 129 + [CitoidUrlTypes.NEWSPAPER_ARTICLE]: SembleUrlType.ARTICLE, 130 + [CitoidUrlTypes.NOTE]: SembleUrlType.LINK, 131 + [CitoidUrlTypes.PATENT]: SembleUrlType.LINK, 132 + [CitoidUrlTypes.PODCAST]: SembleUrlType.AUDIO, 133 + [CitoidUrlTypes.PREPRINT]: SembleUrlType.RESEARCH, 134 + [CitoidUrlTypes.PRESENTATION]: SembleUrlType.RESEARCH, 135 + [CitoidUrlTypes.RADIO_BROADCAST]: SembleUrlType.AUDIO, 136 + [CitoidUrlTypes.REPORT]: SembleUrlType.RESEARCH, 137 + [CitoidUrlTypes.COMPUTER_PROGRAM]: SembleUrlType.SOFTWARE, 138 + [CitoidUrlTypes.STANDARD]: SembleUrlType.LINK, 139 + [CitoidUrlTypes.STATUTE]: SembleUrlType.LINK, 140 + [CitoidUrlTypes.THESIS]: SembleUrlType.RESEARCH, 141 + [CitoidUrlTypes.TV_BROADCAST]: SembleUrlType.VIDEO, 142 + [CitoidUrlTypes.VIDEO_RECORDING]: SembleUrlType.VIDEO, 143 + [CitoidUrlTypes.WEBPAGE]: SembleUrlType.LINK 144 + }; 145 + 146 + export function mapCitoidUrlType(citoidType?: string): SembleUrlType { 147 + if (!citoidType) return SembleUrlType.LINK; 148 + const normalizedType = citoidType as CitoidUrlTypes; 149 + return citoidToSembleType[normalizedType] || SembleUrlType.LINK; 150 + } 151 + 152 + export function buildSembleMetadataFromCitoid(data: CitoidResponse): SembleUrlMetadata { 153 + return { 154 + title: data.title, 155 + description: data.abstractNote, 156 + author: extractPrimaryAuthor(data.creators), 157 + publishedDate: extractPublishedDate(data), 158 + siteName: determineSiteName(data), 159 + type: mapCitoidUrlType(data.itemType), 160 + doi: data.DOI, 161 + isbn: data.ISBN 162 + }; 163 + } 164 + 165 + function extractPrimaryAuthor(creators?: CitoidCreator[]): string | undefined { 166 + if (!creators || creators.length === 0) return undefined; 167 + 168 + const authorCreator = creators.find(creator => creator.creatorType === "author"); 169 + if (authorCreator) return formatAuthor(authorCreator); 170 + 171 + const primaryTypes = [ 172 + "artist", 173 + "performer", 174 + "director", 175 + "podcaster", 176 + "cartographer", 177 + "programmer", 178 + "presenter", 179 + "sponsor", 180 + "inventor", 181 + "interviewee", 182 + "bookAuthor", 183 + "editor", 184 + "contributor" 185 + ]; 186 + 187 + for (const type of primaryTypes) { 188 + const creator = creators.find(entry => entry.creatorType === type); 189 + if (creator) return formatAuthor(creator); 190 + } 191 + 192 + return formatAuthor(creators[0] ?? {}); 193 + } 194 + 195 + function formatAuthor(creator: CitoidCreator): string { 196 + if (creator.name) return creator.name; 197 + if (creator.firstName && creator.lastName) { 198 + return `${creator.firstName} ${creator.lastName}`; 199 + } 200 + return creator.lastName || creator.firstName || ""; 201 + } 202 + 203 + function determineSiteName(data: CitoidResponse): string | undefined { 204 + const itemType = data.itemType; 205 + switch (itemType) { 206 + case CitoidUrlTypes.JOURNAL_ARTICLE: 207 + case CitoidUrlTypes.MAGAZINE_ARTICLE: 208 + case CitoidUrlTypes.NEWSPAPER_ARTICLE: 209 + return data.publicationTitle || data.publisher; 210 + case CitoidUrlTypes.BLOG_POST: 211 + return data.blogTitle || data.websiteTitle; 212 + case CitoidUrlTypes.FORUM_POST: 213 + return data.forumTitle; 214 + case CitoidUrlTypes.WEBPAGE: 215 + return data.websiteTitle; 216 + case CitoidUrlTypes.BOOK: 217 + case CitoidUrlTypes.BOOK_SECTION: 218 + return data.publisher || data.series; 219 + case CitoidUrlTypes.CONFERENCE_PAPER: 220 + return data.proceedingsTitle || data.conferenceName || data.publisher; 221 + case CitoidUrlTypes.THESIS: 222 + return data.university || data.institution; 223 + case CitoidUrlTypes.REPORT: 224 + return data.institution || data.publisher; 225 + case CitoidUrlTypes.DATASET: 226 + case CitoidUrlTypes.PREPRINT: 227 + return data.repository || data.institution; 228 + case CitoidUrlTypes.PODCAST: 229 + return data.seriesTitle || data.network; 230 + case CitoidUrlTypes.TV_BROADCAST: 231 + case CitoidUrlTypes.RADIO_BROADCAST: 232 + return data.network || data.programTitle; 233 + case CitoidUrlTypes.FILM: 234 + case CitoidUrlTypes.VIDEO_RECORDING: 235 + return data.distributor || data.studio; 236 + case CitoidUrlTypes.AUDIO_RECORDING: 237 + return data.label || data.publisher; 238 + default: 239 + return ( 240 + data.publicationTitle || 241 + data.websiteTitle || 242 + data.blogTitle || 243 + data.forumTitle || 244 + data.repository || 245 + data.libraryCatalog || 246 + data.institution || 247 + data.university || 248 + data.publisher 249 + ); 250 + } 251 + } 252 + 253 + function extractPublishedDate(data: CitoidResponse): Date | undefined { 254 + let dateString: string | undefined; 255 + switch (data.itemType) { 256 + case CitoidUrlTypes.CASE: 257 + dateString = data.dateDecided || data.date; 258 + break; 259 + case CitoidUrlTypes.STATUTE: 260 + dateString = data.dateEnacted || data.date; 261 + break; 262 + case CitoidUrlTypes.PATENT: 263 + dateString = data.issueDate || data.filingDate || data.date; 264 + break; 265 + default: 266 + dateString = data.date; 267 + break; 268 + } 269 + return dateString ? parseDate(dateString) : undefined; 270 + } 271 + 272 + function parseDate(dateString: string): Date | undefined { 273 + if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) { 274 + const parsed = new Date(`${dateString}T00:00:00.000Z`); 275 + return Number.isNaN(parsed.getTime()) ? undefined : parsed; 276 + } 277 + if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(dateString)) { 278 + const parsed = new Date(dateString); 279 + return Number.isNaN(parsed.getTime()) ? undefined : parsed; 280 + } 281 + if (/^\d{4}$/.test(dateString)) { 282 + const parsed = new Date(`${dateString}-01-01T00:00:00.000Z`); 283 + return Number.isNaN(parsed.getTime()) ? undefined : parsed; 284 + } 285 + if (/^\d{4}-\d{2}$/.test(dateString)) { 286 + const parsed = new Date(`${dateString}-01T00:00:00.000Z`); 287 + return Number.isNaN(parsed.getTime()) ? undefined : parsed; 288 + } 289 + const parsed = new Date(dateString); 290 + return Number.isNaN(parsed.getTime()) ? undefined : parsed; 291 + }
+51
src/lib/metadata/url-classifier.ts
··· 1 + import {SembleUrlType} from "./citoid"; 2 + 3 + export interface UrlPattern { 4 + regex: RegExp; 5 + type: SembleUrlType; 6 + description?: string; 7 + } 8 + 9 + export class UrlClassifier { 10 + private static readonly patterns: UrlPattern[] = [ 11 + { 12 + regex: /^https:\/\/bsky\.app\/profile\/[^/]+\/post\/[^/]+$/, 13 + type: SembleUrlType.SOCIAL, 14 + description: "Bluesky post" 15 + }, 16 + { 17 + regex: /^https:\/\/blacksky\.community\/profile\/[^/]+\/post\/[^/]+$/, 18 + type: SembleUrlType.SOCIAL, 19 + description: "Blacksky community post" 20 + }, 21 + { 22 + regex: /^https:\/\/deer\.social\/profile\/[^/]+\/post\/[^/]+$/, 23 + type: SembleUrlType.SOCIAL, 24 + description: "Deer social post" 25 + }, 26 + { 27 + regex: /^https:\/\/smokesignal\.events\/[^/]+\/[^/]+$/, 28 + type: SembleUrlType.LINK, 29 + description: "Smokesignal event" 30 + }, 31 + { 32 + regex: /^https:\/\/tangled\.org\/[^/]+\/[^/]+/, 33 + type: SembleUrlType.SOFTWARE, 34 + description: "Tangled repo" 35 + }, 36 + { 37 + regex: /^https:\/\/[^./]+\.leaflet\.pub\/[^/]+$/, 38 + type: SembleUrlType.ARTICLE, 39 + description: "Leaflet article" 40 + } 41 + ]; 42 + 43 + public static classifyUrl(url: string): SembleUrlType | null { 44 + for (const pattern of this.patterns) { 45 + if (pattern.regex.test(url)) { 46 + return pattern.type; 47 + } 48 + } 49 + return null; 50 + } 51 + }
+87
src/lib/parse/markdown.ts
··· 1 + import type {SembleCard, SembleCollection} from "../semble/types"; 2 + 3 + export interface ParsedSource { 4 + cards: SembleCard[]; 5 + collections: SembleCollection[]; 6 + } 7 + 8 + export function parseMarkdownSource(sourceId: string, text: string): ParsedSource { 9 + const lines = text.split(/\r?\n/); 10 + const cards: SembleCard[] = []; 11 + const collections: SembleCollection[] = []; 12 + let collection: string | undefined; 13 + 14 + for (let index = 0; index < lines.length; index += 1) { 15 + const line = lines[index]; 16 + if (!line) continue; 17 + 18 + const headerMatch = line.match(/^#\s+(.+?)\s*$/); 19 + if (headerMatch) { 20 + collection = headerMatch[1]?.trim(); 21 + if (collection) { 22 + collections.push({ 23 + name: collection, 24 + sourceId, 25 + line: index + 1 26 + }); 27 + } 28 + continue; 29 + } 30 + 31 + const listMatch = line.match(/^\s*[-*]\s+(.+)$/); 32 + const candidate = listMatch ? listMatch[1] ?? "" : line.trim(); 33 + const parsed = parseCardLine(candidate); 34 + if (!parsed) continue; 35 + 36 + const cardId = `${sourceId}#L${index + 1}:${parsed.url}`; 37 + cards.push({ 38 + ...parsed, 39 + collection, 40 + sourceId, 41 + line: index + 1, 42 + id: cardId 43 + }); 44 + } 45 + 46 + return {cards, collections}; 47 + } 48 + 49 + function parseCardLine(text: string): Omit<SembleCard, "collection" | "sourceId" | "line" | "id"> | null { 50 + const trimmed = text.trim(); 51 + if (!trimmed) return null; 52 + 53 + const splitIndex = trimmed.indexOf(" : "); 54 + const linkPart = splitIndex >= 0 ? trimmed.slice(0, splitIndex).trim() : trimmed; 55 + const notePart = splitIndex >= 0 ? trimmed.slice(splitIndex + 3).trim() : undefined; 56 + 57 + const markdownLink = linkPart.match(/^\[(.+?)\]\((.+?)\)$/); 58 + if (markdownLink) { 59 + const title = markdownLink[1]?.trim(); 60 + const url = markdownLink[2]?.trim(); 61 + if (!url) return null; 62 + return { 63 + url, 64 + title: title || undefined, 65 + note: notePart || undefined 66 + }; 67 + } 68 + 69 + const angleLink = linkPart.match(/^<([^>]+)>$/); 70 + if (angleLink) { 71 + const url = angleLink[1]?.trim(); 72 + if (!url) return null; 73 + return { 74 + url, 75 + note: notePart || undefined 76 + }; 77 + } 78 + 79 + if (!/^(https?:\/\/|at:\/\/)\S+$/i.test(linkPart)) { 80 + return null; 81 + } 82 + 83 + return { 84 + url: linkPart, 85 + note: notePart || undefined 86 + }; 87 + }
+23
src/lib/semble/types.ts
··· 1 + import type {SembleUrlMetadata} from "../metadata/citoid"; 2 + 3 + export interface SembleCard { 4 + url: string; 5 + title?: string; 6 + note?: string; 7 + metadata?: SembleUrlMetadata; 8 + collection?: string; 9 + sourceId: string; 10 + line: number; 11 + id: string; 12 + } 13 + 14 + export interface SembleCollection { 15 + name: string; 16 + sourceId: string; 17 + line: number; 18 + } 19 + 20 + export interface SembleCollectionLink { 21 + collectionName: string; 22 + cardId: string; 23 + }
+24
src/lib/utils/urls.ts
··· 1 + export function normalizeSourceUrl(input: string): string { 2 + const trimmed = input.trim(); 3 + if (!trimmed) return trimmed; 4 + 5 + try { 6 + const url = new URL(trimmed); 7 + if (url.hostname === "gist.github.com") { 8 + const [user, gistId] = url.pathname.split("/").filter(Boolean); 9 + if (user && gistId) { 10 + return `https://gist.githubusercontent.com/${user}/${gistId}/raw`; 11 + } 12 + } 13 + if (url.hostname === "gist.githubusercontent.com" && url.pathname.includes("/raw")) { 14 + return trimmed; 15 + } 16 + return trimmed; 17 + } catch { 18 + return trimmed; 19 + } 20 + } 21 + 22 + export function isProbablyUrl(input: string): boolean { 23 + return /^(https?:\/\/|at:\/\/)/i.test(input.trim()); 24 + }