Something went wrong. Try again.
a tool for shared writing and social publishing
Something went wrong. Try again.
38 kB · 1246 lines
TypeScript
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247import { DeepReadonly, Replicache, WriteTransaction } from "replicache";import type { Fact, ReplicacheMutators } from ".";import type { Attribute, Attributes, FilterAttributes } from "./attributes";import { SupabaseClient } from "@supabase/supabase-js";import { Database } from "supabase/database.types";import { generateKeyBetween } from "fractional-indexing";import { v7 } from "uuid";import { localImages } from "src/utils/addImage";
export type MutationContext = { permission_token_id: string; createEntity: (args: { entityID: string; permission_set: string; }) => Promise<boolean>; scanIndex: { eav: <A extends Attribute>( entity: string, attribute: A, ) => Promise<DeepReadonly<Fact<A>[]>>; }; deleteEntity: (entity: string) => Promise<void>; assertFact: <A extends Attribute>( f: Omit<Fact<A>, "id"> & { id?: string }, ) => Promise<void>; retractFact: (id: string) => Promise<void>; runOnServer( cb: (ctx: { supabase: SupabaseClient<Database> }) => Promise<void>, ): Promise<void>; runOnClient( cb: (ctx: { supabase: SupabaseClient<Database>; tx: WriteTransaction; }) => Promise<void>, ): Promise<void>;};
type Mutation<T> = ( args: T & { ignoreUndo?: true }, ctx: MutationContext,) => Promise<void>;
const addCanvasBlock: Mutation<{ parent: string; permission_set: string; factID: string; type: Fact<"block/type">["data"]["value"]; newEntityID: string; position: { x: number; y: number };}> = async (args, ctx) => { await ctx.createEntity({ entityID: args.newEntityID, permission_set: args.permission_set, }); await ctx.assertFact({ entity: args.parent, id: args.factID, data: { type: "spatial-reference", value: args.newEntityID, position: args.position, }, attribute: "canvas/block", }); await ctx.assertFact({ entity: args.newEntityID, data: { type: "block-type-union", value: args.type }, attribute: "block/type", });};
const addBlock: Mutation<{ parent: string; permission_set: string; factID: string; type: Fact<"block/type">["data"]["value"]; newEntityID: string; position: string; // Set in the same mutation as the type so the block is never briefly a // non-list block — otherwise a racing Enter sees no list data and inserts a // plain paragraph, breaking the list. list?: { listStyle?: Fact<"block/list-style">["data"]["value"]; checklist?: boolean; };}> = async (args, ctx) => { await ctx.createEntity({ entityID: args.newEntityID, permission_set: args.permission_set, }); await ctx.assertFact({ entity: args.parent, id: args.factID, data: { type: "ordered-reference", value: args.newEntityID, position: args.position, }, attribute: "card/block", }); await ctx.assertFact({ entity: args.newEntityID, data: { type: "block-type-union", value: args.type }, attribute: "block/type", }); if (args.list) { await ctx.assertFact({ entity: args.newEntityID, attribute: "block/is-list", data: { type: "boolean", value: true }, }); if (args.list.listStyle) { await ctx.assertFact({ entity: args.newEntityID, attribute: "block/list-style", data: { type: "list-style-union", value: args.list.listStyle }, }); } if (args.list.checklist !== undefined) { await ctx.assertFact({ entity: args.newEntityID, attribute: "block/check-list", data: { type: "boolean", value: args.list.checklist }, }); } }};
const addLastBlock: Mutation<{ parent: string; factID: string; entity: string;}> = async (args, ctx) => { let children = await ctx.scanIndex.eav(args.parent, "card/block"); let lastChild = children.toSorted((a, b) => a.data.position > b.data.position ? 1 : -1, )[children.length - 1]; await ctx.assertFact({ entity: args.parent, id: args.factID, attribute: "card/block", data: { type: "ordered-reference", value: args.entity, position: generateKeyBetween(lastChild?.data.position || null, null), }, });};
const moveBlock: Mutation<{ oldParent: string; block: string; newParent: string; position: | { type: "first" } | { type: "end" } | { type: "after"; entity: string };}> = async (args, ctx) => { let children = ( await ctx.scanIndex.eav(args.oldParent, "card/block") ).toSorted((a, b) => (a.data.position > b.data.position ? 1 : -1)); let newSiblings = ( await ctx.scanIndex.eav(args.newParent, "card/block") ).toSorted((a, b) => (a.data.position > b.data.position ? 1 : -1)); let block = children.find((f) => f.data.value === args.block); if (!block) return; // Move by overwriting the block's existing card/block fact in place (reusing // its id) rather than retract-then-assert. assertFact captures the old // (parent, position) as the single undo entry, so undo/redo of a move never // passes through an intermediate state where the fact is gone and the block // disappears. let newPosition; let pos = args.position; switch (pos.type) { case "first": { newPosition = generateKeyBetween( null, newSiblings[0]?.data.position || null, ); break; } case "end": { newPosition = generateKeyBetween( newSiblings[newSiblings.length - 1]?.data.position || null, null, ); break; } case "after": { let index = newSiblings.findIndex((f) => f.data.value == pos?.entity); newPosition = generateKeyBetween( newSiblings[index]?.data.position || null, newSiblings[index + 1]?.data.position || null, ); } } await ctx.assertFact({ id: block.id, entity: args.newParent, attribute: "card/block", data: { type: "ordered-reference", value: block.data.value, position: newPosition, }, });};const moveChildren: Mutation<{ oldParent: string; newParent: string; after: string | null;}> = async (args, ctx) => { let children = ( await ctx.scanIndex.eav(args.oldParent, "card/block") ).toSorted((a, b) => (a.data.position > b.data.position ? 1 : -1)); let newSiblings = ( await ctx.scanIndex.eav(args.newParent, "card/block") ).toSorted((a, b) => (a.data.position > b.data.position ? 1 : -1)); let index = newSiblings.findIndex((f) => f.data.value === args.after); let newPosition = generateKeyBetween( newSiblings[index]?.data.position || null, newSiblings[index + 1]?.data.position || null, ); for (let child of children) { // Reparent each child by overwriting its existing card/block fact in place // (reusing its id) rather than retract-then-assert. assertFact captures the // old (parent, position) as the single undo entry, so undo/redo of the move // never passes through an intermediate state where the child fact is gone. await ctx.assertFact({ id: child.id, entity: args.newParent, attribute: "card/block", data: { type: "ordered-reference", value: child.data.value, position: newPosition, }, }); newPosition = generateKeyBetween( newPosition, newSiblings[index + 1]?.data.position || null, ); }};
// Relocate a contiguous run of blocks (e.g. a folded heading's whole section)// under the same parent, packing them just after `after` (or at the start when// null) while preserving their relative order. Each block's card/block fact is// overwritten in place (reusing its id) so the whole move is one undo entry.const moveBlocks: Mutation<{ parent: string; blocks: string[]; after: string | null;}> = async (args, ctx) => { let children = (await ctx.scanIndex.eav(args.parent, "card/block")).toSorted( (a, b) => (a.data.position > b.data.position ? 1 : -1), ); let movingIds = new Set(args.blocks); let moving = args.blocks .map((v) => children.find((c) => c.data.value === v)) .filter((c): c is NonNullable<typeof c> => !!c); if (moving.length === 0) return; let rest = children.filter((c) => !movingIds.has(c.data.value)); let anchorIndex = args.after === null ? -1 : rest.findIndex((c) => c.data.value === args.after); let before = rest[anchorIndex]?.data.position || null; let after = rest[anchorIndex + 1]?.data.position || null; let position = generateKeyBetween(before, after); for (let block of moving) { await ctx.assertFact({ id: block.id, entity: args.parent, attribute: "card/block", data: { type: "ordered-reference", value: block.data.value, position, }, }); position = generateKeyBetween(position, after); }};
const outdentBlock: Mutation<{ oldParent: string; newParent: string; after: string; block: string; excludeFromSiblings?: string[];}> = async (args, ctx) => { //we should be able to get normal siblings here as we care only about one level let newSiblings = ( await ctx.scanIndex.eav(args.newParent, "card/block") ).toSorted((a, b) => (a.data.position > b.data.position ? 1 : -1)); let currentSiblings = ( await ctx.scanIndex.eav(args.oldParent, "card/block") ).toSorted((a, b) => (a.data.position > b.data.position ? 1 : -1));
let currentFactIndex = currentSiblings.findIndex( (f) => f.data.value === args.block, ); if (currentFactIndex === -1) return; // Bail before the writes below: a missing anchor here is a clean no-op, but // checking afterward would reparent the siblings under the block while leaving // the block itself unmoved, orphaning them and dropping them from the document. let index = newSiblings.findIndex((f) => f.data.value === args.after); if (index === -1) return; // Filter out blocks that are being processed separately (e.g., in multi-select outdent) let excludeSet = new Set(args.excludeFromSiblings || []); let currentSiblingsAfter = currentSiblings .slice(currentFactIndex + 1) .filter((sib) => !excludeSet.has(sib.data.value)); let currentChildren = ( await ctx.scanIndex.eav(args.block, "card/block") ).toSorted((a, b) => (a.data.position > b.data.position ? 1 : -1)); let lastPosition = currentChildren[currentChildren.length - 1]?.data.position || null; for (let sib of currentSiblingsAfter) { lastPosition = generateKeyBetween(lastPosition, null); // Reparent the sibling under the block by overwriting its card/block fact in // place (reusing its id) rather than retract-then-assert, so undo/redo of the // outdent never passes through an intermediate state where the sibling is gone. await ctx.assertFact({ entity: args.block, id: sib.id, attribute: "card/block", data: { type: "ordered-reference", position: lastPosition, value: sib.data.value, }, }); }
let newPosition = generateKeyBetween( newSiblings[index]?.data.position, newSiblings[index + 1]?.data.position || null, ); // Move the block to its new parent by overwriting its existing card/block fact // in place (reusing its id) rather than retract-then-assert, so undo/redo of // the outdent never passes through an intermediate state where the block is // gone (its reparented children would be momentarily orphaned). await ctx.assertFact({ id: currentSiblings[currentFactIndex].id, entity: args.newParent, attribute: "card/block", data: { type: "ordered-reference", position: newPosition, value: args.block, }, });};
const addPageLinkBlock: Mutation<{ type: "canvas" | "doc"; permission_set: string; blockEntity: string; firstBlockEntity: string; firstBlockFactID: string; pageEntity: string;}> = async (args, ctx) => { await ctx.createEntity({ entityID: args.pageEntity, permission_set: args.permission_set, }); await ctx.assertFact({ entity: args.blockEntity, attribute: "block/card", data: { type: "reference", value: args.pageEntity }, }); await ctx.assertFact({ attribute: "page/type", entity: args.pageEntity, data: { type: "page-type-union", value: args.type }, }); await addBlock( { factID: args.firstBlockFactID, permission_set: args.permission_set, newEntityID: args.firstBlockEntity, type: "heading", parent: args.pageEntity, position: "a0", }, ctx, );};
const retractFact: Mutation<{ factID: string }> = async (args, ctx) => { await ctx.retractFact(args.factID);};
// Add a content page to a publication draft leaflet's nav, seeded with a// single empty text block.const addPublicationNavPage: Mutation<{ rootEntity: string; pageEntity: string; permission_set: string; navFactID: string; route: string; title: string; firstBlockEntity: string; firstBlockFactID: string;}> = async (args, ctx) => { let entries = await ctx.scanIndex.eav(args.rootEntity, "root/page"); let last = entries.toSorted((a, b) => a.data.position > b.data.position ? 1 : -1, )[entries.length - 1]; await ctx.createEntity({ entityID: args.pageEntity, permission_set: args.permission_set, }); await ctx.assertFact({ id: args.navFactID, entity: args.rootEntity, attribute: "root/page", data: { type: "ordered-reference", value: args.pageEntity, position: generateKeyBetween(last?.data.position || null, null), }, }); await ctx.assertFact({ entity: args.pageEntity, attribute: "page/type", data: { type: "page-type-union", value: "doc" }, }); await ctx.assertFact({ entity: args.pageEntity, attribute: "page/route", data: { type: "string", value: args.route }, }); await ctx.assertFact({ entity: args.pageEntity, attribute: "page/title", data: { type: "string", value: args.title }, }); await addBlock( { factID: args.firstBlockFactID, permission_set: args.permission_set, newEntityID: args.firstBlockEntity, type: "text", parent: args.pageEntity, position: "a0", }, ctx, );};
// Add an external link tab: same root/page list as content pages, but with an// external url and no content.const addPublicationNavLink: Mutation<{ rootEntity: string; linkEntity: string; permission_set: string; navFactID: string; url: string; title: string;}> = async (args, ctx) => { let entries = await ctx.scanIndex.eav(args.rootEntity, "root/page"); let last = entries.toSorted((a, b) => a.data.position > b.data.position ? 1 : -1, )[entries.length - 1]; await ctx.createEntity({ entityID: args.linkEntity, permission_set: args.permission_set, }); await ctx.assertFact({ id: args.navFactID, entity: args.rootEntity, attribute: "root/page", data: { type: "ordered-reference", value: args.linkEntity, position: generateKeyBetween(last?.data.position || null, null), }, }); await ctx.assertFact({ entity: args.linkEntity, attribute: "page/external-url", data: { type: "string", value: args.url }, }); await ctx.assertFact({ entity: args.linkEntity, attribute: "page/title", data: { type: "string", value: args.title }, });};
const removePublicationNavEntry: Mutation<{ rootEntity: string; entity: string;}> = async (args, ctx) => { let entries = await ctx.scanIndex.eav(args.rootEntity, "root/page"); let fact = entries.find((f) => f.data.value === args.entity); if (fact) await ctx.retractFact(fact.id); await ctx.deleteEntity(args.entity);};
const removeBlock: Mutation< { blockEntity: string } | { blockEntity: string }[]> = async (args, ctx) => { for (let block of [args].flat()) { let [image] = await ctx.scanIndex.eav(block.blockEntity, "block/image"); await ctx.runOnServer(async ({ supabase }) => { if (image) { let paths = image.data.src.split("/"); await supabase.storage .from("minilink-user-assets") .remove([paths[paths.length - 1]]); } }); await ctx.runOnClient(async ({ tx }) => { if (image) { // Release the local preview's object URL. let localSrc = localImages.get(image.data.src); if (localSrc) { URL.revokeObjectURL(localSrc); localImages.delete(image.data.src); } } }); await ctx.deleteEntity(block.blockEntity); }};
const deleteEntity: Mutation<{ entity: string }> = async (args, ctx) => { await ctx.deleteEntity(args.entity);};
export type FactInput = { [k in Attribute]: Omit<Fact<k>, "id"> & { id?: string };}[Attribute];const assertFact: Mutation<FactInput | Array<FactInput>> = async ( args, ctx,) => { for (let f of [args].flat()) { await ctx.assertFact(f); }};
const increaseHeadingLevel: Mutation<{ entityID: string }> = async ( args, ctx,) => { let blockType = (await ctx.scanIndex.eav(args.entityID, "block/type"))[0]; let headinglevel = ( await ctx.scanIndex.eav(args.entityID, "block/heading-level") )[0]; if (blockType?.data.value !== "heading") await ctx.assertFact({ entity: args.entityID, attribute: "block/type", data: { type: "block-type-union", value: "heading" }, });
if (!headinglevel || blockType?.data.value !== "heading") { return await ctx.assertFact({ entity: args.entityID, attribute: "block/heading-level", data: { type: "number", value: 1 }, }); } if (headinglevel?.data.value === 3) return; return await ctx.assertFact({ entity: args.entityID, attribute: "block/heading-level", data: { type: "number", value: headinglevel.data.value + 1 }, });};
const moveBlockUp: Mutation<{ entityID: string; parent: string }> = async ( args, ctx,) => { let children = (await ctx.scanIndex.eav(args.parent, "card/block")).toSorted( (a, b) => (a.data.position > b.data.position ? 1 : -1), ); let index = children.findIndex((f) => f.data.value === args.entityID); if (index === -1) return; let next = children[index - 1]; if (!next) return; // Reorder by overwriting the block's card/block fact in place (reusing its // id), so the move is a single undo entry and undo/redo never blanks the // block through an intermediate retracted state. await ctx.assertFact({ id: children[index].id, entity: args.parent, attribute: "card/block", data: { type: "ordered-reference", position: generateKeyBetween( children[index - 2]?.data.position || null, next.data.position, ), value: args.entityID, }, });};const moveBlockDown: Mutation<{ entityID: string; parent: string; permission_set?: string;}> = async (args, ctx) => { let children = (await ctx.scanIndex.eav(args.parent, "card/block")).toSorted( (a, b) => (a.data.position > b.data.position ? 1 : -1), ); let index = children.findIndex((f) => f.data.value === args.entityID); if (index === -1) return; let next = children[index + 1]; if (!next) { // If this is the last block, create a new empty block above it using the addBlock helper if (!args.permission_set) return; // Can't create block without permission_set
let newEntityID = v7(); let previousBlock = children[index - 1]; let position = generateKeyBetween( previousBlock?.data.position || null, children[index].data.position, );
// Call the addBlock mutation helper directly await addBlock( { parent: args.parent, permission_set: args.permission_set, factID: v7(), type: "text", newEntityID: newEntityID, position: position, }, ctx, ); return; } // Reorder by overwriting the block's card/block fact in place (reusing its // id), so the move is a single undo entry and undo/redo never blanks the // block through an intermediate retracted state. await ctx.assertFact({ id: children[index].id, entity: args.parent, attribute: "card/block", data: { type: "ordered-reference", position: generateKeyBetween( next.data.position, children[index + 2]?.data.position || null, ), value: args.entityID, }, });};
const createEntity: Mutation< Array<{ entityID: string; permission_set: string }>> = async (args, ctx) => { for (let newentity of args) { await ctx.createEntity(newentity); }};
const createDraft: Mutation<{ mailboxEntity: string; newEntity: string; permission_set: string; firstBlockEntity: string; firstBlockFactID: string;}> = async (args, ctx) => { let [existingDraft] = await ctx.scanIndex.eav( args.mailboxEntity, "mailbox/draft", ); if (existingDraft) return; await ctx.createEntity({ entityID: args.newEntity, permission_set: args.permission_set, }); await ctx.assertFact({ entity: args.mailboxEntity, attribute: "mailbox/draft", data: { type: "reference", value: args.newEntity }, }); await addBlock( { factID: args.firstBlockFactID, permission_set: args.permission_set, newEntityID: args.firstBlockEntity, type: "text", parent: args.newEntity, position: "a0", }, ctx, );};
const archiveDraft: Mutation<{ mailboxEntity: string; archiveEntity: string; newBlockEntity: string; entity_set: string;}> = async (args, ctx) => { let [existingDraft] = await ctx.scanIndex.eav( args.mailboxEntity, "mailbox/draft", ); if (!existingDraft) return;
let [archive] = await ctx.scanIndex.eav( args.mailboxEntity, "mailbox/archive", ); let archiveEntity = archive?.data.value; if (!archive) { archiveEntity = args.archiveEntity; await ctx.createEntity({ entityID: archiveEntity, permission_set: args.entity_set, }); await ctx.assertFact({ entity: args.mailboxEntity, attribute: "mailbox/archive", data: { type: "reference", value: archiveEntity }, }); }
let archiveChildren = await ctx.scanIndex.eav(archiveEntity, "card/block"); let firstChild = archiveChildren.toSorted((a, b) => a.data.position > b.data.position ? 1 : -1, )[0];
await ctx.createEntity({ entityID: args.newBlockEntity, permission_set: args.entity_set, }); await ctx.assertFact({ entity: args.newBlockEntity, attribute: "block/type", data: { type: "block-type-union", value: "card" }, });
await ctx.assertFact({ entity: args.newBlockEntity, attribute: "block/card", data: { type: "reference", value: existingDraft.data.value }, });
await ctx.assertFact({ entity: archiveEntity, attribute: "card/block", data: { type: "ordered-reference", value: args.newBlockEntity, position: generateKeyBetween(null, firstChild?.data.position), }, });
await ctx.retractFact(existingDraft.id);};
const retractAttribute: Mutation<{ entity: string; attribute: | keyof FilterAttributes<{ cardinality: "one" }> | Array<keyof FilterAttributes<{ cardinality: "one" }>>;}> = async (args, ctx) => { for (let a of [args.attribute].flat()) { let fact = (await ctx.scanIndex.eav(args.entity, a))[0]; if (fact) await ctx.retractFact(fact.id); }};
const toggleTodoState: Mutation<{ entityID: string }> = async (args, ctx) => { let [checked] = await ctx.scanIndex.eav(args.entityID, "block/check-list"); if (!checked) { await ctx.assertFact({ entity: args.entityID, attribute: "block/check-list", data: { type: "boolean", value: false }, }); } else if (!checked.data.value) { await ctx.assertFact({ entity: args.entityID, attribute: "block/check-list", data: { type: "boolean", value: true }, }); } else { await ctx.retractFact(checked.id); }};
const addPollOption: Mutation<{ pollEntity: string; pollOptionEntity: string; pollOptionName: string; permission_set: string; factID: string;}> = async (args, ctx) => { await ctx.createEntity({ entityID: args.pollOptionEntity, permission_set: args.permission_set, });
await ctx.assertFact({ entity: args.pollOptionEntity, attribute: "poll-option/name", data: { type: "string", value: args.pollOptionName }, });
let children = await ctx.scanIndex.eav(args.pollEntity, "poll/options"); let lastChild = children.toSorted((a, b) => a.data.position > b.data.position ? 1 : -1, )[children.length - 1];
await ctx.assertFact({ entity: args.pollEntity, id: args.factID, attribute: "poll/options", data: { type: "ordered-reference", value: args.pollOptionEntity, position: generateKeyBetween(lastChild?.data.position || null, null), }, });};
const removePollOption: Mutation<{ optionEntity: string;}> = async (args, ctx) => { await ctx.deleteEntity(args.optionEntity);};
const addGalleryImage: Mutation<{ galleryEntity: string; imageEntity: string; permission_set: string; factID: string;}> = async (args, ctx) => { await ctx.createEntity({ entityID: args.imageEntity, permission_set: args.permission_set, });
let children = await ctx.scanIndex.eav(args.galleryEntity, "gallery/image"); let lastChild = children.toSorted((a, b) => a.data.position > b.data.position ? 1 : -1, )[children.length - 1];
await ctx.assertFact({ entity: args.galleryEntity, id: args.factID, attribute: "gallery/image", data: { type: "ordered-reference", value: args.imageEntity, position: generateKeyBetween(lastChild?.data.position || null, null), }, });};
const removeGalleryImage: Mutation<{ imageEntity: string;}> = async (args, ctx) => { let [image] = await ctx.scanIndex.eav(args.imageEntity, "block/image"); await ctx.runOnServer(async ({ supabase }) => { if (image) { let paths = image.data.src.split("/"); await supabase.storage .from("minilink-user-assets") .remove([paths[paths.length - 1]]); } }); await ctx.runOnClient(async ({ tx }) => { if (image) { let localSrc = localImages.get(image.data.src); if (localSrc) { URL.revokeObjectURL(localSrc); localImages.delete(image.data.src); } } }); await ctx.deleteEntity(args.imageEntity);};
const updatePublicationDraft: Mutation<{ title?: string; description?: string; tags?: string[]; localPublishedAt?: string | null; preferences?: { showComments?: boolean; showMentions?: boolean; showRecommends?: boolean; } | null;}> = async (args, ctx) => { await ctx.runOnServer(async (serverCtx) => { const updates: { description?: string; title?: string; tags?: string[]; preferences?: { showComments?: boolean; showMentions?: boolean; showRecommends?: boolean; } | null; } = {}; if (args.description !== undefined) updates.description = args.description; if (args.title !== undefined) updates.title = args.title; if (args.tags !== undefined) updates.tags = args.tags; if (args.preferences !== undefined) updates.preferences = args.preferences;
if (Object.keys(updates).length > 0) { // First try to update leaflets_in_publications (for publications) const { data: pubResult } = await serverCtx.supabase .from("leaflets_in_publications") .update(updates) .eq("leaflet", ctx.permission_token_id) .select("leaflet");
// If no rows were updated in leaflets_in_publications, // try leaflets_to_documents (for standalone documents) if (!pubResult || pubResult.length === 0) { await serverCtx.supabase .from("leaflets_to_documents") .update(updates) .eq("leaflet", ctx.permission_token_id); } } }); await ctx.runOnClient(async ({ tx }) => { if (args.title !== undefined) await tx.set("publication_title", args.title); if (args.description !== undefined) await tx.set("publication_description", args.description); if (args.tags !== undefined) await tx.set("publication_tags", args.tags); if (args.localPublishedAt !== undefined) await tx.set("publication_local_published_at", args.localPublishedAt); if (args.preferences !== undefined) await tx.set("post_preferences", args.preferences); });};
const updateLeafletMetadata: Mutation<{ title?: string; description?: string | null;}> = async (args, ctx) => { await ctx.runOnServer(async (serverCtx) => { const updates: { title?: string; description?: string | null } = {}; if (args.title !== undefined) updates.title = args.title; if (args.description !== undefined) updates.description = args.description; if (Object.keys(updates).length === 0) return;
const { data: writerToken } = await serverCtx.supabase .from("permission_tokens") .select("root_entity, permission_token_rights(write)") .eq("id", ctx.permission_token_id) .single(); if (!writerToken) return; const hasWrite = writerToken.permission_token_rights.some((r) => r.write); if (!hasWrite) return;
await serverCtx.supabase .from("permission_tokens") .update(updates) .eq("root_entity", writerToken.root_entity); });};
// Toggle a draft contributor (the byline for this draft). The selected dids// live in the leaflet_contributors table and are pulled into Replicache under// the "draft_contributors" key, so the byline syncs live across collaborators.const toggleDraftContributor: Mutation<{ contributor_did: string; selected: boolean;}> = async (args, ctx) => { await ctx.runOnServer(async ({ supabase }) => { let { contributor_did } = args; let leaflet = ctx.permission_token_id;
if (!args.selected) { await supabase .from("leaflet_contributors") .delete() .eq("leaflet", leaflet) .eq("contributor_did", contributor_did); return; }
// Adding: the target must be the publication owner or a confirmed contributor. let { data: link } = await supabase .from("leaflets_in_publications") .select("publication, publications(identity_did)") .eq("leaflet", leaflet) .maybeSingle(); if (!link?.publication) return;
let isOwner = contributor_did === link.publications?.identity_did; if (!isOwner) { let { data: pubContrib } = await supabase .from("publication_contributors") .select("confirmed") .eq("publication_uri", link.publication) .eq("contributor_did", contributor_did) .maybeSingle(); if (!pubContrib?.confirmed) return; }
await supabase .from("leaflet_contributors") .upsert( { leaflet, contributor_did }, { onConflict: "leaflet,contributor_did", ignoreDuplicates: true }, ); }); await ctx.runOnClient(async ({ tx }) => { let current = (await tx.get<string[]>("draft_contributors")) ?? []; let next = args.selected ? current.includes(args.contributor_did) ? current : [...current, args.contributor_did] : current.filter((d) => d !== args.contributor_did); await tx.set("draft_contributors", next); });};
const createFootnote: Mutation<{ footnoteEntityID: string; blockID: string; permission_set: string; position: string;}> = async (args, ctx) => { await ctx.createEntity({ entityID: args.footnoteEntityID, permission_set: args.permission_set, }); await ctx.assertFact({ entity: args.blockID, attribute: "block/footnote", data: { type: "ordered-reference", value: args.footnoteEntityID, position: args.position, }, });};
const deleteFootnote: Mutation<{ footnoteEntityID: string; blockID: string;}> = async (args, ctx) => { let footnotes = await ctx.scanIndex.eav(args.blockID, "block/footnote"); let fact = footnotes.find((f) => f.data.value === args.footnoteEntityID); if (fact) await ctx.retractFact(fact.id); await ctx.deleteEntity(args.footnoteEntityID);};
// A comment and a reply share the same body: an entity holding the YJS// content plus author/created-at facts, all carrying the author's did so the// server's authentication gate (sessionDid must match author_did) verifies// authorship. Content is a base64-encoded YJS update, composed locally and// only persisted on submit.async function createAuthoredEntity( ctx: MutationContext, args: { entityID: string; permission_set: string; authorDid: string; createdAt: string; content: string; },) { await ctx.createEntity({ entityID: args.entityID, permission_set: args.permission_set, }); await ctx.assertFact({ entity: args.entityID, attribute: "block/text", data: { type: "text", value: args.content }, author_did: args.authorDid, }); await ctx.assertFact({ entity: args.entityID, attribute: "comment/author", data: { type: "string", value: args.authorDid }, author_did: args.authorDid, }); await ctx.assertFact({ entity: args.entityID, attribute: "comment/created-at", data: { type: "string", value: args.createdAt }, author_did: args.authorDid, });}
const createEditorComment: Mutation<{ commentEntityID: string; blockID: string; permission_set: string; // Caller-generated so the client and server mint the same fact id for this // cardinality-many fact; undo retracts by id, and a diverged id leaves the // fact orphaned on the server to resurrect on the next pull. commentFactID: string; position: string; authorDid: string; createdAt: string; anchorStart: number; anchorEnd: number; content: string;}> = async (args, ctx) => { await createAuthoredEntity(ctx, { ...args, entityID: args.commentEntityID }); await ctx.assertFact({ id: args.commentFactID, entity: args.blockID, attribute: "block/comment", data: { type: "ordered-reference", value: args.commentEntityID, position: args.position, }, }); await ctx.assertFact({ entity: args.commentEntityID, attribute: "comment/anchor-start", data: { type: "number", value: args.anchorStart }, }); await ctx.assertFact({ entity: args.commentEntityID, attribute: "comment/anchor-end", data: { type: "number", value: args.anchorEnd }, });};
const createEditorCommentReply: Mutation<{ replyEntityID: string; commentEntityID: string; permission_set: string; // Caller-generated for the same client/server id agreement as commentFactID replyFactID: string; position: string; authorDid: string; createdAt: string; content: string;}> = async (args, ctx) => { await createAuthoredEntity(ctx, { ...args, entityID: args.replyEntityID }); await ctx.assertFact({ id: args.replyFactID, entity: args.commentEntityID, attribute: "comment/reply", data: { type: "ordered-reference", value: args.replyEntityID, position: args.position, }, });};
const deleteEditorComment: Mutation<{ commentEntityID: string; blockID: string;}> = async (args, ctx) => { // Fired both when a thread is resolved and by the editor's orphan diff when a // comment's anchor text is deleted. Deletes the root comment and all replies. let comments = await ctx.scanIndex.eav(args.blockID, "block/comment"); let fact = comments.find((f) => f.data.value === args.commentEntityID); if (fact) await ctx.retractFact(fact.id); let replies = await ctx.scanIndex.eav(args.commentEntityID, "comment/reply"); for (let reply of replies) { await ctx.deleteEntity(reply.data.value); } await ctx.deleteEntity(args.commentEntityID);};
const deleteEditorCommentReply: Mutation<{ replyEntityID: string; commentEntityID: string;}> = async (args, ctx) => { let replies = await ctx.scanIndex.eav(args.commentEntityID, "comment/reply"); let fact = replies.find((f) => f.data.value === args.replyEntityID); if (fact) await ctx.retractFact(fact.id); await ctx.deleteEntity(args.replyEntityID);};
// Replace a comment or reply body in place. Only the original author may// edit. The new content is a full YJS doc state with no shared history with// the old one, so we retract the existing block/text and assert the new value// rather than letting assertFact merge two independent docs together.const editEditorComment: Mutation<{ entityID: string; authorDid: string; content: string;}> = async (args, ctx) => { let author = await ctx.scanIndex.eav(args.entityID, "comment/author"); if (author[0]?.data.value !== args.authorDid) return; let existing = await ctx.scanIndex.eav(args.entityID, "block/text"); for (let fact of existing) await ctx.retractFact(fact.id); await ctx.assertFact({ entity: args.entityID, attribute: "block/text", data: { type: "text", value: args.content }, author_did: args.authorDid, });};
export const mutations = { retractAttribute, addBlock, addCanvasBlock, addLastBlock, outdentBlock, moveBlockUp, moveBlockDown, addPageLinkBlock, addPublicationNavPage, addPublicationNavLink, removePublicationNavEntry, moveBlock, moveBlocks, assertFact, retractFact, removeBlock, deleteEntity, moveChildren, increaseHeadingLevel, archiveDraft, toggleTodoState, createDraft, createEntity, addPollOption, removePollOption, addGalleryImage, removeGalleryImage, updatePublicationDraft, updateLeafletMetadata, toggleDraftContributor, createFootnote, deleteFootnote, createEditorComment, createEditorCommentReply, deleteEditorComment, deleteEditorCommentReply, editEditorComment,};