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