a tool for shared writing and social publishing
at feature/fonts 746 lines 22 kB view raw
1import { DeepReadonly, Replicache, WriteTransaction } from "replicache"; 2import type { Fact, ReplicacheMutators } from "."; 3import type { Attribute, Attributes, FilterAttributes } from "./attributes"; 4import { SupabaseClient } from "@supabase/supabase-js"; 5import { Database } from "supabase/database.types"; 6import { generateKeyBetween } from "fractional-indexing"; 7import { v7 } from "uuid"; 8 9export type MutationContext = { 10 permission_token_id: string; 11 createEntity: (args: { 12 entityID: string; 13 permission_set: string; 14 }) => Promise<boolean>; 15 scanIndex: { 16 eav: <A extends Attribute>( 17 entity: string, 18 attribute: A, 19 ) => Promise<DeepReadonly<Fact<A>[]>>; 20 }; 21 deleteEntity: (entity: string) => Promise<void>; 22 assertFact: <A extends Attribute>( 23 f: Omit<Fact<A>, "id"> & { id?: string }, 24 ) => Promise<void>; 25 retractFact: (id: string) => Promise<void>; 26 runOnServer( 27 cb: (ctx: { supabase: SupabaseClient<Database> }) => Promise<void>, 28 ): Promise<void>; 29 runOnClient( 30 cb: (ctx: { 31 supabase: SupabaseClient<Database>; 32 tx: WriteTransaction; 33 }) => Promise<void>, 34 ): Promise<void>; 35}; 36 37type Mutation<T> = ( 38 args: T & { ignoreUndo?: true }, 39 ctx: MutationContext, 40) => Promise<void>; 41 42const addCanvasBlock: Mutation<{ 43 parent: string; 44 permission_set: string; 45 factID: string; 46 type: Fact<"block/type">["data"]["value"]; 47 newEntityID: string; 48 position: { x: number; y: number }; 49}> = async (args, ctx) => { 50 await ctx.createEntity({ 51 entityID: args.newEntityID, 52 permission_set: args.permission_set, 53 }); 54 await ctx.assertFact({ 55 entity: args.parent, 56 id: args.factID, 57 data: { 58 type: "spatial-reference", 59 value: args.newEntityID, 60 position: args.position, 61 }, 62 attribute: "canvas/block", 63 }); 64 await ctx.assertFact({ 65 entity: args.newEntityID, 66 data: { type: "block-type-union", value: args.type }, 67 attribute: "block/type", 68 }); 69}; 70 71const addBlock: Mutation<{ 72 parent: string; 73 permission_set: string; 74 factID: string; 75 type: Fact<"block/type">["data"]["value"]; 76 newEntityID: string; 77 position: string; 78}> = async (args, ctx) => { 79 await ctx.createEntity({ 80 entityID: args.newEntityID, 81 permission_set: args.permission_set, 82 }); 83 await ctx.assertFact({ 84 entity: args.parent, 85 id: args.factID, 86 data: { 87 type: "ordered-reference", 88 value: args.newEntityID, 89 position: args.position, 90 }, 91 attribute: "card/block", 92 }); 93 await ctx.assertFact({ 94 entity: args.newEntityID, 95 data: { type: "block-type-union", value: args.type }, 96 attribute: "block/type", 97 }); 98}; 99 100const addLastBlock: Mutation<{ 101 parent: string; 102 factID: string; 103 entity: string; 104}> = async (args, ctx) => { 105 let children = await ctx.scanIndex.eav(args.parent, "card/block"); 106 let lastChild = children.toSorted((a, b) => 107 a.data.position > b.data.position ? 1 : -1, 108 )[children.length - 1]; 109 await ctx.assertFact({ 110 entity: args.parent, 111 id: args.factID, 112 attribute: "card/block", 113 data: { 114 type: "ordered-reference", 115 value: args.entity, 116 position: generateKeyBetween(lastChild?.data.position || null, null), 117 }, 118 }); 119}; 120 121const moveBlock: Mutation<{ 122 oldParent: string; 123 block: string; 124 newParent: string; 125 position: 126 | { type: "first" } 127 | { type: "end" } 128 | { type: "after"; entity: string }; 129}> = async (args, ctx) => { 130 let children = ( 131 await ctx.scanIndex.eav(args.oldParent, "card/block") 132 ).toSorted((a, b) => (a.data.position > b.data.position ? 1 : -1)); 133 let newSiblings = ( 134 await ctx.scanIndex.eav(args.newParent, "card/block") 135 ).toSorted((a, b) => (a.data.position > b.data.position ? 1 : -1)); 136 let block = children.find((f) => f.data.value === args.block); 137 if (!block) return; 138 await ctx.retractFact(block.id); 139 let newPosition; 140 let pos = args.position; 141 switch (pos.type) { 142 case "first": { 143 newPosition = generateKeyBetween( 144 null, 145 newSiblings[0]?.data.position || null, 146 ); 147 break; 148 } 149 case "end": { 150 newPosition = generateKeyBetween( 151 newSiblings[newSiblings.length - 1]?.data.position || null, 152 null, 153 ); 154 break; 155 } 156 case "after": { 157 let index = newSiblings.findIndex((f) => f.data.value == pos?.entity); 158 newPosition = generateKeyBetween( 159 newSiblings[index]?.data.position || null, 160 newSiblings[index + 1]?.data.position || null, 161 ); 162 } 163 } 164 await ctx.assertFact({ 165 id: block.id, 166 entity: args.newParent, 167 attribute: "card/block", 168 data: { 169 type: "ordered-reference", 170 value: block.data.value, 171 position: newPosition, 172 }, 173 }); 174}; 175const moveChildren: Mutation<{ 176 oldParent: string; 177 newParent: string; 178 after: string | null; 179}> = async (args, ctx) => { 180 let children = ( 181 await ctx.scanIndex.eav(args.oldParent, "card/block") 182 ).toSorted((a, b) => (a.data.position > b.data.position ? 1 : -1)); 183 let newSiblings = ( 184 await ctx.scanIndex.eav(args.newParent, "card/block") 185 ).toSorted((a, b) => (a.data.position > b.data.position ? 1 : -1)); 186 let index = newSiblings.findIndex((f) => f.data.value === args.after); 187 let newPosition = generateKeyBetween( 188 newSiblings[index]?.data.position || null, 189 newSiblings[index + 1]?.data.position || null, 190 ); 191 for (let child of children) { 192 await ctx.retractFact(child.id); 193 await ctx.assertFact({ 194 id: child.id, 195 entity: args.newParent, 196 attribute: "card/block", 197 data: { 198 type: "ordered-reference", 199 value: child.data.value, 200 position: newPosition, 201 }, 202 }); 203 newPosition = generateKeyBetween( 204 newPosition, 205 newSiblings[index + 1]?.data.position || null, 206 ); 207 } 208}; 209 210const outdentBlock: Mutation<{ 211 oldParent: string; 212 newParent: string; 213 after: string; 214 block: string; 215 excludeFromSiblings?: string[]; 216}> = async (args, ctx) => { 217 //we should be able to get normal siblings here as we care only about one level 218 let newSiblings = ( 219 await ctx.scanIndex.eav(args.newParent, "card/block") 220 ).toSorted((a, b) => (a.data.position > b.data.position ? 1 : -1)); 221 let currentSiblings = ( 222 await ctx.scanIndex.eav(args.oldParent, "card/block") 223 ).toSorted((a, b) => (a.data.position > b.data.position ? 1 : -1)); 224 225 let currentFactIndex = currentSiblings.findIndex( 226 (f) => f.data.value === args.block, 227 ); 228 if (currentFactIndex === -1) return; 229 // Filter out blocks that are being processed separately (e.g., in multi-select outdent) 230 let excludeSet = new Set(args.excludeFromSiblings || []); 231 let currentSiblingsAfter = currentSiblings 232 .slice(currentFactIndex + 1) 233 .filter((sib) => !excludeSet.has(sib.data.value)); 234 let currentChildren = ( 235 await ctx.scanIndex.eav(args.block, "card/block") 236 ).toSorted((a, b) => (a.data.position > b.data.position ? 1 : -1)); 237 let lastPosition = 238 currentChildren[currentChildren.length - 1]?.data.position || null; 239 await ctx.retractFact(currentSiblings[currentFactIndex].id); 240 for (let sib of currentSiblingsAfter) { 241 await ctx.retractFact(sib.id); 242 lastPosition = generateKeyBetween(lastPosition, null); 243 await ctx.assertFact({ 244 entity: args.block, 245 id: sib.id, 246 attribute: "card/block", 247 data: { 248 type: "ordered-reference", 249 position: lastPosition, 250 value: sib.data.value, 251 }, 252 }); 253 } 254 255 let index = newSiblings.findIndex((f) => f.data.value === args.after); 256 if (index === -1) return; 257 let newPosition = generateKeyBetween( 258 newSiblings[index]?.data.position, 259 newSiblings[index + 1]?.data.position || null, 260 ); 261 await ctx.assertFact({ 262 id: currentSiblings[currentFactIndex].id, 263 entity: args.newParent, 264 attribute: "card/block", 265 data: { 266 type: "ordered-reference", 267 position: newPosition, 268 value: args.block, 269 }, 270 }); 271}; 272 273const addPageLinkBlock: Mutation<{ 274 type: "canvas" | "doc"; 275 permission_set: string; 276 blockEntity: string; 277 firstBlockEntity: string; 278 firstBlockFactID: string; 279 pageEntity: string; 280}> = async (args, ctx) => { 281 await ctx.createEntity({ 282 entityID: args.pageEntity, 283 permission_set: args.permission_set, 284 }); 285 await ctx.assertFact({ 286 entity: args.blockEntity, 287 attribute: "block/card", 288 data: { type: "reference", value: args.pageEntity }, 289 }); 290 await ctx.assertFact({ 291 attribute: "page/type", 292 entity: args.pageEntity, 293 data: { type: "page-type-union", value: args.type }, 294 }); 295 await addBlock( 296 { 297 factID: args.firstBlockFactID, 298 permission_set: args.permission_set, 299 newEntityID: args.firstBlockEntity, 300 type: "heading", 301 parent: args.pageEntity, 302 position: "a0", 303 }, 304 ctx, 305 ); 306}; 307 308const retractFact: Mutation<{ factID: string }> = async (args, ctx) => { 309 await ctx.retractFact(args.factID); 310}; 311 312const removeBlock: Mutation< 313 { blockEntity: string } | { blockEntity: string }[] 314> = async (args, ctx) => { 315 for (let block of [args].flat()) { 316 let [image] = await ctx.scanIndex.eav(block.blockEntity, "block/image"); 317 await ctx.runOnServer(async ({ supabase }) => { 318 if (image) { 319 let paths = image.data.src.split("/"); 320 await supabase.storage 321 .from("minilink-user-assets") 322 .remove([paths[paths.length - 1]]); 323 324 // Clear cover image if this block is the cover image 325 // First try leaflets_in_publications 326 const { data: pubResult } = await supabase 327 .from("leaflets_in_publications") 328 .update({ cover_image: null }) 329 .eq("leaflet", ctx.permission_token_id) 330 .eq("cover_image", block.blockEntity) 331 .select("leaflet"); 332 333 // If no rows updated, try leaflets_to_documents 334 if (!pubResult || pubResult.length === 0) { 335 await supabase 336 .from("leaflets_to_documents") 337 .update({ cover_image: null }) 338 .eq("leaflet", ctx.permission_token_id) 339 .eq("cover_image", block.blockEntity); 340 } 341 } 342 }); 343 await ctx.runOnClient(async ({ tx }) => { 344 let cache = await caches.open("minilink-user-assets"); 345 if (image) { 346 await cache.delete(image.data.src + "?local"); 347 348 // Clear cover image in client state if this block was the cover image 349 let currentCoverImage = await tx.get("publication_cover_image"); 350 if (currentCoverImage === block.blockEntity) { 351 await tx.set("publication_cover_image", null); 352 } 353 } 354 }); 355 await ctx.deleteEntity(block.blockEntity); 356 } 357}; 358 359const deleteEntity: Mutation<{ entity: string }> = async (args, ctx) => { 360 await ctx.deleteEntity(args.entity); 361}; 362 363export type FactInput = { 364 [k in Attribute]: Omit<Fact<k>, "id"> & { id?: string }; 365}[Attribute]; 366const assertFact: Mutation<FactInput | Array<FactInput>> = async ( 367 args, 368 ctx, 369) => { 370 for (let f of [args].flat()) { 371 await ctx.assertFact(f); 372 } 373}; 374 375const increaseHeadingLevel: Mutation<{ entityID: string }> = async ( 376 args, 377 ctx, 378) => { 379 let blockType = (await ctx.scanIndex.eav(args.entityID, "block/type"))[0]; 380 let headinglevel = ( 381 await ctx.scanIndex.eav(args.entityID, "block/heading-level") 382 )[0]; 383 if (blockType?.data.value !== "heading") 384 await ctx.assertFact({ 385 entity: args.entityID, 386 attribute: "block/type", 387 data: { type: "block-type-union", value: "heading" }, 388 }); 389 390 if (!headinglevel || blockType?.data.value !== "heading") { 391 return await ctx.assertFact({ 392 entity: args.entityID, 393 attribute: "block/heading-level", 394 data: { type: "number", value: 1 }, 395 }); 396 } 397 if (headinglevel?.data.value === 3) return; 398 return await ctx.assertFact({ 399 entity: args.entityID, 400 attribute: "block/heading-level", 401 data: { type: "number", value: headinglevel.data.value + 1 }, 402 }); 403}; 404 405const moveBlockUp: Mutation<{ entityID: string; parent: string }> = async ( 406 args, 407 ctx, 408) => { 409 let children = (await ctx.scanIndex.eav(args.parent, "card/block")).toSorted( 410 (a, b) => (a.data.position > b.data.position ? 1 : -1), 411 ); 412 let index = children.findIndex((f) => f.data.value === args.entityID); 413 if (index === -1) return; 414 let next = children[index - 1]; 415 if (!next) return; 416 await ctx.retractFact(children[index].id); 417 await ctx.assertFact({ 418 id: children[index].id, 419 entity: args.parent, 420 attribute: "card/block", 421 data: { 422 type: "ordered-reference", 423 position: generateKeyBetween( 424 children[index - 2]?.data.position || null, 425 next.data.position, 426 ), 427 value: args.entityID, 428 }, 429 }); 430}; 431const moveBlockDown: Mutation<{ 432 entityID: string; 433 parent: string; 434 permission_set?: string; 435}> = async (args, ctx) => { 436 let children = (await ctx.scanIndex.eav(args.parent, "card/block")).toSorted( 437 (a, b) => (a.data.position > b.data.position ? 1 : -1), 438 ); 439 let index = children.findIndex((f) => f.data.value === args.entityID); 440 if (index === -1) return; 441 let next = children[index + 1]; 442 if (!next) { 443 // If this is the last block, create a new empty block above it using the addBlock helper 444 if (!args.permission_set) return; // Can't create block without permission_set 445 446 let newEntityID = v7(); 447 let previousBlock = children[index - 1]; 448 let position = generateKeyBetween( 449 previousBlock?.data.position || null, 450 children[index].data.position, 451 ); 452 453 // Call the addBlock mutation helper directly 454 await addBlock( 455 { 456 parent: args.parent, 457 permission_set: args.permission_set, 458 factID: v7(), 459 type: "text", 460 newEntityID: newEntityID, 461 position: position, 462 }, 463 ctx, 464 ); 465 return; 466 } 467 await ctx.retractFact(children[index].id); 468 await ctx.assertFact({ 469 id: children[index].id, 470 entity: args.parent, 471 attribute: "card/block", 472 data: { 473 type: "ordered-reference", 474 position: generateKeyBetween( 475 next.data.position, 476 children[index + 2]?.data.position || null, 477 ), 478 value: args.entityID, 479 }, 480 }); 481}; 482 483const createEntity: Mutation< 484 Array<{ entityID: string; permission_set: string }> 485> = async (args, ctx) => { 486 for (let newentity of args) { 487 await ctx.createEntity(newentity); 488 } 489}; 490 491const createDraft: Mutation<{ 492 mailboxEntity: string; 493 newEntity: string; 494 permission_set: string; 495 firstBlockEntity: string; 496 firstBlockFactID: string; 497}> = async (args, ctx) => { 498 let [existingDraft] = await ctx.scanIndex.eav( 499 args.mailboxEntity, 500 "mailbox/draft", 501 ); 502 if (existingDraft) return; 503 await ctx.createEntity({ 504 entityID: args.newEntity, 505 permission_set: args.permission_set, 506 }); 507 await ctx.assertFact({ 508 entity: args.mailboxEntity, 509 attribute: "mailbox/draft", 510 data: { type: "reference", value: args.newEntity }, 511 }); 512 await addBlock( 513 { 514 factID: args.firstBlockFactID, 515 permission_set: args.permission_set, 516 newEntityID: args.firstBlockEntity, 517 type: "text", 518 parent: args.newEntity, 519 position: "a0", 520 }, 521 ctx, 522 ); 523}; 524 525const archiveDraft: Mutation<{ 526 mailboxEntity: string; 527 archiveEntity: string; 528 newBlockEntity: string; 529 entity_set: string; 530}> = async (args, ctx) => { 531 let [existingDraft] = await ctx.scanIndex.eav( 532 args.mailboxEntity, 533 "mailbox/draft", 534 ); 535 if (!existingDraft) return; 536 537 let [archive] = await ctx.scanIndex.eav( 538 args.mailboxEntity, 539 "mailbox/archive", 540 ); 541 let archiveEntity = archive?.data.value; 542 if (!archive) { 543 archiveEntity = args.archiveEntity; 544 await ctx.createEntity({ 545 entityID: archiveEntity, 546 permission_set: args.entity_set, 547 }); 548 await ctx.assertFact({ 549 entity: args.mailboxEntity, 550 attribute: "mailbox/archive", 551 data: { type: "reference", value: archiveEntity }, 552 }); 553 } 554 555 let archiveChildren = await ctx.scanIndex.eav(archiveEntity, "card/block"); 556 let firstChild = archiveChildren.toSorted((a, b) => 557 a.data.position > b.data.position ? 1 : -1, 558 )[0]; 559 560 await ctx.createEntity({ 561 entityID: args.newBlockEntity, 562 permission_set: args.entity_set, 563 }); 564 await ctx.assertFact({ 565 entity: args.newBlockEntity, 566 attribute: "block/type", 567 data: { type: "block-type-union", value: "card" }, 568 }); 569 570 await ctx.assertFact({ 571 entity: args.newBlockEntity, 572 attribute: "block/card", 573 data: { type: "reference", value: existingDraft.data.value }, 574 }); 575 576 await ctx.assertFact({ 577 entity: archiveEntity, 578 attribute: "card/block", 579 data: { 580 type: "ordered-reference", 581 value: args.newBlockEntity, 582 position: generateKeyBetween(null, firstChild?.data.position), 583 }, 584 }); 585 586 await ctx.retractFact(existingDraft.id); 587}; 588 589const retractAttribute: Mutation<{ 590 entity: string; 591 attribute: 592 | keyof FilterAttributes<{ cardinality: "one" }> 593 | Array<keyof FilterAttributes<{ cardinality: "one" }>>; 594}> = async (args, ctx) => { 595 for (let a of [args.attribute].flat()) { 596 let fact = (await ctx.scanIndex.eav(args.entity, a))[0]; 597 if (fact) await ctx.retractFact(fact.id); 598 } 599}; 600 601const toggleTodoState: Mutation<{ entityID: string }> = async (args, ctx) => { 602 let [checked] = await ctx.scanIndex.eav(args.entityID, "block/check-list"); 603 if (!checked) { 604 await ctx.assertFact({ 605 entity: args.entityID, 606 attribute: "block/check-list", 607 data: { type: "boolean", value: false }, 608 }); 609 } else if (!checked.data.value) { 610 await ctx.assertFact({ 611 entity: args.entityID, 612 attribute: "block/check-list", 613 data: { type: "boolean", value: true }, 614 }); 615 } else { 616 await ctx.retractFact(checked.id); 617 } 618}; 619 620const addPollOption: Mutation<{ 621 pollEntity: string; 622 pollOptionEntity: string; 623 pollOptionName: string; 624 permission_set: string; 625 factID: string; 626}> = async (args, ctx) => { 627 await ctx.createEntity({ 628 entityID: args.pollOptionEntity, 629 permission_set: args.permission_set, 630 }); 631 632 await ctx.assertFact({ 633 entity: args.pollOptionEntity, 634 attribute: "poll-option/name", 635 data: { type: "string", value: args.pollOptionName }, 636 }); 637 638 let children = await ctx.scanIndex.eav(args.pollEntity, "poll/options"); 639 let lastChild = children.toSorted((a, b) => 640 a.data.position > b.data.position ? 1 : -1, 641 )[children.length - 1]; 642 643 await ctx.assertFact({ 644 entity: args.pollEntity, 645 id: args.factID, 646 attribute: "poll/options", 647 data: { 648 type: "ordered-reference", 649 value: args.pollOptionEntity, 650 position: generateKeyBetween(lastChild?.data.position || null, null), 651 }, 652 }); 653}; 654 655const removePollOption: Mutation<{ 656 optionEntity: string; 657}> = async (args, ctx) => { 658 await ctx.deleteEntity(args.optionEntity); 659}; 660 661const updatePublicationDraft: Mutation<{ 662 title?: string; 663 description?: string; 664 tags?: string[]; 665 cover_image?: string | null; 666 localPublishedAt?: string | null; 667 preferences?: { 668 showComments?: boolean; 669 showMentions?: boolean; 670 showRecommends?: boolean; 671 } | null; 672}> = async (args, ctx) => { 673 await ctx.runOnServer(async (serverCtx) => { 674 const updates: { 675 description?: string; 676 title?: string; 677 tags?: string[]; 678 cover_image?: string | null; 679 preferences?: { 680 showComments?: boolean; 681 showMentions?: boolean; 682 showRecommends?: boolean; 683 } | null; 684 } = {}; 685 if (args.description !== undefined) updates.description = args.description; 686 if (args.title !== undefined) updates.title = args.title; 687 if (args.tags !== undefined) updates.tags = args.tags; 688 if (args.cover_image !== undefined) updates.cover_image = args.cover_image; 689 if (args.preferences !== undefined) updates.preferences = args.preferences; 690 691 if (Object.keys(updates).length > 0) { 692 // First try to update leaflets_in_publications (for publications) 693 const { data: pubResult } = await serverCtx.supabase 694 .from("leaflets_in_publications") 695 .update(updates) 696 .eq("leaflet", ctx.permission_token_id) 697 .select("leaflet"); 698 699 // If no rows were updated in leaflets_in_publications, 700 // try leaflets_to_documents (for standalone documents) 701 if (!pubResult || pubResult.length === 0) { 702 await serverCtx.supabase 703 .from("leaflets_to_documents") 704 .update(updates) 705 .eq("leaflet", ctx.permission_token_id); 706 } 707 } 708 }); 709 await ctx.runOnClient(async ({ tx }) => { 710 if (args.title !== undefined) await tx.set("publication_title", args.title); 711 if (args.description !== undefined) 712 await tx.set("publication_description", args.description); 713 if (args.tags !== undefined) await tx.set("publication_tags", args.tags); 714 if (args.cover_image !== undefined) 715 await tx.set("publication_cover_image", args.cover_image); 716 if (args.localPublishedAt !== undefined) 717 await tx.set("publication_local_published_at", args.localPublishedAt); 718 if (args.preferences !== undefined) 719 await tx.set("post_preferences", args.preferences); 720 }); 721}; 722 723export const mutations = { 724 retractAttribute, 725 addBlock, 726 addCanvasBlock, 727 addLastBlock, 728 outdentBlock, 729 moveBlockUp, 730 moveBlockDown, 731 addPageLinkBlock, 732 moveBlock, 733 assertFact, 734 retractFact, 735 removeBlock, 736 deleteEntity, 737 moveChildren, 738 increaseHeadingLevel, 739 archiveDraft, 740 toggleTodoState, 741 createDraft, 742 createEntity, 743 addPollOption, 744 removePollOption, 745 updatePublicationDraft, 746};