a tool for shared writing and social publishing
at update/delete-leaflets 574 lines 19 kB view raw
1"use server"; 2 3import * as Y from "yjs"; 4import * as base64 from "base64-js"; 5import { createOauthClient } from "src/atproto-oauth"; 6import { getIdentityData } from "actions/getIdentityData"; 7import { 8 AtpBaseClient, 9 PubLeafletBlocksHeader, 10 PubLeafletBlocksImage, 11 PubLeafletBlocksText, 12 PubLeafletBlocksUnorderedList, 13 PubLeafletDocument, 14 PubLeafletPagesLinearDocument, 15 PubLeafletPagesCanvas, 16 PubLeafletRichtextFacet, 17 PubLeafletBlocksWebsite, 18 PubLeafletBlocksCode, 19 PubLeafletBlocksMath, 20 PubLeafletBlocksHorizontalRule, 21 PubLeafletBlocksBskyPost, 22 PubLeafletBlocksBlockquote, 23 PubLeafletBlocksIframe, 24 PubLeafletBlocksPage, 25 PubLeafletBlocksPoll, 26 PubLeafletBlocksButton, 27 PubLeafletPollDefinition, 28} from "lexicons/api"; 29import { Block } from "components/Blocks/Block"; 30import { TID } from "@atproto/common"; 31import { supabaseServerClient } from "supabase/serverClient"; 32import { scanIndexLocal } from "src/replicache/utils"; 33import type { Fact } from "src/replicache"; 34import type { Attribute } from "src/replicache/attributes"; 35import { 36 Delta, 37 YJSFragmentToString, 38} from "components/Blocks/TextBlock/RenderYJSFragment"; 39import { ids } from "lexicons/api/lexicons"; 40import { BlobRef } from "@atproto/lexicon"; 41import { AtUri } from "@atproto/syntax"; 42import { Json } from "supabase/database.types"; 43import { $Typed, UnicodeString } from "@atproto/api"; 44import { List, parseBlocksToList } from "src/utils/parseBlocksToList"; 45import { getBlocksWithTypeLocal } from "src/hooks/queries/useBlocks"; 46import { Lock } from "src/utils/lock"; 47 48export async function publishToPublication({ 49 root_entity, 50 publication_uri, 51 leaflet_id, 52 title, 53 description, 54}: { 55 root_entity: string; 56 publication_uri: string; 57 leaflet_id: string; 58 title?: string; 59 description?: string; 60}) { 61 const oauthClient = await createOauthClient(); 62 let identity = await getIdentityData(); 63 if (!identity || !identity.atp_did) throw new Error("No Identity"); 64 65 let credentialSession = await oauthClient.restore(identity.atp_did); 66 let agent = new AtpBaseClient( 67 credentialSession.fetchHandler.bind(credentialSession), 68 ); 69 let { data: draft } = await supabaseServerClient 70 .from("leaflets_in_publications") 71 .select("*, publications(*), documents(*)") 72 .eq("publication", publication_uri) 73 .eq("leaflet", leaflet_id) 74 .single(); 75 if (!draft || identity.atp_did !== draft?.publications?.identity_did) 76 throw new Error("No draft or not publisher"); 77 let { data } = await supabaseServerClient.rpc("get_facts", { 78 root: root_entity, 79 }); 80 let facts = (data as unknown as Fact<Attribute>[]) || []; 81 82 let { firstPageBlocks, pages } = await processBlocksToPages( 83 facts, 84 agent, 85 root_entity, 86 credentialSession.did!, 87 ); 88 89 let existingRecord = 90 (draft?.documents?.data as PubLeafletDocument.Record | undefined) || {}; 91 let record: PubLeafletDocument.Record = { 92 $type: "pub.leaflet.document", 93 author: credentialSession.did!, 94 publication: publication_uri, 95 publishedAt: new Date().toISOString(), 96 ...existingRecord, 97 title: title || "Untitled", 98 description: description || "", 99 pages: [ 100 { 101 $type: "pub.leaflet.pages.linearDocument", 102 blocks: firstPageBlocks, 103 }, 104 ...pages.map((p) => { 105 if (p.type === "canvas") { 106 return { 107 $type: "pub.leaflet.pages.canvas" as const, 108 id: p.id, 109 blocks: p.blocks as PubLeafletPagesCanvas.Block[], 110 }; 111 } else { 112 return { 113 $type: "pub.leaflet.pages.linearDocument" as const, 114 id: p.id, 115 blocks: p.blocks as PubLeafletPagesLinearDocument.Block[], 116 }; 117 } 118 }), 119 ], 120 }; 121 let rkey = draft?.doc ? new AtUri(draft.doc).rkey : TID.nextStr(); 122 let { data: result } = await agent.com.atproto.repo.putRecord({ 123 rkey, 124 repo: credentialSession.did!, 125 collection: record.$type, 126 record, 127 validate: false, //TODO publish the lexicon so we can validate! 128 }); 129 130 await supabaseServerClient.from("documents").upsert({ 131 uri: result.uri, 132 data: record as Json, 133 }); 134 await Promise.all([ 135 //Optimistically put these in! 136 supabaseServerClient.from("documents_in_publications").upsert({ 137 publication: record.publication, 138 document: result.uri, 139 }), 140 supabaseServerClient 141 .from("leaflets_in_publications") 142 .update({ 143 doc: result.uri, 144 }) 145 .eq("leaflet", leaflet_id) 146 .eq("publication", publication_uri), 147 ]); 148 149 return { rkey, record: JSON.parse(JSON.stringify(record)) }; 150} 151 152async function processBlocksToPages( 153 facts: Fact<any>[], 154 agent: AtpBaseClient, 155 root_entity: string, 156 did: string, 157) { 158 let scan = scanIndexLocal(facts); 159 let pages: { 160 id: string; 161 blocks: 162 | PubLeafletPagesLinearDocument.Block[] 163 | PubLeafletPagesCanvas.Block[]; 164 type: "doc" | "canvas"; 165 }[] = []; 166 167 // Create a lock to serialize image uploads 168 const uploadLock = new Lock(); 169 170 let firstEntity = scan.eav(root_entity, "root/page")?.[0]; 171 if (!firstEntity) throw new Error("No root page"); 172 let blocks = getBlocksWithTypeLocal(facts, firstEntity?.data.value); 173 let b = await blocksToRecord(blocks, did); 174 return { firstPageBlocks: b, pages }; 175 176 async function uploadImage(src: string) { 177 let data = await fetch(src); 178 if (data.status !== 200) return; 179 let binary = await data.blob(); 180 return uploadLock.withLock(async () => { 181 let blob = await agent.com.atproto.repo.uploadBlob(binary, { 182 headers: { "Content-Type": binary.type }, 183 }); 184 return blob.data.blob; 185 }); 186 } 187 async function blocksToRecord( 188 blocks: Block[], 189 did: string, 190 ): Promise<PubLeafletPagesLinearDocument.Block[]> { 191 let parsedBlocks = parseBlocksToList(blocks); 192 return ( 193 await Promise.all( 194 parsedBlocks.map(async (blockOrList) => { 195 if (blockOrList.type === "block") { 196 let alignmentValue = scan.eav( 197 blockOrList.block.value, 198 "block/text-alignment", 199 )[0]?.data.value; 200 let alignment: ExcludeString< 201 PubLeafletPagesLinearDocument.Block["alignment"] 202 > = 203 alignmentValue === "center" 204 ? "lex:pub.leaflet.pages.linearDocument#textAlignCenter" 205 : alignmentValue === "right" 206 ? "lex:pub.leaflet.pages.linearDocument#textAlignRight" 207 : alignmentValue === "justify" 208 ? "lex:pub.leaflet.pages.linearDocument#textAlignJustify" 209 : alignmentValue === "left" 210 ? "lex:pub.leaflet.pages.linearDocument#textAlignLeft" 211 : undefined; 212 let b = await blockToRecord(blockOrList.block, did); 213 if (!b) return []; 214 let block: PubLeafletPagesLinearDocument.Block = { 215 $type: "pub.leaflet.pages.linearDocument#block", 216 alignment, 217 block: b, 218 }; 219 return [block]; 220 } else { 221 let block: PubLeafletPagesLinearDocument.Block = { 222 $type: "pub.leaflet.pages.linearDocument#block", 223 block: { 224 $type: "pub.leaflet.blocks.unorderedList", 225 children: await childrenToRecord(blockOrList.children, did), 226 }, 227 }; 228 return [block]; 229 } 230 }), 231 ) 232 ).flat(); 233 } 234 235 async function childrenToRecord(children: List[], did: string) { 236 return ( 237 await Promise.all( 238 children.map(async (child) => { 239 let content = await blockToRecord(child.block, did); 240 if (!content) return []; 241 let record: PubLeafletBlocksUnorderedList.ListItem = { 242 $type: "pub.leaflet.blocks.unorderedList#listItem", 243 content, 244 children: await childrenToRecord(child.children, did), 245 }; 246 return record; 247 }), 248 ) 249 ).flat(); 250 } 251 async function blockToRecord(b: Block, did: string) { 252 const getBlockContent = (b: string) => { 253 let [content] = scan.eav(b, "block/text"); 254 if (!content) return ["", [] as PubLeafletRichtextFacet.Main[]] as const; 255 let doc = new Y.Doc(); 256 const update = base64.toByteArray(content.data.value); 257 Y.applyUpdate(doc, update); 258 let nodes = doc.getXmlElement("prosemirror").toArray(); 259 let stringValue = YJSFragmentToString(nodes[0]); 260 let facets = YJSFragmentToFacets(nodes[0]); 261 return [stringValue, facets] as const; 262 }; 263 if (b.type === "card") { 264 let [page] = scan.eav(b.value, "block/card"); 265 if (!page) return; 266 let [pageType] = scan.eav(page.data.value, "page/type"); 267 268 if (pageType?.data.value === "canvas") { 269 let canvasBlocks = await canvasBlocksToRecord(page.data.value, did); 270 pages.push({ 271 id: page.data.value, 272 blocks: canvasBlocks, 273 type: "canvas", 274 }); 275 } else { 276 let blocks = getBlocksWithTypeLocal(facts, page.data.value); 277 pages.push({ 278 id: page.data.value, 279 blocks: await blocksToRecord(blocks, did), 280 type: "doc", 281 }); 282 } 283 284 let block: $Typed<PubLeafletBlocksPage.Main> = { 285 $type: "pub.leaflet.blocks.page", 286 id: page.data.value, 287 }; 288 return block; 289 } 290 291 if (b.type === "bluesky-post") { 292 let [post] = scan.eav(b.value, "block/bluesky-post"); 293 if (!post || !post.data.value.post) return; 294 let block: $Typed<PubLeafletBlocksBskyPost.Main> = { 295 $type: ids.PubLeafletBlocksBskyPost, 296 postRef: { 297 uri: post.data.value.post.uri, 298 cid: post.data.value.post.cid, 299 }, 300 }; 301 return block; 302 } 303 if (b.type === "horizontal-rule") { 304 let block: $Typed<PubLeafletBlocksHorizontalRule.Main> = { 305 $type: ids.PubLeafletBlocksHorizontalRule, 306 }; 307 return block; 308 } 309 310 if (b.type === "heading") { 311 let [headingLevel] = scan.eav(b.value, "block/heading-level"); 312 313 let [stringValue, facets] = getBlockContent(b.value); 314 let block: $Typed<PubLeafletBlocksHeader.Main> = { 315 $type: "pub.leaflet.blocks.header", 316 level: headingLevel?.data.value || 1, 317 plaintext: stringValue, 318 facets, 319 }; 320 return block; 321 } 322 323 if (b.type === "blockquote") { 324 let [stringValue, facets] = getBlockContent(b.value); 325 let block: $Typed<PubLeafletBlocksBlockquote.Main> = { 326 $type: ids.PubLeafletBlocksBlockquote, 327 plaintext: stringValue, 328 facets, 329 }; 330 return block; 331 } 332 333 if (b.type == "text") { 334 let [stringValue, facets] = getBlockContent(b.value); 335 let block: $Typed<PubLeafletBlocksText.Main> = { 336 $type: ids.PubLeafletBlocksText, 337 plaintext: stringValue, 338 facets, 339 }; 340 return block; 341 } 342 if (b.type === "embed") { 343 let [url] = scan.eav(b.value, "embed/url"); 344 let [height] = scan.eav(b.value, "embed/height"); 345 if (!url) return; 346 let block: $Typed<PubLeafletBlocksIframe.Main> = { 347 $type: "pub.leaflet.blocks.iframe", 348 url: url.data.value, 349 height: height?.data.value || 600, 350 }; 351 return block; 352 } 353 if (b.type == "image") { 354 let [image] = scan.eav(b.value, "block/image"); 355 if (!image) return; 356 let [altText] = scan.eav(b.value, "image/alt"); 357 let blobref = await uploadImage(image.data.src); 358 if (!blobref) return; 359 let block: $Typed<PubLeafletBlocksImage.Main> = { 360 $type: "pub.leaflet.blocks.image", 361 image: blobref, 362 aspectRatio: { 363 height: image.data.height, 364 width: image.data.width, 365 }, 366 alt: altText ? altText.data.value : undefined, 367 }; 368 return block; 369 } 370 if (b.type === "link") { 371 let [previewImage] = scan.eav(b.value, "link/preview"); 372 let [description] = scan.eav(b.value, "link/description"); 373 let [src] = scan.eav(b.value, "link/url"); 374 if (!src) return; 375 let blobref = previewImage 376 ? await uploadImage(previewImage?.data.src) 377 : undefined; 378 let [title] = scan.eav(b.value, "link/title"); 379 let block: $Typed<PubLeafletBlocksWebsite.Main> = { 380 $type: "pub.leaflet.blocks.website", 381 previewImage: blobref, 382 src: src.data.value, 383 description: description?.data.value, 384 title: title?.data.value, 385 }; 386 return block; 387 } 388 if (b.type === "code") { 389 let [language] = scan.eav(b.value, "block/code-language"); 390 let [code] = scan.eav(b.value, "block/code"); 391 let [theme] = scan.eav(root_entity, "theme/code-theme"); 392 let block: $Typed<PubLeafletBlocksCode.Main> = { 393 $type: "pub.leaflet.blocks.code", 394 language: language?.data.value, 395 plaintext: code?.data.value || "", 396 syntaxHighlightingTheme: theme?.data.value, 397 }; 398 return block; 399 } 400 if (b.type === "math") { 401 let [math] = scan.eav(b.value, "block/math"); 402 let block: $Typed<PubLeafletBlocksMath.Main> = { 403 $type: "pub.leaflet.blocks.math", 404 tex: math?.data.value || "", 405 }; 406 return block; 407 } 408 if (b.type === "poll") { 409 // Get poll options from the entity 410 let pollOptions = scan.eav(b.value, "poll/options"); 411 let options: PubLeafletPollDefinition.Option[] = pollOptions.map( 412 (opt) => { 413 let optionName = scan.eav(opt.data.value, "poll-option/name")?.[0]; 414 return { 415 $type: "pub.leaflet.poll.definition#option", 416 text: optionName?.data.value || "", 417 }; 418 }, 419 ); 420 421 // Create the poll definition record 422 let pollRecord: PubLeafletPollDefinition.Record = { 423 $type: "pub.leaflet.poll.definition", 424 name: "Poll", // Default name, can be customized 425 options, 426 }; 427 428 // Upload the poll record 429 let { data: pollResult } = await agent.com.atproto.repo.putRecord({ 430 //use the entity id as the rkey so we can associate it in the editor 431 rkey: b.value, 432 repo: did, 433 collection: pollRecord.$type, 434 record: pollRecord, 435 validate: false, 436 }); 437 438 // Optimistically write poll definition to database 439 console.log( 440 await supabaseServerClient.from("atp_poll_records").upsert({ 441 uri: pollResult.uri, 442 cid: pollResult.cid, 443 record: pollRecord as Json, 444 }), 445 ); 446 447 // Return a poll block with reference to the poll record 448 let block: $Typed<PubLeafletBlocksPoll.Main> = { 449 $type: "pub.leaflet.blocks.poll", 450 pollRef: { 451 uri: pollResult.uri, 452 cid: pollResult.cid, 453 }, 454 }; 455 return block; 456 } 457 if (b.type === "button") { 458 let [text] = scan.eav(b.value, "button/text"); 459 let [url] = scan.eav(b.value, "button/url"); 460 if (!text || !url) return; 461 let block: $Typed<PubLeafletBlocksButton.Main> = { 462 $type: "pub.leaflet.blocks.button", 463 text: text.data.value, 464 url: url.data.value, 465 }; 466 return block; 467 } 468 return; 469 } 470 471 async function canvasBlocksToRecord( 472 pageID: string, 473 did: string, 474 ): Promise<PubLeafletPagesCanvas.Block[]> { 475 let canvasBlocks = scan.eav(pageID, "canvas/block"); 476 return ( 477 await Promise.all( 478 canvasBlocks.map(async (canvasBlock) => { 479 let blockEntity = canvasBlock.data.value; 480 let position = canvasBlock.data.position; 481 482 // Get the block content 483 let blockType = scan.eav(blockEntity, "block/type")?.[0]; 484 if (!blockType) return null; 485 486 let block: Block = { 487 type: blockType.data.value, 488 value: blockEntity, 489 parent: pageID, 490 position: "", 491 factID: canvasBlock.id, 492 }; 493 494 let content = await blockToRecord(block, did); 495 if (!content) return null; 496 497 // Get canvas-specific properties 498 let width = 499 scan.eav(blockEntity, "canvas/block/width")?.[0]?.data.value || 360; 500 let rotation = scan.eav(blockEntity, "canvas/block/rotation")?.[0] 501 ?.data.value; 502 503 let canvasBlockRecord: PubLeafletPagesCanvas.Block = { 504 $type: "pub.leaflet.pages.canvas#block", 505 block: content, 506 x: Math.floor(position.x), 507 y: Math.floor(position.y), 508 width: Math.floor(width), 509 ...(rotation !== undefined && { rotation: Math.floor(rotation) }), 510 }; 511 512 return canvasBlockRecord; 513 }), 514 ) 515 ).filter((b): b is PubLeafletPagesCanvas.Block => b !== null); 516 } 517} 518 519function YJSFragmentToFacets( 520 node: Y.XmlElement | Y.XmlText | Y.XmlHook, 521): PubLeafletRichtextFacet.Main[] { 522 if (node.constructor === Y.XmlElement) { 523 return node 524 .toArray() 525 .map((f) => YJSFragmentToFacets(f)) 526 .flat(); 527 } 528 if (node.constructor === Y.XmlText) { 529 let facets: PubLeafletRichtextFacet.Main[] = []; 530 let delta = node.toDelta() as Delta[]; 531 let byteStart = 0; 532 for (let d of delta) { 533 let unicodestring = new UnicodeString(d.insert); 534 let facet: PubLeafletRichtextFacet.Main = { 535 index: { 536 byteStart, 537 byteEnd: byteStart + unicodestring.length, 538 }, 539 features: [], 540 }; 541 542 if (d.attributes?.strikethrough) 543 facet.features.push({ 544 $type: "pub.leaflet.richtext.facet#strikethrough", 545 }); 546 547 if (d.attributes?.code) 548 facet.features.push({ $type: "pub.leaflet.richtext.facet#code" }); 549 if (d.attributes?.highlight) 550 facet.features.push({ $type: "pub.leaflet.richtext.facet#highlight" }); 551 if (d.attributes?.underline) 552 facet.features.push({ $type: "pub.leaflet.richtext.facet#underline" }); 553 if (d.attributes?.strong) 554 facet.features.push({ $type: "pub.leaflet.richtext.facet#bold" }); 555 if (d.attributes?.em) 556 facet.features.push({ $type: "pub.leaflet.richtext.facet#italic" }); 557 if (d.attributes?.link) 558 facet.features.push({ 559 $type: "pub.leaflet.richtext.facet#link", 560 uri: d.attributes.link.href, 561 }); 562 if (facet.features.length > 0) facets.push(facet); 563 byteStart += unicodestring.length; 564 } 565 return facets; 566 } 567 return []; 568} 569 570type ExcludeString<T> = T extends string 571 ? string extends T 572 ? never 573 : T /* maybe literal, not the whole `string` */ 574 : T; /* not a string */