Moving an artifact to the trash from its own page takes it out of the sidebar at once: the trip back Home used to cancel the sidebar's reload, so the Artifacts layout now remembers that something changed and reloads with the navigation. The Trash page gets Empty trash, which deletes for good everything in it the viewer has full access to (`empty_folio_trash`, telling a `folio.deleted` event per artifact, as a single delete now does too) and says how many go and how many stay. And a space can be deleted, General included (`delete_space`): everything in it goes to the trash together, the space's row stays marked so the trash keeps its name and rules and General is never made again, an artifact restored from it lands in General or its owner's Private, archived spaces are listed on the Spaces page with Restore and Delete, and a workspace may have no spaces at all. The artifacts guide says all of this.
20 files+670−900/20 viewed
| 67 | 67 | | **Shared** | The highest artifact of each tree someone shared with you. | | |
| 68 | 68 | | **Possibly out of date** | Docs whose cited code changed. Shown when there are any. | | |
| 69 | 69 | | **Projects' docs** | Repositories' `docs/` folders, read-only. | | |
| 70 | − | | **Trash** | What was moved to the trash. | | |
| 70 | + | | **Trash** | What was moved to the trash, with how many. | | |
| 71 | + | ||
| 72 | + | The sidebar follows every change at once: an artifact you move to the trash | |
| 73 | + | leaves it, one you restore comes back, and the count beside **Trash** moves | |
| 74 | + | with them, without reloading the page. | |
| 71 | 75 | ||
| 72 | 76 | Drag a row onto a doc to put it inside, or onto the top edge of a row to put | |
| 73 | 77 | it before it. Only a doc can hold other artifacts: a doc with others inside | |
| ⋯ | |||
| 137 | 141 | ||
| 138 | 142 | ## Spaces | |
| 139 | 143 | ||
| 140 | − | Every workspace has a **General** space for what everyone should know. Make | |
| 141 | − | more from **+** beside **Spaces** in the sidebar, then **New space**. | |
| 144 | + | Every workspace starts with a **General** space for what everyone should | |
| 145 | + | know. Make more from **+** beside **Spaces** in the sidebar, then **New | |
| 146 | + | space**. No space has to exist: a workspace can have none at all, and | |
| 147 | + | artifacts in no space live in their owners' **Private**. | |
| 142 | 148 | ||
| 143 | 149 | | Who it's for | Who can open it | | |
| 144 | 150 | | --- | --- | | |
| ⋯ | |||
| 155 | 161 | the projects it's about. **Editors can share**, off by default, lets people | |
| 156 | 162 | who can edit what's in the space share it too, up to **Can edit**. Only | |
| 157 | 163 | people with full access give full access, change general access, or change | |
| 158 | − | someone else's full access. **Archive this space** takes it out of the | |
| 159 | − | sidebar and search; what's in it is kept. | |
| 164 | + | someone else's full access. | |
| 165 | + | ||
| 166 | + | ### Archiving and deleting a space | |
| 160 | 167 | ||
| 168 | + | Both are at the bottom of a space's **Settings**, for people with full access | |
| 169 | + | to the space; workspace owners can also delete any space they can open. | |
| 170 | + | ||
| 171 | + | | | What happens | | |
| 172 | + | | --- | --- | | |
| 173 | + | | **Archive space** | It leaves the sidebar and search. Everything in it is kept. **Browse spaces** lists it under **Archived**, where **Restore** brings it back as it was, or **Delete** deletes it. | | |
| 174 | + | | **Delete space** | Everything in it goes to the trash together, where anyone who could edit an artifact restores it as usual for 30 days. The space itself, its members and settings are gone for good. | | |
| 175 | + | ||
| 176 | + | Any space can be archived or deleted, **General** included. A workspace whose | |
| 177 | + | General space is deleted has no General space; none takes its place, and | |
| 178 | + | new spaces are made as usual. An artifact restored from a deleted space | |
| 179 | + | comes back at the top of **General** when there is one you can add to, else | |
| 180 | + | in its owner's **Private**. | |
| 181 | + | ||
| 161 | 182 | ## Docs | |
| 162 | 183 | ||
| 163 | 184 | Make a doc, give it a title, and press **Enter** to start writing. Type `/` | |
| ⋯ | |||
| 305 | 326 | ## Trash | |
| 306 | 327 | ||
| 307 | 328 | **⋯ → Move to trash** puts an artifact, and everything inside it, in the | |
| 308 | − | **Trash**. Anyone who can edit it can restore it from there, with what was | |
| 309 | − | inside. After 30 days in the trash it's deleted for good. Deleting it sooner | |
| 310 | − | needs full access. | |
| 329 | + | **Trash**; it leaves the sidebar at once. Anyone who can edit it can restore | |
| 330 | + | it from there, with what was inside. After 30 days in the trash it's deleted | |
| 331 | + | for good. Deleting it sooner needs full access: **Delete** beside one | |
| 332 | + | artifact, or **Empty trash** at the top of the page. | |
| 333 | + | ||
| 334 | + | **Empty trash** deletes for good every artifact in the trash you have full | |
| 335 | + | access to, with its history, comments and everything inside it. Artifacts | |
| 336 | + | you can only edit stay; the page says how many go and how many stay before | |
| 337 | + | you confirm. Emptying the trash can't be undone. | |
| 311 | 338 | ||
| 312 | 339 | ## Projects' docs | |
| 313 | 340 | ||
| 41 | 41 | return { ok: false, error: { code: "conflict", message } }; | |
| 42 | 42 | } | |
| 43 | 43 | ||
| 44 | + | /** | |
| 45 | + | * Whether something changed since the layout last loaded its sidebar. A | |
| 46 | + | * change (a `foliosRequest` that succeeded) sets it; the layout's | |
| 47 | + | * `shouldRevalidate` reads it, so a page that changes something and then | |
| 48 | + | * navigates (trashing an artifact from its own page, making one from the | |
| 49 | + | * sidebar) reloads the sidebar with the navigation, instead of the | |
| 50 | + | * navigation cancelling the reload the change asked for. The layout | |
| 51 | + | * clears it once its data arrives. | |
| 52 | + | */ | |
| 53 | + | let sidebarChanged = false; | |
| 54 | + | ||
| 55 | + | /** For the layout's `shouldRevalidate`: whether a change happened since its data last arrived. */ | |
| 56 | + | export function sidebarChangedSinceLoad(): boolean { | |
| 57 | + | return sidebarChanged; | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | /** The layout's data arrived: the sidebar shows every change made before it. */ | |
| 61 | + | export function sidebarLoaded(): void { | |
| 62 | + | sidebarChanged = false; | |
| 63 | + | } | |
| 64 | + | ||
| 44 | 65 | export async function foliosRequest<T>(slug: string, intent: string, body: Record<string, unknown> = {}): Promise<Result<T>> { | |
| 45 | 66 | try { | |
| 46 | 67 | const response = await fetch(`/${slug}/-/artifacts/api`, { | |
| ⋯ | |||
| 49 | 70 | body: JSON.stringify({ intent, ...body }), | |
| 50 | 71 | signal: AbortSignal.timeout(FOLIOS_TIMEOUT_MS), | |
| 51 | 72 | }); | |
| 52 | − | return (await response.json()) as Result<T>; | |
| 73 | + | const result = (await response.json()) as Result<T>; | |
| 74 | + | if (result.ok) sidebarChanged = true; | |
| 75 | + | return result; | |
| 53 | 76 | } catch (error) { | |
| 54 | 77 | return foliosFailure(error); | |
| 55 | 78 | } | |
| 116 | 116 | return folios.restore(slug, viewer, id); | |
| 117 | 117 | case "delete": | |
| 118 | 118 | return folios.delete(slug, viewer, id); | |
| 119 | + | case "empty_trash": | |
| 120 | + | return folios.emptyTrash(slug, viewer); | |
| 119 | 121 | case "favorite": | |
| 120 | 122 | return folios.favorite(slug, viewer, id, !!sent.on); | |
| 121 | 123 | case "access": | |
| ⋯ | |||
| 142 | 144 | return docs.createSpace(slug, viewer, sent.space ?? ({ name: "", kind: "workspace" } as NewDocSpace)); | |
| 143 | 145 | case "update_space": | |
| 144 | 146 | return docs.updateSpace(slug, sent.space_id ?? "", viewer, sent.space_change ?? {}); | |
| 147 | + | // Deleting one trashes its artifacts, which is the folio code's. | |
| 148 | + | case "delete_space": | |
| 149 | + | return folios.deleteSpace(slug, viewer, sent.space_id ?? ""); | |
| 145 | 150 | case "set_member": | |
| 146 | 151 | return docs.setSpaceMember(slug, sent.space_id ?? "", viewer, sent.member ?? "", sent.role ?? null); | |
| 147 | 152 | case "add_repo_space": | |
| 1 | + | import { useEffect } from "react"; | |
| 1 | 2 | import { Outlet, data, type ShouldRevalidateFunctionArgs } from "react-router"; | |
| 2 | 3 | ||
| 3 | 4 | import { type FoliosSidebar as Sidebar, shownUsername } from "@g1t/contracts"; | |
| 4 | 5 | ||
| 5 | 6 | import type { Route } from "./+types/layout"; | |
| 7 | + | import { sidebarChangedSinceLoad, sidebarLoaded } from "../../../components/folios/actions"; | |
| 6 | 8 | import type { DocMentionable } from "../../../components/folios/doc/editor"; | |
| 7 | 9 | import { workspacePeople } from "../../../lib/chat.server"; | |
| 8 | 10 | import { readCookie } from "../../../lib/mission"; | |
| ⋯ | |||
| 60 | 62 | } | |
| 61 | 63 | ||
| 62 | 64 | /** | |
| 63 | − | * The sidebar changes when something is made, renamed, moved, shared or | |
| 64 | − | * trashed (a submission, or a page asking again with `useRevalidator`); | |
| 65 | − | * not when moving between pages. | |
| 65 | + | * The sidebar changes when something is made, renamed, moved, shared, | |
| 66 | + | * trashed, restored or deleted: a submission, a page asking again with | |
| 67 | + | * `useRevalidator`, or a change followed by a navigation (which | |
| 68 | + | * `takeSidebarChange` remembers, since a navigation cancels a reload in | |
| 69 | + | * flight, so the sidebar reloads with it); not when moving between | |
| 70 | + | * pages. | |
| 66 | 71 | */ | |
| 67 | 72 | export function shouldRevalidate({ currentParams, nextParams, currentUrl, nextUrl, formMethod, defaultShouldRevalidate }: ShouldRevalidateFunctionArgs) { | |
| 73 | + | if (sidebarChangedSinceLoad()) return true; | |
| 68 | 74 | if (formMethod && formMethod !== "GET") return defaultShouldRevalidate; | |
| 69 | 75 | if (currentUrl.href === nextUrl.href) return defaultShouldRevalidate; | |
| 70 | 76 | return currentParams.owner !== nextParams.owner; | |
| 71 | 77 | } | |
| 72 | 78 | ||
| 73 | − | export default function FoliosLayout() { | |
| 79 | + | export default function FoliosLayout({ loaderData }: Route.ComponentProps) { | |
| 80 | + | // Fresh sidebar data: every change made before it is shown. | |
| 81 | + | useEffect(() => sidebarLoaded(), [loaderData]); | |
| 74 | 82 | return <Outlet />; | |
| 75 | 83 | } | |
| 1 | 1 | import { DOC_ROLES, DOC_ROLE_LABELS, DOC_ROLE_SUMMARIES, type DocRole, type DocSpace, type DocSpaceMember } from "@g1t/contracts"; | |
| 2 | − | import { Archive, Share2 } from "lucide-react"; | |
| 2 | + | import { Archive, Share2, Trash2 } from "lucide-react"; | |
| 3 | 3 | import { useState } from "react"; | |
| 4 | 4 | import { Link, data, useNavigate } from "react-router"; | |
| 5 | 5 | ||
| ⋯ | |||
| 47 | 47 | const [adding, setAdding] = useState(""); | |
| 48 | 48 | const [addRole, setAddRole] = useState<DocRole>("edit"); | |
| 49 | 49 | const [archiving, setArchiving] = useState(false); | |
| 50 | + | const [deleting, setDeleting] = useState(false); | |
| 51 | + | // How many artifacts the sidebar shows in it, when it shows the space. | |
| 52 | + | const inside = layout?.sidebar?.spaces.find((s) => s.id === space.id)?.page_count ?? null; | |
| 50 | 53 | const listed = new Set(members.map((m) => m.key)); | |
| 51 | 54 | const candidates = [ | |
| 52 | 55 | ...(layout?.mentionables ?? []).map((m) => ({ value: m.kind === "agent" ? `agent:${m.id}` : `username:${m.name}`, label: m.kind === "agent" ? `${m.display_name} (agent)` : `${m.display_name} (@${m.name})` })), | |
| ⋯ | |||
| 155 | 158 | )} | |
| 156 | 159 | </section> | |
| 157 | 160 | ||
| 158 | − | {!space.is_default && ( | |
| 159 | − | <section className="mt-12 rounded-xl border border-danger/30 p-5"> | |
| 160 | − | <h2 className="text-sm font-semibold">Archive this space</h2> | |
| 161 | − | <p className="mt-1 text-xs leading-relaxed text-muted">It leaves the sidebar and search. What's in it, its history and comments are kept.</p> | |
| 162 | − | <Button type="button" variant="destructive" onClick={() => setArchiving(true)} className="mt-3"> | |
| 163 | − | <Archive size={14} /> Archive space | |
| 164 | − | </Button> | |
| 165 | − | </section> | |
| 166 | − | )} | |
| 161 | + | <section className="mt-12 rounded-xl border border-danger/30 p-5"> | |
| 162 | + | <h2 className="text-sm font-semibold">Archive this space</h2> | |
| 163 | + | <p className="mt-1 text-xs leading-relaxed text-muted">It leaves the sidebar and search. What's in it, its history and comments are kept, and it comes back from the Spaces page.</p> | |
| 164 | + | <Button type="button" variant="destructive" onClick={() => setArchiving(true)} className="mt-3"> | |
| 165 | + | <Archive size={14} /> Archive space | |
| 166 | + | </Button> | |
| 167 | + | <h2 className="mt-6 text-sm font-semibold">Delete this space</h2> | |
| 168 | + | <p className="mt-1 text-xs leading-relaxed text-muted"> | |
| 169 | + | Everything in it goes to the trash, where anyone who could edit it can restore it for 30 days. The space itself, its members and settings are gone for good. | |
| 170 | + | {space.is_default ? " A workspace needs no General space; none takes its place." : ""} | |
| 171 | + | </p> | |
| 172 | + | <Button type="button" variant="destructive" onClick={() => setDeleting(true)} className="mt-3"> | |
| 173 | + | <Trash2 size={14} /> Delete space | |
| 174 | + | </Button> | |
| 175 | + | </section> | |
| 176 | + | <AlertDialog open={deleting} onOpenChange={setDeleting}> | |
| 177 | + | <AlertDialogContent> | |
| 178 | + | <AlertDialogHeader> | |
| 179 | + | <AlertDialogTitle>Delete {space.name}?</AlertDialogTitle> | |
| 180 | + | <AlertDialogDescription> | |
| 181 | + | {inside === null ? "Everything in it goes" : inside === 1 ? "The artifact in it goes" : `The ${inside} artifacts in it go`} to the trash, where anyone who could edit it can restore it for 30 days. The space can't be brought back. | |
| 182 | + | </AlertDialogDescription> | |
| 183 | + | </AlertDialogHeader> | |
| 184 | + | <AlertDialogFooter> | |
| 185 | + | <AlertDialogCancel>Keep it</AlertDialogCancel> | |
| 186 | + | <AlertDialogAction | |
| 187 | + | onClick={async () => { | |
| 188 | + | const done = await settings.send("delete_space", { space_id: space.id }); | |
| 189 | + | if (done.ok) navigate(`/${slug}/-/artifacts`); | |
| 190 | + | }} | |
| 191 | + | > | |
| 192 | + | Delete space | |
| 193 | + | </AlertDialogAction> | |
| 194 | + | </AlertDialogFooter> | |
| 195 | + | </AlertDialogContent> | |
| 196 | + | </AlertDialog> | |
| 167 | 197 | <AlertDialog open={archiving} onOpenChange={setArchiving}> | |
| 168 | 198 | <AlertDialogContent> | |
| 169 | 199 | <AlertDialogHeader> | |
| 170 | 200 | <AlertDialogTitle>Archive {space.name}?</AlertDialogTitle> | |
| 171 | − | <AlertDialogDescription>Nobody will see it in Artifacts until it is brought back.</AlertDialogDescription> | |
| 201 | + | <AlertDialogDescription>Nobody will see it in Artifacts until it is brought back from the Spaces page.</AlertDialogDescription> | |
| 172 | 202 | </AlertDialogHeader> | |
| 173 | 203 | <AlertDialogFooter> | |
| 174 | 204 | <AlertDialogCancel>Keep it</AlertDialogCancel> | |
| 1 | 1 | import type { DocSpace } from "@g1t/contracts"; | |
| 2 | − | import { Check, Plus } from "lucide-react"; | |
| 2 | + | import { Archive, Check, Plus, RotateCcw, Trash2 } from "lucide-react"; | |
| 3 | + | import { useState } from "react"; | |
| 3 | 4 | import { Link, data } from "react-router"; | |
| 4 | 5 | ||
| 5 | 6 | import type { Route } from "./+types/spaces"; | |
| 6 | 7 | import { useFoliosAction, useFoliosData } from "../../../components/folios/actions"; | |
| 7 | 8 | import { SpaceIcon, spaceKindLabel } from "../../../components/folios/parts"; | |
| 8 | − | import { EmptyState, ErrorText } from "../../../components/ui"; | |
| 9 | + | import { EmptyState, ErrorText, TimeAgo } from "../../../components/ui"; | |
| 10 | + | import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "../../../components/ui/alert-dialog"; | |
| 11 | + | import { Button } from "../../../components/ui/button"; | |
| 9 | 12 | import { Card } from "../../../components/ui/card"; | |
| 10 | − | import { spacePath } from "../../../lib/folios"; | |
| 13 | + | import { canDo, spacePath } from "../../../lib/folios"; | |
| 11 | 14 | import { page } from "../../../lib/meta"; | |
| 12 | 15 | import { docs } from "../../../lib/services.server"; | |
| 13 | 16 | import { requireUser, roleIn } from "../../../lib/session.server"; | |
| ⋯ | |||
| 19 | 22 | export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ spaces: DocSpace[] | null }> { | |
| 20 | 23 | const viewer = requireUser(context, request); | |
| 21 | 24 | if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 }); | |
| 22 | − | const found = await docs.sidebar(params.owner.toLowerCase(), viewer).catch(() => null); | |
| 23 | − | return { spaces: found?.ok ? found.value.spaces.filter((s) => !s.archived_at).map(({ pages: _pages, ...space }) => space) : null }; | |
| 25 | + | const found = await docs.spaces(params.owner.toLowerCase(), viewer).catch(() => null); | |
| 26 | + | return { spaces: found?.ok ? found.value : null }; | |
| 24 | 27 | } | |
| 25 | 28 | ||
| 26 | 29 | /** | |
| 27 | 30 | * Every space the viewer can open. Open spaces are joined to show in the | |
| 28 | 31 | * sidebar, so it doesn't fill up; team and members-only spaces show | |
| 29 | − | * there already. | |
| 32 | + | * there already. Archived spaces are listed apart at the bottom, where | |
| 33 | + | * they are brought back or deleted. | |
| 30 | 34 | */ | |
| 31 | 35 | export default function BrowseSpaces({ loaderData, params }: Route.ComponentProps) { | |
| 32 | 36 | const slug = params.owner.toLowerCase(); | |
| 33 | 37 | const layout = useFoliosData(); | |
| 34 | 38 | const { send, busy, error } = useFoliosAction(slug); | |
| 35 | 39 | const shown = new Set((layout?.sidebar?.spaces ?? []).map((s) => s.id)); | |
| 40 | + | const owner = layout?.role === "owner"; | |
| 41 | + | const [deleting, setDeleting] = useState<DocSpace | null>(null); | |
| 36 | 42 | const spaces = loaderData.spaces; | |
| 43 | + | const live = (spaces ?? []).filter((s) => !s.archived_at); | |
| 44 | + | const archived = (spaces ?? []).filter((s) => s.archived_at); | |
| 37 | 45 | return ( | |
| 38 | 46 | <div className="mx-auto max-w-4xl"> | |
| 39 | 47 | <div className="flex flex-wrap items-end justify-between gap-4"> | |
| ⋯ | |||
| 41 | 49 | <h1 className="text-xl font-semibold tracking-tight">Spaces</h1> | |
| 42 | 50 | <p className="mt-1 text-sm text-muted">A space holds artifacts for a team, a project or a topic. Join an open space to see it in your sidebar.</p> | |
| 43 | 51 | </div> | |
| 44 | − | <Link to={`/${slug}/-/artifacts/spaces/new`} className="inline-flex h-9 items-center gap-1.5 rounded-md border border-line px-3 text-sm text-fg/80 hover:border-line-strong hover:bg-surface hover:text-fg"> | |
| 45 | − | <Plus size={15} /> New space | |
| 46 | − | </Link> | |
| 52 | + | <Button asChild variant="outline"> | |
| 53 | + | <Link to={`/${slug}/-/artifacts/spaces/new`}> | |
| 54 | + | <Plus size={15} /> New space | |
| 55 | + | </Link> | |
| 56 | + | </Button> | |
| 47 | 57 | </div> | |
| 48 | 58 | {error && ( | |
| 49 | 59 | <div className="mt-3"> | |
| ⋯ | |||
| 53 | 63 | <div className="mt-6"> | |
| 54 | 64 | {spaces === null ? ( | |
| 55 | 65 | <EmptyState title="Artifacts didn't answer">Try again in a moment.</EmptyState> | |
| 56 | − | ) : spaces.length === 0 ? ( | |
| 57 | − | <EmptyState title="No spaces yet">Make one for a team, a project or a topic.</EmptyState> | |
| 66 | + | ) : live.length === 0 ? ( | |
| 67 | + | <EmptyState title="No spaces yet">Make one for a team, a project or a topic. Artifacts in no space live in their owners' Private.</EmptyState> | |
| 58 | 68 | ) : ( | |
| 59 | 69 | <Card asChild divided className="overflow-hidden"> | |
| 60 | 70 | <ul> | |
| 61 | − | {spaces.map((s) => { | |
| 71 | + | {live.map((s) => { | |
| 62 | 72 | const joined = shown.has(s.id); | |
| 63 | 73 | const joinable = s.kind === "workspace" && !s.is_default; | |
| 64 | 74 | return ( | |
| ⋯ | |||
| 76 | 86 | </span> | |
| 77 | 87 | </span> | |
| 78 | 88 | {joinable ? ( | |
| 79 | − | <button | |
| 80 | − | type="button" | |
| 81 | − | disabled={busy} | |
| 82 | − | onClick={() => send(joined ? "leave_space" : "join_space", { space_id: s.id })} | |
| 83 | − | className={`inline-flex h-8 shrink-0 items-center gap-1.5 rounded-md px-3 text-xs font-medium disabled:opacity-60 ${joined ? "border border-line text-muted hover:border-danger/40 hover:text-danger" : "bg-accent text-bg hover:bg-accent-hover"}`} | |
| 84 | − | > | |
| 89 | + | <Button type="button" size="sm" variant={joined ? "outline" : "accent"} disabled={busy} onClick={() => send(joined ? "leave_space" : "join_space", { space_id: s.id })} className="shrink-0"> | |
| 85 | 90 | {joined ? "Leave" : "Join"} | |
| 86 | − | </button> | |
| 91 | + | </Button> | |
| 87 | 92 | ) : ( | |
| 88 | 93 | <span className="inline-flex shrink-0 items-center gap-1 text-xs text-faint"> | |
| 89 | 94 | <Check size={13} /> In your sidebar | |
| ⋯ | |||
| 96 | 101 | </Card> | |
| 97 | 102 | )} | |
| 98 | 103 | </div> | |
| 104 | + | {archived.length > 0 && ( | |
| 105 | + | <section className="mt-10"> | |
| 106 | + | <h2 className="flex items-center gap-2 text-sm font-semibold"> | |
| 107 | + | <Archive size={14} className="text-faint" /> Archived | |
| 108 | + | </h2> | |
| 109 | + | <p className="mt-1 mb-4 text-xs leading-relaxed text-faint">Out of the sidebar and search, with everything in them kept. Bring one back, or delete it: its artifacts go to the trash.</p> | |
| 110 | + | <Card asChild divided className="overflow-hidden"> | |
| 111 | + | <ul> | |
| 112 | + | {archived.map((s) => { | |
| 113 | + | const manages = canDo(s.viewer_role, "manage") || owner; | |
| 114 | + | return ( | |
| 115 | + | <li key={s.id} className="flex items-center gap-3 px-4 py-3"> | |
| 116 | + | <span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-raised text-base opacity-70"> | |
| 117 | + | <SpaceIcon space={s} size={16} /> | |
| 118 | + | </span> | |
| 119 | + | <span className="min-w-0 grow"> | |
| 120 | + | <span className="block truncate text-sm font-medium text-fg">{s.name}</span> | |
| 121 | + | <span className="block truncate text-xs text-faint"> | |
| 122 | + | {spaceKindLabel(s.kind)} · archived {s.archived_at ? <TimeAgo at={s.archived_at} /> : null} | |
| 123 | + | </span> | |
| 124 | + | </span> | |
| 125 | + | {manages && ( | |
| 126 | + | <> | |
| 127 | + | <Button type="button" size="sm" variant="outline" disabled={busy} onClick={() => send("update_space", { space_id: s.id, space_change: { archived: false } })} className="shrink-0 text-xs font-normal"> | |
| 128 | + | <RotateCcw size={13} /> Restore | |
| 129 | + | </Button> | |
| 130 | + | <Button type="button" size="sm" variant="destructive" disabled={busy} onClick={() => setDeleting(s)} className="shrink-0 text-xs font-normal"> | |
| 131 | + | <Trash2 size={13} /> <span className="max-sm:hidden">Delete</span> | |
| 132 | + | </Button> | |
| 133 | + | </> | |
| 134 | + | )} | |
| 135 | + | </li> | |
| 136 | + | ); | |
| 137 | + | })} | |
| 138 | + | </ul> | |
| 139 | + | </Card> | |
| 140 | + | </section> | |
| 141 | + | )} | |
| 142 | + | <AlertDialog open={!!deleting} onOpenChange={(open) => !open && setDeleting(null)}> | |
| 143 | + | <AlertDialogContent> | |
| 144 | + | <AlertDialogHeader> | |
| 145 | + | <AlertDialogTitle>Delete {deleting?.name}?</AlertDialogTitle> | |
| 146 | + | <AlertDialogDescription> | |
| 147 | + | Everything in it goes to the trash, where anyone who could edit it can restore it for 30 days. The space itself, its members and settings are gone for good. | |
| 148 | + | {deleting?.is_default ? " The workspace will have no General space; none takes its place." : ""} | |
| 149 | + | </AlertDialogDescription> | |
| 150 | + | </AlertDialogHeader> | |
| 151 | + | <AlertDialogFooter> | |
| 152 | + | <AlertDialogCancel>Keep it</AlertDialogCancel> | |
| 153 | + | <AlertDialogAction | |
| 154 | + | onClick={() => { | |
| 155 | + | if (deleting) void send("delete_space", { space_id: deleting.id }); | |
| 156 | + | setDeleting(null); | |
| 157 | + | }} | |
| 158 | + | > | |
| 159 | + | Delete space | |
| 160 | + | </AlertDialogAction> | |
| 161 | + | </AlertDialogFooter> | |
| 162 | + | </AlertDialogContent> | |
| 163 | + | </AlertDialog> | |
| 99 | 164 | </div> | |
| 100 | 165 | ); | |
| 101 | 166 | } | |
| 26 | 26 | return { items: found?.ok ? found.value : null }; | |
| 27 | 27 | } | |
| 28 | 28 | ||
| 29 | − | /** Artifacts moved to the trash that the viewer can change: restore them, or delete them for good. After 30 days they go on their own. */ | |
| 29 | + | const count = (n: number) => `${n} ${n === 1 ? "artifact" : "artifacts"}`; | |
| 30 | + | ||
| 31 | + | /** | |
| 32 | + | * Artifacts moved to the trash that the viewer can change: restore them, | |
| 33 | + | * delete one for good, or empty the trash (everything they have full | |
| 34 | + | * access to goes). After 30 days they go on their own. Every change | |
| 35 | + | * reloads the sidebar's count with the page. | |
| 36 | + | */ | |
| 30 | 37 | export default function FoliosTrash({ loaderData, params }: Route.ComponentProps) { | |
| 31 | 38 | const slug = params.owner.toLowerCase(); | |
| 32 | − | const { send, error } = useFoliosAction(slug); | |
| 39 | + | const { send, busy, error } = useFoliosAction(slug); | |
| 33 | 40 | const [forever, setForever] = useState<Folio | null>(null); | |
| 41 | + | const [emptying, setEmptying] = useState(false); | |
| 34 | 42 | const items = loaderData.items; | |
| 43 | + | const deletable = (items ?? []).filter((f) => canDo(f.viewer_role, "manage")); | |
| 44 | + | const kept = (items?.length ?? 0) - deletable.length; | |
| 35 | 45 | return ( | |
| 36 | 46 | <div className="mx-auto max-w-3xl"> | |
| 37 | − | <h1 className="text-xl font-semibold tracking-tight">Trash</h1> | |
| 38 | − | <p className="mt-1 text-sm text-muted">Artifacts come back with what was inside them. After 30 days in the trash they are deleted for good.</p> | |
| 47 | + | <div className="flex flex-wrap items-end justify-between gap-4"> | |
| 48 | + | <div> | |
| 49 | + | <h1 className="text-xl font-semibold tracking-tight">Trash</h1> | |
| 50 | + | <p className="mt-1 text-sm text-muted">Artifacts come back with what was inside them. After 30 days in the trash they are deleted for good.</p> | |
| 51 | + | </div> | |
| 52 | + | {deletable.length > 0 && ( | |
| 53 | + | <Button type="button" variant="destructive" disabled={busy} onClick={() => setEmptying(true)}> | |
| 54 | + | <Trash2 size={14} /> Empty trash | |
| 55 | + | </Button> | |
| 56 | + | )} | |
| 57 | + | </div> | |
| 39 | 58 | {error && ( | |
| 40 | 59 | <div className="mt-3"> | |
| 41 | 60 | <ErrorText>{error}</ErrorText> | |
| ⋯ | |||
| 58 | 77 | {f.space?.name ?? "Private"} · trashed {f.trashed_at ? <TimeAgo at={f.trashed_at} /> : null} | |
| 59 | 78 | </span> | |
| 60 | 79 | </span> | |
| 61 | − | <Button type="button" onClick={() => send("restore", { folio_id: f.id })} variant="outline" size="sm" className="text-xs text-fg hover:bg-raised font-normal"> | |
| 80 | + | <Button type="button" disabled={busy} onClick={() => send("restore", { folio_id: f.id })} variant="outline" size="sm" className="text-xs text-fg hover:bg-raised font-normal"> | |
| 62 | 81 | <RotateCcw size={13} /> Restore | |
| 63 | 82 | </Button> | |
| 64 | 83 | {canDo(f.viewer_role, "manage") && ( | |
| 65 | − | <Button type="button" onClick={() => setForever(f)} variant="destructive" size="sm" className="text-xs font-normal"> | |
| 84 | + | <Button type="button" disabled={busy} onClick={() => setForever(f)} variant="destructive" size="sm" className="text-xs font-normal"> | |
| 66 | 85 | <Trash2 size={13} /> <span className="max-sm:hidden">Delete</span> | |
| 67 | 86 | </Button> | |
| 68 | 87 | )} | |
| ⋯ | |||
| 91 | 110 | </AlertDialogFooter> | |
| 92 | 111 | </AlertDialogContent> | |
| 93 | 112 | </AlertDialog> | |
| 113 | + | <AlertDialog open={emptying} onOpenChange={setEmptying}> | |
| 114 | + | <AlertDialogContent> | |
| 115 | + | <AlertDialogHeader> | |
| 116 | + | <AlertDialogTitle>Empty the trash?</AlertDialogTitle> | |
| 117 | + | <AlertDialogDescription> | |
| 118 | + | {count(deletable.length)} you have full access to {deletable.length === 1 ? "goes" : "go"} for good, with {deletable.length === 1 ? "its" : "their"} history, comments and everything inside. | |
| 119 | + | {kept > 0 ? ` The ${count(kept)} you can only edit ${kept === 1 ? "stays" : "stay"}.` : ""} This can't be undone. | |
| 120 | + | </AlertDialogDescription> | |
| 121 | + | </AlertDialogHeader> | |
| 122 | + | <AlertDialogFooter> | |
| 123 | + | <AlertDialogCancel>Keep everything</AlertDialogCancel> | |
| 124 | + | <AlertDialogAction | |
| 125 | + | onClick={() => { | |
| 126 | + | void send("empty_trash"); | |
| 127 | + | setEmptying(false); | |
| 128 | + | }} | |
| 129 | + | > | |
| 130 | + | Delete {count(deletable.length)} for good | |
| 131 | + | </AlertDialogAction> | |
| 132 | + | </AlertDialogFooter> | |
| 133 | + | </AlertDialogContent> | |
| 134 | + | </AlertDialog> | |
| 94 | 135 | </div> | |
| 95 | 136 | ); | |
| 96 | 137 | } | |
| 499 | 499 | ||
| 500 | 500 | /// Every method the artifacts service answers for folios, as `FOLIO_RPC_METHODS` | |
| 501 | 501 | /// in folios.ts. | |
| 502 | − | pub const FOLIO_RPC_METHODS: [&str; 48] = [ | |
| 502 | + | pub const FOLIO_RPC_METHODS: [&str; 50] = [ | |
| 503 | 503 | "folio_list", | |
| 504 | 504 | "folio_sidebar", | |
| 505 | 505 | "folio", | |
| ⋯ | |||
| 512 | 512 | "restore_folio", | |
| 513 | 513 | "delete_folio", | |
| 514 | 514 | "folio_trash", | |
| 515 | + | "empty_folio_trash", | |
| 515 | 516 | "favorite_folio", | |
| 516 | 517 | "folio_content", | |
| 517 | 518 | "edit_folio", | |
| ⋯ | |||
| 521 | 522 | "request_folio_access", | |
| 522 | 523 | "join_space", | |
| 523 | 524 | "leave_space", | |
| 525 | + | "delete_space", | |
| 524 | 526 | "search_folios", | |
| 525 | 527 | "folio_versions", | |
| 526 | 528 | "folio_version", | |
| ⋯ | |||
| 669 | 671 | /// The `folio.*` types the artifacts service publishes, with no `repoId` on | |
| 670 | 672 | /// the event: a folio may be private, so it never reaches a repository's | |
| 671 | 673 | /// timeline or webhooks. Nothing subscribes to them yet. | |
| 672 | − | pub const FOLIO_EVENTS: [&str; 6] = ["folio.created", "folio.updated", "folio.trashed", "folio.restored", "folio.shared", "folio.stale"]; | |
| 674 | + | pub const FOLIO_EVENTS: [&str; 7] = ["folio.created", "folio.updated", "folio.trashed", "folio.restored", "folio.deleted", "folio.shared", "folio.stale"]; | |
| 673 | 675 | ||
| 674 | 676 | /// What every `folio.*` event carries (`FolioEventData` in events.ts). | |
| 675 | 677 | /// Event data is camelCase, as every event on the bus. | |
| 237 | 237 | use super::*; | |
| 238 | 238 | ||
| 239 | 239 | /// Types published on the bus that hooks are not offered. | |
| 240 | − | const UNOFFERED: [&str; 28] = [ | |
| 240 | + | const UNOFFERED: [&str; 30] = [ | |
| 241 | 241 | "pull.mergecheck", | |
| 242 | 242 | "pull.mergeability", | |
| 243 | 243 | "deployment.review_requested", | |
| ⋯ | |||
| 261 | 261 | "doc.page.updated", | |
| 262 | 262 | "doc.page.archived", | |
| 263 | 263 | "doc.page.stale", | |
| 264 | + | "doc.space.deleted", | |
| 264 | 265 | // Artifacts (folios): no repository either, and a folio may be private. | |
| 265 | 266 | "folio.created", | |
| 266 | 267 | "folio.updated", | |
| 267 | 268 | "folio.trashed", | |
| 268 | 269 | "folio.restored", | |
| 270 | + | "folio.deleted", | |
| 269 | 271 | "folio.shared", | |
| 270 | 272 | "folio.stale", | |
| 271 | 273 | ]; | |
| 85 | 85 | agent_mode: DocAgentMode; | |
| 86 | 86 | /** People with edit access may share what is in it (Artifacts), as people with full access can. Off unless turned on. */ | |
| 87 | 87 | editors_can_share: boolean; | |
| 88 | − | /** The workspace's General space, made the first time Docs is opened. Can't be archived. */ | |
| 88 | + | /** | |
| 89 | + | * The workspace's General space, made once when Artifacts is first | |
| 90 | + | * opened and shown to every member without joining. It can be archived | |
| 91 | + | * and deleted like any other; once deleted it is not made again, and no | |
| 92 | + | * other space takes its place. | |
| 93 | + | */ | |
| 89 | 94 | is_default: boolean; | |
| 90 | 95 | /** Projects (repositories, `owner/name`) the space is about: Docs filters by them. */ | |
| 91 | 96 | projects: string[]; | |
| ⋯ | |||
| 589 | 594 | sidebar(workspace: string, viewer: User): Promise<Result<DocsSidebar>>; | |
| 590 | 595 | home(workspace: string, viewer: User, options?: { project?: string | null }): Promise<Result<DocsHome>>; | |
| 591 | 596 | space(workspace: string, spaceSlug: string, viewer: User): Promise<Result<{ space: DocSpace; members: DocSpaceMember[]; pages: DocPage[] }>>; | |
| 597 | + | /** Every space the viewer can open, archived ones included (never deleted ones), General first. */ | |
| 598 | + | spaces(workspace: string, viewer: User): Promise<Result<DocSpace[]>>; | |
| 592 | 599 | createSpace(workspace: string, viewer: User, input: NewDocSpace): Promise<Result<DocSpace>>; | |
| 593 | − | /** Manage role only. */ | |
| 600 | + | /** Manage role only. `archived` true or false archives a space or brings it back, the General space too. */ | |
| 594 | 601 | updateSpace(workspace: string, spaceId: string, viewer: User, change: DocSpaceChange): Promise<Result<DocSpace>>; | |
| 595 | 602 | /** Adds, changes (`role`) or removes (`role` null) a member. Manage role only; the last manager stays. */ | |
| 596 | 603 | setSpaceMember(workspace: string, spaceId: string, viewer: User, member: DocMemberKey, role: DocRole | null): Promise<Result<DocSpaceMember[]>>; | |
| ⋯ | |||
| 750 | 757 | sidebar: (workspace, viewer) => call("sidebar", { workspace, viewer }), | |
| 751 | 758 | home: (workspace, viewer, options) => call("home", { workspace, viewer, project: options?.project ?? null }), | |
| 752 | 759 | space: (workspace, spaceSlug, viewer) => call("space", { workspace, space: spaceSlug, viewer }), | |
| 760 | + | spaces: (workspace, viewer) => call("spaces", { workspace, viewer }), | |
| 753 | 761 | createSpace: (workspace, viewer, input) => call("create_space", { workspace, viewer, input }), | |
| 754 | 762 | updateSpace: (workspace, spaceId, viewer, change) => call("update_space", { workspace, space_id: spaceId, viewer, change }), | |
| 755 | 763 | setSpaceMember: (workspace, spaceId, viewer, member, role) => call("set_space_member", { workspace, space_id: spaceId, viewer, member, role }), | |
| 540 | 540 | /** A folio went to the trash (with everything under it; one event for the folio asked about). */ | |
| 541 | 541 | "folio.trashed": FolioEventData; | |
| 542 | 542 | "folio.restored": FolioEventData; | |
| 543 | + | /** | |
| 544 | + | * A folio was deleted for good from the trash (with everything under it; | |
| 545 | + | * one event for each top that went): by someone with full access, by | |
| 546 | + | * emptying the trash, or by the daily purge (`actor` null then). | |
| 547 | + | */ | |
| 548 | + | "folio.deleted": FolioEventData; | |
| 543 | 549 | /** Someone was given access: who (member keys) and the role. Never content. */ | |
| 544 | 550 | "folio.shared": FolioEventData & { principals: string[]; role: "view" | "comment" | "edit" | "manage" }; | |
| 545 | 551 | /** Code a folio cites changed. The repository is in `data` as `owner/name` only. */ | |
| 546 | 552 | "folio.stale": FolioEventData & { repo: string; commit: string; pull: number | null; paths: string[]; owners: string[] }; | |
| 553 | + | /** | |
| 554 | + | * A space was deleted (services/artifacts): everything in it went to the | |
| 555 | + | * trash (`trashed` counts the artifacts; this one event stands for them | |
| 556 | + | * all). Like `folio.*`, published with no `repoId` and never offered to | |
| 557 | + | * webhooks; `name` is null for a members-only space. | |
| 558 | + | */ | |
| 559 | + | "doc.space.deleted": { workspace: string; workspaceId: string; spaceId: string; name: string | null; kind: "workspace" | "team" | "private"; trashed: number }; | |
| 547 | 560 | }; | |
| 548 | 561 | ||
| 549 | 562 | /** |
| 630 | 630 | "restore_folio", | |
| 631 | 631 | "delete_folio", | |
| 632 | 632 | "folio_trash", | |
| 633 | + | "empty_folio_trash", | |
| 633 | 634 | "favorite_folio", | |
| 634 | 635 | // A person's (or a token's) own read and edit in the agent form. | |
| 635 | 636 | "folio_content", | |
| ⋯ | |||
| 641 | 642 | "request_folio_access", | |
| 642 | 643 | "join_space", | |
| 643 | 644 | "leave_space", | |
| 645 | + | "delete_space", | |
| 644 | 646 | // Search, history, templates and export. | |
| 645 | 647 | "search_folios", | |
| 646 | 648 | "folio_versions", | |
| ⋯ | |||
| 699 | 701 | restore(workspace: string, viewer: User, folioId: string): Promise<Result<Folio>>; | |
| 700 | 702 | /** For good: only from the trash, manage role. */ | |
| 701 | 703 | delete(workspace: string, viewer: User, folioId: string): Promise<Result<boolean>>; | |
| 704 | + | /** What is in the trash that the viewer may restore: the tops of what went there together. */ | |
| 702 | 705 | trashed(workspace: string, viewer: User): Promise<Result<Folio[]>>; | |
| 706 | + | /** | |
| 707 | + | * Deletes for good everything in the trash that the viewer has manage | |
| 708 | + | * role on (what `trashed` lists for them with full access), with | |
| 709 | + | * everything under it. What they may only edit stays. `deleted` counts | |
| 710 | + | * the tops that went. | |
| 711 | + | */ | |
| 712 | + | emptyTrash(workspace: string, viewer: User): Promise<Result<{ deleted: number }>>; | |
| 703 | 713 | favorite(workspace: string, viewer: User, folioId: string, on: boolean): Promise<Result<boolean>>; | |
| 704 | 714 | /** The folio in its agent form, for a person or their token. */ | |
| 705 | 715 | content(workspace: string, viewer: User, folioId: string): Promise<Result<FolioAgentRead>>; | |
| ⋯ | |||
| 713 | 723 | requestAccess(workspace: string, viewer: User, folioId: string, message?: string | null): Promise<Result<boolean>>; | |
| 714 | 724 | joinSpace(workspace: string, viewer: User, spaceId: string): Promise<Result<boolean>>; | |
| 715 | 725 | leaveSpace(workspace: string, viewer: User, spaceId: string): Promise<Result<boolean>>; | |
| 726 | + | /** | |
| 727 | + | * Deletes a space, the General space included: everything in it goes to | |
| 728 | + | * the trash (`trashed` counts the artifacts), where whoever could edit | |
| 729 | + | * it restores it as usual, and the space itself is gone for good. Full | |
| 730 | + | * access to the space, or a workspace owner. A workspace may end up with | |
| 731 | + | * no spaces; General is never made again once deleted. | |
| 732 | + | */ | |
| 733 | + | deleteSpace(workspace: string, viewer: User, spaceId: string): Promise<Result<{ trashed: number }>>; | |
| 716 | 734 | ||
| 717 | 735 | search(workspace: string, viewer: User, query: { q: string; kinds?: FolioKind[] | null; space_id?: string | null; project?: string | null; owner?: string | null; mode?: "words" | "hybrid" | null; limit?: number | null }): Promise<Result<FolioSearchHit[]>>; | |
| 718 | 736 | versions(workspace: string, viewer: User, folioId: string): Promise<Result<FolioVersion[]>>; | |
| ⋯ | |||
| 802 | 820 | restore: (workspace, viewer, folioId) => call("restore_folio", { workspace, viewer, folio_id: folioId }), | |
| 803 | 821 | delete: (workspace, viewer, folioId) => call("delete_folio", { workspace, viewer, folio_id: folioId }), | |
| 804 | 822 | trashed: (workspace, viewer) => call("folio_trash", { workspace, viewer }), | |
| 823 | + | emptyTrash: (workspace, viewer) => call("empty_folio_trash", { workspace, viewer }), | |
| 805 | 824 | favorite: (workspace, viewer, folioId, on) => call("favorite_folio", { workspace, viewer, folio_id: folioId, on }), | |
| 806 | 825 | content: (workspace, viewer, folioId) => call("folio_content", { workspace, viewer, folio_id: folioId }), | |
| 807 | 826 | edit: (workspace, viewer, folioId, edit) => call("edit_folio", { workspace, viewer, folio_id: folioId, edit }), | |
| ⋯ | |||
| 811 | 830 | requestAccess: (workspace, viewer, folioId, message) => call("request_folio_access", { workspace, viewer, folio_id: folioId, message: message ?? null }), | |
| 812 | 831 | joinSpace: (workspace, viewer, spaceId) => call("join_space", { workspace, viewer, space_id: spaceId }), | |
| 813 | 832 | leaveSpace: (workspace, viewer, spaceId) => call("leave_space", { workspace, viewer, space_id: spaceId }), | |
| 833 | + | deleteSpace: (workspace, viewer, spaceId) => call("delete_space", { workspace, viewer, space_id: spaceId }), | |
| 814 | 834 | search: (workspace, viewer, query) => call("search_folios", { workspace, viewer, query }), | |
| 815 | 835 | versions: (workspace, viewer, folioId) => call("folio_versions", { workspace, viewer, folio_id: folioId }), | |
| 816 | 836 | version: (workspace, viewer, folioId, versionId) => call("folio_version", { workspace, viewer, folio_id: folioId, version_id: versionId }), | |
| 1 | + | -- A space can be deleted, the General space too (a workspace may have no | |
| 2 | + | -- spaces). Its row stays, marked: the trash's rows that point at it keep | |
| 3 | + | -- their space's name and rules, so whoever could edit them still restores | |
| 4 | + | -- them for the usual 30 days; and General, once deleted, is never made | |
| 5 | + | -- again (`ensureDefault` finds the row). Deleting frees the address for a | |
| 6 | + | -- new space by renaming the row's slug to `<slug>~<id>`, which no address | |
| 7 | + | -- can be. Listings leave deleted spaces out (src/who.ts, src/index.ts). | |
| 8 | + | ALTER TABLE spaces ADD COLUMN deleted_at TEXT; | |
| 9 | + | ALTER TABLE spaces ADD COLUMN deleted_by TEXT; |
| 10 | 10 | ||
| 11 | 11 | import { actorOf } from "../events.ts"; | |
| 12 | 12 | ||
| 13 | − | export type FolioEventType = "folio.created" | "folio.updated" | "folio.trashed" | "folio.restored" | "folio.shared" | "folio.stale"; | |
| 13 | + | export type FolioEventType = "folio.created" | "folio.updated" | "folio.trashed" | "folio.restored" | "folio.deleted" | "folio.shared" | "folio.stale"; | |
| 14 | 14 | ||
| 15 | 15 | export async function publishFolioEvent<T extends FolioEventType>(events: ServiceBinding | undefined, type: T, data: EventPayloads[T] & FolioEventData, actor: string | null): Promise<void> { | |
| 16 | 16 | if (!events) return; | |
| ⋯ | |||
| 20 | 20 | console.error("folios could not publish", type, String(error)); | |
| 21 | 21 | } | |
| 22 | 22 | } | |
| 23 | + | ||
| 24 | + | /** A space was deleted: the one event that stands for everything in it going to the trash. Never throws. */ | |
| 25 | + | export async function publishSpaceDeleted(events: ServiceBinding | undefined, data: EventPayloads["doc.space.deleted"], actor: string | null): Promise<void> { | |
| 26 | + | if (!events) return; | |
| 27 | + | try { | |
| 28 | + | await eventsClient(events).publish([{ type: "doc.space.deleted", source: "docs", repoId: null, actor: actorOf(actor), data } as never]); | |
| 29 | + | } catch (error) { | |
| 30 | + | console.error("folios could not publish doc.space.deleted", String(error)); | |
| 31 | + | } | |
| 32 | + | } | |
| 25 | 25 | restore_folio: (s, a) => s.restore(a), | |
| 26 | 26 | delete_folio: (s, a) => s.delete(a), | |
| 27 | 27 | folio_trash: (s, a) => s.trashed(a), | |
| 28 | + | empty_folio_trash: (s, a) => s.emptyTrash(a), | |
| 28 | 29 | favorite_folio: (s, a) => s.favorite(a), | |
| 29 | 30 | folio_content: (s, a) => s.content(a), | |
| 30 | 31 | edit_folio: (s, a) => s.edit(a), | |
| ⋯ | |||
| 34 | 35 | request_folio_access: (s, a) => s.requestAccess(a), | |
| 35 | 36 | join_space: (s, a) => s.joinSpace(a), | |
| 36 | 37 | leave_space: (s, a) => s.leaveSpace(a), | |
| 38 | + | delete_space: (s, a) => s.deleteSpace(a), | |
| 37 | 39 | search_folios: (s, a) => s.search(a), | |
| 38 | 40 | folio_versions: (s, a) => s.versions(a), | |
| 39 | 41 | folio_version: (s, a) => s.version(a), | |
| 109 | 109 | type ReaderContext, | |
| 110 | 110 | } from "./access-store.ts"; | |
| 111 | 111 | import { agentMayFind, agentReach, audienceRule, type AgentReach, type AudienceRule } from "./agents.ts"; | |
| 112 | − | import { publishFolioEvent } from "./events.ts"; | |
| 112 | + | import { publishFolioEvent, publishSpaceDeleted } from "./events.ts"; | |
| 113 | 113 | import { MAX_DEPTH, MAX_PAGE_STATE, cleanCover, cleanIcon, cleanNote, cleanSource, cleanTarget, cleanTitle, decodeCursor, depthOf, encodeCursor, listLimit, pageState, sharedTops, slugOf, subtreeHeight, treeNodes } from "./list.ts"; | |
| 114 | 114 | import { REQUEST_RECIPIENTS, claimAccessRequest } from "./requests.ts"; | |
| 115 | 115 | import type { FolioRoom } from "./room.ts"; | |
| 116 | 116 | import { builtinFolioTemplate, builtinFolioTemplates } from "./templates.ts"; | |
| 117 | + | import { deleteSpaceRows, eraseFolios, landingFor } from "./trash.ts"; | |
| 117 | 118 | ||
| 118 | 119 | export type FoliosEnv = WhoEnv & { | |
| 119 | 120 | FOLIOS: DurableObjectNamespace<FolioRoom>; | |
| ⋯ | |||
| 453 | 454 | if (!found.ok) return found; | |
| 454 | 455 | const ctx = found.value; | |
| 455 | 456 | const joins = new Set(joined.results.map((r) => r.space_id)); | |
| 456 | − | // Joined open spaces (General always), team spaces of theirs, Members-only spaces they're in. | |
| 457 | − | const shown = ctx.spaces.filter((s) => s.role && !s.row.archived_at && (s.row.kind !== "workspace" || s.row.is_default || joins.has(s.row.id))); | |
| 457 | + | // Joined open spaces (General always), team spaces of theirs, Members-only spaces they're in; never archived or deleted ones. | |
| 458 | + | const shown = ctx.spaces.filter((s) => s.role && !s.row.archived_at && !s.row.deleted_at && (s.row.kind !== "workspace" || s.row.is_default || joins.has(s.row.id))); | |
| 458 | 459 | const keys = personKeys(ctx.person); | |
| 459 | 460 | const [spaceRows, privateRows, sharedRows, favoriteRows, repos, trashed] = await Promise.all([ | |
| 460 | 461 | shown.length | |
| ⋯ | |||
| 633 | 634 | } | |
| 634 | 635 | if (input.space_id) { | |
| 635 | 636 | const space = ctx.spaceById.get(input.space_id); | |
| 636 | − | if (!space?.role || space.row.archived_at) return fail("not_found", "No such space."); | |
| 637 | + | if (!space?.role || space.row.archived_at || space.row.deleted_at) return fail("not_found", "No such space."); | |
| 637 | 638 | if (!atLeast(space.role, "edit")) return fail("forbidden", `You can read ${space.row.name} but not add to it.`); | |
| 638 | 639 | return ok({ space_id: space.row.id, parent: null }); | |
| 639 | 640 | } | |
| ⋯ | |||
| 866 | 867 | spaceId = parent.space_id; | |
| 867 | 868 | } else if (move.space_id) { | |
| 868 | 869 | const space = ctx.spaceById.get(move.space_id); | |
| 869 | − | if (!space?.role || space.row.archived_at) return fail("not_found", "No such space."); | |
| 870 | + | if (!space?.role || space.row.archived_at || space.row.deleted_at) return fail("not_found", "No such space."); | |
| 870 | 871 | if (!atLeast(space.role, "edit")) return fail("forbidden", `You can't add to ${space.row.name}.`); | |
| 871 | 872 | spaceId = space.row.id; | |
| 872 | 873 | } else { | |
| ⋯ | |||
| 1032 | 1033 | // Its parent is gone or still in the trash: it comes back at the top of where it was. | |
| 1033 | 1034 | const detach = !!row.parent_id && (!parent || !!parent.trashed_at); | |
| 1034 | 1035 | if (detach) statements.push(this.db.prepare("UPDATE folios SET parent_id = NULL WHERE id = ?").bind(row.id)); | |
| 1036 | + | // Its space was deleted: a top comes back at the end of General (when | |
| 1037 | + | // there is one the restorer may add to), else of its owner's Private; a | |
| 1038 | + | // child whose parent lives follows its parent's place (rebuilt below). | |
| 1039 | + | const gone = !!row.space_id && !!ctx.spaceById.get(row.space_id)?.row.deleted_at; | |
| 1040 | + | const relocate = gone && (detach || !row.parent_id); | |
| 1041 | + | let spaceId = row.space_id; | |
| 1042 | + | if (relocate) { | |
| 1043 | + | spaceId = landingFor(ctx.spaces)?.row.id ?? null; | |
| 1044 | + | const last = spaceId | |
| 1045 | + | ? await this.db.prepare("SELECT MAX(position) AS p FROM folios WHERE space_id = ? AND parent_id IS NULL AND trashed_at IS NULL").bind(spaceId).first<{ p: number | null }>() | |
| 1046 | + | : await this.db.prepare("SELECT MAX(position) AS p FROM folios WHERE workspace_id = ? AND space_id IS NULL AND parent_id IS NULL AND owner = ? AND trashed_at IS NULL").bind(ctx.workspace.id, row.owner).first<{ p: number | null }>(); | |
| 1047 | + | statements.push(this.db.prepare("UPDATE folios SET space_id = ?, parent_id = NULL, position = ?, updated_at = ?, updated_by = ? WHERE id = ?").bind(spaceId, (last?.p ?? 0) + 1, now(), ctx.key, row.id)); | |
| 1048 | + | } | |
| 1035 | 1049 | await runBatches(this.db, statements); | |
| 1036 | − | if (detach) await this.afterAccessChange(ctx, row.id, below.length); | |
| 1050 | + | if (detach || gone) await this.afterAccessChange(ctx, row.id, below.length); | |
| 1037 | 1051 | else this.defer((async () => { for (const id of ids.slice(0, 2000)) await indexFolio(this.env, id); })()); | |
| 1038 | 1052 | const open = await workspaceReadable(this.db, row.id).catch(() => false); | |
| 1039 | − | this.defer(publishFolioEvent(this.env.EVENTS, "folio.restored", { workspace: ctx.workspace.slug, workspaceId: ctx.workspace.id, folioId: row.id, kind: row.kind, spaceId: row.space_id, title: open ? row.title : null }, ctx.key)); | |
| 1053 | + | this.defer(publishFolioEvent(this.env.EVENTS, "folio.restored", { workspace: ctx.workspace.slug, workspaceId: ctx.workspace.id, folioId: row.id, kind: row.kind, spaceId, title: open ? row.title : null }, ctx.key)); | |
| 1040 | 1054 | return ok(await this.folioOf(ctx, row)); | |
| 1041 | 1055 | } | |
| 1042 | 1056 | ||
| ⋯ | |||
| 1048 | 1062 | if (!opened.ok) return opened; | |
| 1049 | 1063 | const { row } = opened.value; | |
| 1050 | 1064 | if (!row.trashed_at) return fail("invalid", "Move it to the trash first."); | |
| 1051 | − | // Deepest first, so no parent goes before its children. | |
| 1052 | − | const ids = (await subtree(this.db, row)).sort((x, y) => y.path.length - x.path.length).map((r) => r.id); | |
| 1053 | − | await forgetFolios(this.env, ids); | |
| 1054 | − | await runBatches( | |
| 1055 | − | this.db, | |
| 1056 | − | ids.flatMap((id) => [this.db.prepare("DELETE FROM folios_fts WHERE folio_id = ?").bind(id), this.db.prepare("DELETE FROM folios WHERE id = ?").bind(id)]), | |
| 1057 | − | ); | |
| 1065 | + | const open = await workspaceReadable(this.db, row.id).catch(() => false); | |
| 1066 | + | const ids = await eraseFolios(this.env, await subtree(this.db, row)); | |
| 1058 | 1067 | for (const id of ids.slice(0, INLINE_ROOMS)) this.defer(this.room(id).destroy().catch(() => undefined)); | |
| 1068 | + | this.defer(publishFolioEvent(this.env.EVENTS, "folio.deleted", { workspace: ctx.workspace.slug, workspaceId: ctx.workspace.id, folioId: row.id, kind: row.kind, spaceId: row.space_id, title: open ? row.title : null }, ctx.key)); | |
| 1059 | 1069 | return ok(true); | |
| 1060 | 1070 | } | |
| 1061 | 1071 | ||
| 1062 | − | /** Trashed folios the viewer may restore: the tops of what went to the trash together. */ | |
| 1063 | − | private async trashedFor(ctx: Ctx, limit: number): Promise<FolioRow[]> { | |
| 1072 | + | /** | |
| 1073 | + | * Empties the viewer's trash: everything `trashed` lists for them that | |
| 1074 | + | * they have full access to goes for good, with everything under it, as | |
| 1075 | + | * `delete` does one at a time. What they may only edit stays. Tells one | |
| 1076 | + | * `folio.deleted` per top, with no title (many folios, each a chain to | |
| 1077 | + | * check). Answers how many tops went. | |
| 1078 | + | */ | |
| 1079 | + | async emptyTrash(a: Args): Promise<Result<{ deleted: number }>> { | |
| 1080 | + | const found = await this.ctx(a.workspace, a.viewer); | |
| 1081 | + | if (!found.ok) return found; | |
| 1082 | + | const ctx = found.value; | |
| 1083 | + | let deleted = 0; | |
| 1084 | + | for (;;) { | |
| 1085 | + | const tops = await this.trashedFor(ctx, 500, "manage"); | |
| 1086 | + | if (!tops.length) break; | |
| 1087 | + | const rows: FolioRow[] = []; | |
| 1088 | + | for (const top of tops) rows.push(...(await subtree(this.db, top))); | |
| 1089 | + | const ids = await eraseFolios(this.env, rows); | |
| 1090 | + | for (const id of ids.slice(0, INLINE_ROOMS)) this.defer(this.room(id).destroy().catch(() => undefined)); | |
| 1091 | + | for (const top of tops) { | |
| 1092 | + | this.defer(publishFolioEvent(this.env.EVENTS, "folio.deleted", { workspace: ctx.workspace.slug, workspaceId: ctx.workspace.id, folioId: top.id, kind: top.kind, spaceId: top.space_id, title: null }, ctx.key)); | |
| 1093 | + | } | |
| 1094 | + | deleted += tops.length; | |
| 1095 | + | if (tops.length < 500) break; | |
| 1096 | + | } | |
| 1097 | + | return ok({ deleted }); | |
| 1098 | + | } | |
| 1099 | + | ||
| 1100 | + | /** Trashed folios the viewer has `need` role on (edit: may restore; manage: may delete): the tops of what went to the trash together. */ | |
| 1101 | + | private async trashedFor(ctx: Ctx, limit: number, need: DocRole = "edit"): Promise<FolioRow[]> { | |
| 1064 | 1102 | const filter = this.filterOf(ctx); | |
| 1065 | 1103 | const rows = ( | |
| 1066 | 1104 | await this.db | |
| ⋯ | |||
| 1070 | 1108 | ).results; | |
| 1071 | 1109 | const { roles } = await this.roles(ctx, rows); | |
| 1072 | 1110 | const byId = new Map(rows.map((r) => [r.id, r])); | |
| 1073 | − | return rows.filter((r) => atLeast(roles.get(r.id), "edit") && !(r.parent_id && byId.get(r.parent_id)?.trashed_at === r.trashed_at)).slice(0, limit); | |
| 1111 | + | return rows.filter((r) => atLeast(roles.get(r.id), need) && !(r.parent_id && byId.get(r.parent_id)?.trashed_at === r.trashed_at)).slice(0, limit); | |
| 1074 | 1112 | } | |
| 1075 | 1113 | ||
| 1076 | 1114 | async trashed(a: Args): Promise<Result<Folio[]>> { | |
| ⋯ | |||
| 1079 | 1117 | return ok(await this.toFolios(found.value, await this.trashedFor(found.value, 200))); | |
| 1080 | 1118 | } | |
| 1081 | 1119 | ||
| 1120 | + | /** | |
| 1121 | + | * Deletes a space, the General space too: everything in it goes to the | |
| 1122 | + | * trash together (whoever could edit an artifact restores it as usual; | |
| 1123 | + | * it then lands in General, or in its owner's Private), and the space is | |
| 1124 | + | * gone for good, its row kept marked (SpaceRow.deleted_at), so General | |
| 1125 | + | * is never made again and a workspace may have no spaces. Full access to | |
| 1126 | + | * the space, or a workspace owner who can open it. | |
| 1127 | + | */ | |
| 1128 | + | async deleteSpace(a: Args & { space_id: string }): Promise<Result<{ trashed: number }>> { | |
| 1129 | + | const found = await this.ctx(a.workspace, a.viewer); | |
| 1130 | + | if (!found.ok) return found; | |
| 1131 | + | const ctx = found.value; | |
| 1132 | + | const space = ctx.spaceById.get(String(a.space_id ?? "")); | |
| 1133 | + | if (!space?.role || space.row.deleted_at) return fail("not_found", "No such space."); | |
| 1134 | + | if (!atLeast(space.role, "manage") && !ctx.owner) return fail("forbidden", "Only people with full access to a space, or a workspace owner, can delete it."); | |
| 1135 | + | const ids = await deleteSpaceRows(this.db, space.row, now(), ctx.key); | |
| 1136 | + | for (const id of ids.slice(0, INLINE_ROOMS)) this.defer(this.room(id).closeAll("Moved to the trash").catch(() => undefined)); | |
| 1137 | + | this.defer(forgetFolios(this.env, ids)); | |
| 1138 | + | this.who.forgetSpaces(); | |
| 1139 | + | this.defer( | |
| 1140 | + | publishSpaceDeleted( | |
| 1141 | + | this.env.EVENTS, | |
| 1142 | + | { workspace: ctx.workspace.slug, workspaceId: ctx.workspace.id, spaceId: space.row.id, name: space.row.kind === "private" ? null : space.row.name, kind: space.row.kind, trashed: ids.length }, | |
| 1143 | + | ctx.key, | |
| 1144 | + | ), | |
| 1145 | + | ); | |
| 1146 | + | return ok({ trashed: ids.length }); | |
| 1147 | + | } | |
| 1148 | + | ||
| 1082 | 1149 | async favorite(a: Args & { folio_id: string; on: boolean }): Promise<Result<boolean>> { | |
| 1083 | 1150 | const found = await this.ctx(a.workspace, a.viewer); | |
| 1084 | 1151 | if (!found.ok) return found; | |
| ⋯ | |||
| 1374 | 1441 | if (!found.ok) return found; | |
| 1375 | 1442 | const ctx = found.value; | |
| 1376 | 1443 | const space = ctx.spaceById.get(String(a.space_id ?? "")); | |
| 1377 | − | if (!space?.role || space.row.archived_at) return fail("not_found", "No such space."); | |
| 1444 | + | if (!space?.role || space.row.archived_at || space.row.deleted_at) return fail("not_found", "No such space."); | |
| 1378 | 1445 | if (space.row.kind !== "workspace") return fail("invalid", "Only open spaces are joined; you're in team and members-only spaces already."); | |
| 1379 | 1446 | await this.db | |
| 1380 | 1447 | .prepare("INSERT OR IGNORE INTO space_joins (space_id, user_id, position, joined_at) VALUES (?, ?, (SELECT COALESCE(MAX(position), 0) + 1024 FROM space_joins WHERE user_id = ?), ?)") | |
| ⋯ | |||
| 2559 | 2626 | ||
| 2560 | 2627 | /** | |
| 2561 | 2628 | * The daily cron: folios in the trash for over TRASH_DAYS are deleted for | |
| 2562 | − | * good, deepest first, at most 500 a run (the rest go the next day). | |
| 2629 | + | * good (`eraseFolios`, as emptying the trash does), at most 500 a run | |
| 2630 | + | * (the rest go the next day). | |
| 2563 | 2631 | */ | |
| 2564 | 2632 | export async function purgeTrash(env: FoliosEnv, at = new Date()): Promise<number> { | |
| 2565 | 2633 | const cutoff = new Date(at.getTime() - TRASH_DAYS * 24 * 60 * 60 * 1000).toISOString(); | |
| 2566 | 2634 | const rows = (await env.DB.prepare("SELECT id, path FROM folios WHERE trashed_at IS NOT NULL AND trashed_at < ? ORDER BY length(path) DESC LIMIT 500").bind(cutoff).all<{ id: string; path: string }>()).results; | |
| 2567 | 2635 | if (!rows.length) return 0; | |
| 2568 | − | // Children still alive under one being purged go to the top of where they were. | |
| 2569 | − | const ids = rows.map((r) => r.id); | |
| 2570 | − | await env.DB.prepare("UPDATE folios SET parent_id = NULL WHERE parent_id IN (SELECT value FROM json_each(?)) AND id NOT IN (SELECT value FROM json_each(?))").bind(json(ids), json(ids)).run(); | |
| 2571 | − | await forgetFolios(env, ids); | |
| 2572 | − | await runBatches( | |
| 2573 | − | env.DB, | |
| 2574 | − | ids.flatMap((id) => [env.DB.prepare("DELETE FROM folios_fts WHERE folio_id = ?").bind(id), env.DB.prepare("DELETE FROM folios WHERE id = ?").bind(id)]), | |
| 2575 | − | ); | |
| 2636 | + | const ids = await eraseFolios(env, rows); | |
| 2576 | 2637 | if (env.FOLIOS) for (const id of ids) await env.FOLIOS.get(env.FOLIOS.idFromName(id)).destroy().catch(() => undefined); | |
| 2577 | 2638 | return ids.length; | |
| 2578 | 2639 | } | |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { readFileSync, readdirSync } from "node:fs"; | |
| 3 | + | import { DatabaseSync } from "node:sqlite"; | |
| 4 | + | import { test } from "node:test"; | |
| 5 | + | ||
| 6 | + | import type { Space } from "../who.ts"; | |
| 7 | + | import { deleteSpaceRows, eraseFolios, landingFor } from "./trash.ts"; | |
| 8 | + | ||
| 9 | + | /** D1, as far as the trash uses it, over node's SQLite with the service's migrations. */ | |
| 10 | + | function fakeD1(): D1Database & { raw: DatabaseSync } { | |
| 11 | + | const db = new DatabaseSync(":memory:"); | |
| 12 | + | const dir = new URL("../../migrations/", import.meta.url); | |
| 13 | + | for (const file of readdirSync(dir).sort()) db.exec(readFileSync(new URL(file, dir), "utf8")); | |
| 14 | + | db.exec("PRAGMA foreign_keys = OFF"); | |
| 15 | + | type Statement = { sql: string; params: unknown[] }; | |
| 16 | + | const statement = (sql: string, params: unknown[] = []): Statement & Record<string, unknown> => ({ | |
| 17 | + | sql, | |
| 18 | + | params, | |
| 19 | + | bind: (...values: unknown[]) => statement(sql, values), | |
| 20 | + | first: async () => (db.prepare(sql).get(...(params as never[])) as unknown) ?? null, | |
| 21 | + | run: async () => db.prepare(sql).run(...(params as never[])), | |
| 22 | + | all: async () => ({ results: db.prepare(sql).all(...(params as never[])) }), | |
| 23 | + | }); | |
| 24 | + | return { | |
| 25 | + | raw: db, | |
| 26 | + | prepare: (sql: string) => statement(sql), | |
| 27 | + | batch: async (statements: Statement[]) => statements.map((s) => db.prepare(s.sql).run(...(s.params as never[]))), | |
| 28 | + | } as unknown as D1Database & { raw: DatabaseSync }; | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | const AT = "2026-10-11T12:00:00.000Z"; | |
| 32 | + | ||
| 33 | + | function space(db: DatabaseSync, id: string, over: Partial<{ slug: string; is_default: number; archived_at: string | null; deleted_at: string | null }> = {}) { | |
| 34 | + | db.prepare("INSERT INTO spaces (id, workspace_id, slug, name, kind, default_role, is_default, created_by, created_at, archived_at, deleted_at) VALUES (?, 'wsp_1', ?, ?, 'workspace', 'edit', ?, 'user:ana', ?, ?, ?)").run( | |
| 35 | + | id, | |
| 36 | + | over.slug ?? id, | |
| 37 | + | id, | |
| 38 | + | over.is_default ?? 0, | |
| 39 | + | AT, | |
| 40 | + | over.archived_at ?? null, | |
| 41 | + | over.deleted_at ?? null, | |
| 42 | + | ); | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | function folio(db: DatabaseSync, id: string, over: Partial<{ space_id: string | null; parent_id: string | null; path: string; trashed_at: string | null }> = {}) { | |
| 46 | + | const path = over.path ?? `/${id}/`; | |
| 47 | + | db.prepare( | |
| 48 | + | "INSERT INTO folios (id, workspace_id, kind, title, owner, space_id, parent_id, position, acl_root, path, created_by, created_at, updated_at, edited_at, trashed_at) VALUES (?, 'wsp_1', 'doc', ?, 'user:ana', ?, ?, 1, ?, ?, 'user:ana', ?, ?, ?, ?)", | |
| 49 | + | ).run(id, id, over.space_id ?? null, over.parent_id ?? null, path.split("/").filter(Boolean)[0]!, path, AT, AT, AT, over.trashed_at ?? null); | |
| 50 | + | db.prepare("INSERT INTO folios_fts (folio_id, kind, title, body) VALUES (?, 'doc', ?, '')").run(id, id); | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | const ids = (db: DatabaseSync, sql: string) => (db.prepare(sql).all() as { id: string }[]).map((r) => r.id).sort(); | |
| 54 | + | ||
| 55 | + | test("erasing folios takes their rows and search rows, deepest first, and leaves the rest", async () => { | |
| 56 | + | const d1 = fakeD1(); | |
| 57 | + | const db = d1.raw; | |
| 58 | + | folio(db, "top", { trashed_at: AT }); | |
| 59 | + | folio(db, "kid", { parent_id: "top", path: "/top/kid/", trashed_at: AT }); | |
| 60 | + | folio(db, "grandkid", { parent_id: "kid", path: "/top/kid/grandkid/", trashed_at: AT }); | |
| 61 | + | folio(db, "other"); | |
| 62 | + | const gone = await eraseFolios({ DB: d1 }, [ | |
| 63 | + | { id: "top", path: "/top/" }, | |
| 64 | + | { id: "kid", path: "/top/kid/" }, | |
| 65 | + | { id: "grandkid", path: "/top/kid/grandkid/" }, | |
| 66 | + | // Asked twice: goes once. | |
| 67 | + | { id: "kid", path: "/top/kid/" }, | |
| 68 | + | ]); | |
| 69 | + | assert.deepEqual(gone, ["grandkid", "kid", "top"]); | |
| 70 | + | assert.deepEqual(ids(db, "SELECT id FROM folios"), ["other"]); | |
| 71 | + | assert.deepEqual(ids(db, "SELECT folio_id AS id FROM folios_fts"), ["other"]); | |
| 72 | + | }); | |
| 73 | + | ||
| 74 | + | test("a child still alive under an erased folio goes to the top of where it was", async () => { | |
| 75 | + | const d1 = fakeD1(); | |
| 76 | + | const db = d1.raw; | |
| 77 | + | folio(db, "top", { trashed_at: AT }); | |
| 78 | + | folio(db, "alive", { parent_id: "top", path: "/top/alive/" }); | |
| 79 | + | await eraseFolios({ DB: d1 }, [{ id: "top", path: "/top/" }]); | |
| 80 | + | const alive = db.prepare("SELECT parent_id FROM folios WHERE id = 'alive'").get() as { parent_id: string | null }; | |
| 81 | + | assert.equal(alive.parent_id, null); | |
| 82 | + | }); | |
| 83 | + | ||
| 84 | + | test("erasing nothing does nothing", async () => { | |
| 85 | + | const d1 = fakeD1(); | |
| 86 | + | folio(d1.raw, "a"); | |
| 87 | + | assert.deepEqual(await eraseFolios({ DB: d1 }, []), []); | |
| 88 | + | assert.deepEqual(ids(d1.raw, "SELECT id FROM folios"), ["a"]); | |
| 89 | + | }); | |
| 90 | + | ||
| 91 | + | test("deleting a space trashes what is in it together, frees its address and keeps its row marked", async () => { | |
| 92 | + | const d1 = fakeD1(); | |
| 93 | + | const db = d1.raw; | |
| 94 | + | space(db, "spc_eng", { slug: "engineering" }); | |
| 95 | + | space(db, "spc_other", { slug: "other" }); | |
| 96 | + | folio(db, "a", { space_id: "spc_eng" }); | |
| 97 | + | folio(db, "b", { space_id: "spc_eng", parent_id: "a", path: "/a/b/" }); | |
| 98 | + | folio(db, "old", { space_id: "spc_eng", trashed_at: "2026-10-01T00:00:00.000Z" }); | |
| 99 | + | folio(db, "elsewhere", { space_id: "spc_other" }); | |
| 100 | + | folio(db, "private"); | |
| 101 | + | db.prepare("INSERT INTO space_joins (space_id, user_id, position, joined_at) VALUES ('spc_eng', 'bo', 1, ?), ('spc_other', 'bo', 2, ?)").run(AT, AT); | |
| 102 | + | const trashed = await deleteSpaceRows(d1, { id: "spc_eng", slug: "engineering" }, AT, "user:ana"); | |
| 103 | + | assert.deepEqual(trashed.sort(), ["a", "b"]); | |
| 104 | + | // Together: one trashed_at, so each top restores the whole of what went with it. | |
| 105 | + | assert.deepEqual(ids(db, `SELECT id FROM folios WHERE trashed_at = '${AT}' AND trashed_by = 'user:ana'`), ["a", "b"]); | |
| 106 | + | // What was already in the trash keeps its own time; other spaces and Private are untouched. | |
| 107 | + | assert.equal((db.prepare("SELECT trashed_at FROM folios WHERE id = 'old'").get() as { trashed_at: string }).trashed_at, "2026-10-01T00:00:00.000Z"); | |
| 108 | + | assert.deepEqual(ids(db, "SELECT id FROM folios WHERE trashed_at IS NULL"), ["elsewhere", "private"]); | |
| 109 | + | // The row stays, marked, its address freed; the other space's joins stay. | |
| 110 | + | const row = db.prepare("SELECT slug, deleted_at, deleted_by FROM spaces WHERE id = 'spc_eng'").get() as { slug: string; deleted_at: string; deleted_by: string }; | |
| 111 | + | assert.deepEqual({ ...row }, { slug: "engineering~spc_eng", deleted_at: AT, deleted_by: "user:ana" }); | |
| 112 | + | assert.deepEqual(ids(db, "SELECT space_id AS id FROM space_joins"), ["spc_other"]); | |
| 113 | + | // A new space takes the address. | |
| 114 | + | space(db, "spc_new", { slug: "engineering" }); | |
| 115 | + | assert.deepEqual(ids(db, "SELECT id FROM spaces WHERE slug = 'engineering'"), ["spc_new"]); | |
| 116 | + | }); | |
| 117 | + | ||
| 118 | + | test("a deleted General is still the workspace's default row, so it is never made again", async () => { | |
| 119 | + | const d1 = fakeD1(); | |
| 120 | + | const db = d1.raw; | |
| 121 | + | space(db, "spc_general", { slug: "general", is_default: 1 }); | |
| 122 | + | await deleteSpaceRows(d1, { id: "spc_general", slug: "general" }, AT, "user:ana"); | |
| 123 | + | // What ensureDefault asks (src/who.ts, src/index.ts). | |
| 124 | + | const found = db.prepare("SELECT id FROM spaces WHERE workspace_id = 'wsp_1' AND is_default = 1").get() as { id: string } | undefined; | |
| 125 | + | assert.equal(found?.id, "spc_general"); | |
| 126 | + | // And no second default can be made: the one-default index holds. | |
| 127 | + | assert.throws(() => space(db, "spc_again", { slug: "general", is_default: 1 })); | |
| 128 | + | // Deleting it again changes nothing. | |
| 129 | + | assert.deepEqual(await deleteSpaceRows(d1, { id: "spc_general", slug: "general~spc_general" }, "2026-10-12T00:00:00.000Z", "user:bo"), []); | |
| 130 | + | assert.equal((db.prepare("SELECT deleted_at FROM spaces WHERE id = 'spc_general'").get() as { deleted_at: string }).deleted_at, AT); | |
| 131 | + | }); | |
| 132 | + | ||
| 133 | + | function aSpace(over: Partial<Space["row"]> & { role?: Space["role"] }): Space { | |
| 134 | + | const { role = "edit", ...row } = over; | |
| 135 | + | return { | |
| 136 | + | row: { | |
| 137 | + | id: "spc", | |
| 138 | + | workspace_id: "wsp_1", | |
| 139 | + | slug: "spc", | |
| 140 | + | name: "Space", | |
| 141 | + | description: null, | |
| 142 | + | icon: null, | |
| 143 | + | kind: "workspace", | |
| 144 | + | team: null, | |
| 145 | + | default_role: "edit", | |
| 146 | + | agent_mode: "suggest", | |
| 147 | + | editors_can_share: 0, | |
| 148 | + | is_default: 0, | |
| 149 | + | created_by: "user:ana", | |
| 150 | + | created_at: AT, | |
| 151 | + | archived_at: null, | |
| 152 | + | deleted_at: null, | |
| 153 | + | deleted_by: null, | |
| 154 | + | ...row, | |
| 155 | + | }, | |
| 156 | + | members: [], | |
| 157 | + | projects: [], | |
| 158 | + | role, | |
| 159 | + | }; | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | test("a folio restored from a deleted space lands in General when it is live and the restorer may add to it", () => { | |
| 163 | + | const general = aSpace({ id: "spc_general", is_default: 1 }); | |
| 164 | + | assert.equal(landingFor([aSpace({ id: "spc_gone", deleted_at: AT }), general])?.row.id, "spc_general"); | |
| 165 | + | // No General, an archived or deleted one, or one they can only read: its owner's Private. | |
| 166 | + | assert.equal(landingFor([aSpace({ id: "spc_other" })]), null); | |
| 167 | + | assert.equal(landingFor([aSpace({ id: "spc_general", is_default: 1, archived_at: AT })]), null); | |
| 168 | + | assert.equal(landingFor([aSpace({ id: "spc_general", is_default: 1, deleted_at: AT })]), null); | |
| 169 | + | assert.equal(landingFor([aSpace({ id: "spc_general", is_default: 1, role: "view" })]), null); | |
| 170 | + | assert.equal(landingFor([]), null); | |
| 171 | + | }); |
| 1 | + | /** | |
| 2 | + | * The trash's two ends, over D1 alone so they are tested on SQLite | |
| 3 | + | * (trash.test.ts): what deleting folios for good does to the database | |
| 4 | + | * (`eraseFolios`: one artifact, the whole trash, the daily purge), what | |
| 5 | + | * deleting a space does to its rows (`deleteSpaceRows`), and where a folio | |
| 6 | + | * restored from the trash lands when its space is gone (`landingFor`). | |
| 7 | + | * Rooms, events and the index's vectors are the callers' (service.ts). | |
| 8 | + | */ | |
| 9 | + | import { atLeast } from "../access.ts"; | |
| 10 | + | import { forgetFolios, type IndexEnv } from "../indexer.ts"; | |
| 11 | + | import type { Space } from "../who.ts"; | |
| 12 | + | import { json, runBatches } from "./access-store.ts"; | |
| 13 | + | ||
| 14 | + | /** | |
| 15 | + | * Deletes folios for good: their passages leave the index, children still | |
| 16 | + | * alive under them go to the top of where they were, and their search rows | |
| 17 | + | * and rows go, deepest first, so no parent goes before its children. Returns | |
| 18 | + | * the ids that went, in that order. Callers destroy the rooms. | |
| 19 | + | */ | |
| 20 | + | export async function eraseFolios(env: IndexEnv, rows: readonly { id: string; path: string }[]): Promise<string[]> { | |
| 21 | + | if (!rows.length) return []; | |
| 22 | + | const unique = [...new Map(rows.map((r) => [r.id, r])).values()]; | |
| 23 | + | const ids = unique.sort((x, y) => y.path.length - x.path.length).map((r) => r.id); | |
| 24 | + | await env.DB.prepare("UPDATE folios SET parent_id = NULL WHERE parent_id IN (SELECT value FROM json_each(?)) AND id NOT IN (SELECT value FROM json_each(?))").bind(json(ids), json(ids)).run(); | |
| 25 | + | await forgetFolios(env, ids); | |
| 26 | + | await runBatches( | |
| 27 | + | env.DB, | |
| 28 | + | ids.flatMap((id) => [env.DB.prepare("DELETE FROM folios_fts WHERE folio_id = ?").bind(id), env.DB.prepare("DELETE FROM folios WHERE id = ?").bind(id)]), | |
| 29 | + | ); | |
| 30 | + | return ids; | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | /** | |
| 34 | + | * What deleting a space does to the database, at `at` by `by`: every folio | |
| 35 | + | * in it not already in the trash goes there together (so each top restores | |
| 36 | + | * as usual), the space's pages (Docs' older model) too, members' sidebars | |
| 37 | + | * drop it, and its row is marked deleted with its address freed for a new | |
| 38 | + | * space (`<slug>~<id>`, which no address can be; see migration 0007). The | |
| 39 | + | * row stays, so the trash keeps the space's name and rules and a deleted | |
| 40 | + | * General is never made again. Returns the ids of the folios trashed. | |
| 41 | + | */ | |
| 42 | + | export async function deleteSpaceRows(db: D1Database, space: { id: string; slug: string }, at: string, by: string): Promise<string[]> { | |
| 43 | + | const rows = (await db.prepare("SELECT id FROM folios WHERE space_id = ? AND trashed_at IS NULL").bind(space.id).all<{ id: string }>()).results; | |
| 44 | + | await db.batch([ | |
| 45 | + | db.prepare("UPDATE folios SET trashed_at = ?, trashed_by = ? WHERE space_id = ? AND trashed_at IS NULL").bind(at, by, space.id), | |
| 46 | + | db.prepare("UPDATE pages SET archived_at = ?, archived_by = ? WHERE space_id = ? AND archived_at IS NULL").bind(at, by, space.id), | |
| 47 | + | db.prepare("DELETE FROM space_joins WHERE space_id = ?").bind(space.id), | |
| 48 | + | db.prepare("UPDATE spaces SET deleted_at = ?, deleted_by = ?, slug = ? WHERE id = ? AND deleted_at IS NULL").bind(at, by, `${space.slug}~${space.id}`, space.id), | |
| 49 | + | ]); | |
| 50 | + | return rows.map((r) => r.id); | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | /** | |
| 54 | + | * Where a folio restored from the trash lands when the space it was in has | |
| 55 | + | * been deleted: the General space, when there is one that is live and the | |
| 56 | + | * restorer may add to; else null, its owner's Private. | |
| 57 | + | */ | |
| 58 | + | export function landingFor(spaces: readonly Space[]): Space | null { | |
| 59 | + | return spaces.find((s) => s.row.is_default && !s.row.deleted_at && !s.row.archived_at && atLeast(s.role, "edit")) ?? null; | |
| 60 | + | } |
| 152 | 152 | created_by: string; | |
| 153 | 153 | created_at: string; | |
| 154 | 154 | archived_at: string | null; | |
| 155 | + | /** Set when the space was deleted (migration 0007; src/who.ts). Docs leaves deleted spaces out everywhere. */ | |
| 156 | + | deleted_at: string | null; | |
| 157 | + | deleted_by: string | null; | |
| 155 | 158 | }; | |
| 156 | 159 | ||
| 157 | 160 | type PageRow = { | |
| ⋯ | |||
| 421 | 424 | return !!viewer.workspaces?.some((m) => m.slug === slug.toLowerCase() && m.role === "owner"); | |
| 422 | 425 | } | |
| 423 | 426 | ||
| 424 | − | /** Every space in the workspace (archived ones too when asked), with members and projects. */ | |
| 427 | + | /** Every space in the workspace (archived ones too when asked; never deleted ones), with members and projects. */ | |
| 425 | 428 | private async allSpaces(workspace: Workspace, archived = false): Promise<Omit<Space, "role">[]> { | |
| 426 | 429 | const [spaces, members, projects] = await Promise.all([ | |
| 427 | 430 | this.db | |
| 428 | − | .prepare(`SELECT * FROM spaces WHERE workspace_id = ? ${archived ? "" : "AND archived_at IS NULL"} ORDER BY is_default DESC, name COLLATE NOCASE`) | |
| 431 | + | .prepare(`SELECT * FROM spaces WHERE workspace_id = ? AND deleted_at IS NULL ${archived ? "" : "AND archived_at IS NULL"} ORDER BY is_default DESC, name COLLATE NOCASE`) | |
| 429 | 432 | .bind(workspace.id) | |
| 430 | 433 | .all<SpaceRow>(), | |
| 431 | 434 | this.db | |
| ⋯ | |||
| 456 | 459 | return spaces.map((s) => ({ ...s, role: roleOf(rulesOf({ ...s, role: null }), person) })); | |
| 457 | 460 | } | |
| 458 | 461 | ||
| 459 | − | /** Makes the workspace's General space, once. */ | |
| 462 | + | /** Makes the workspace's General space, once; a deleted General (its row kept, `deleted_at` set) is not made again. */ | |
| 460 | 463 | private async ensureDefault(workspace: Workspace, viewer: User): Promise<boolean> { | |
| 461 | 464 | const found = await this.db.prepare("SELECT id FROM spaces WHERE workspace_id = ? AND is_default = 1").bind(workspace.id).first<{ id: string }>(); | |
| 462 | 465 | if (found) return false; | |
| ⋯ | |||
| 855 | 858 | return ok({ space: this.toSpace(space, rows.length), members, pages }); | |
| 856 | 859 | } | |
| 857 | 860 | ||
| 861 | + | /** Every space the viewer can open, archived ones included (the Spaces page lists those apart), General first. */ | |
| 862 | + | async spaces(a: { workspace: string; viewer: Viewer }): Promise<Result<DocSpace[]>> { | |
| 863 | + | const found = await this.viewerWorkspace(a.workspace, a.viewer); | |
| 864 | + | if (!found.ok) return found; | |
| 865 | + | const spaces = await this.spacesFor(found.value, a.viewer!, true); | |
| 866 | + | return ok(spaces.filter((s) => s.role).map((s) => this.toSpace(s))); | |
| 867 | + | } | |
| 868 | + | ||
| 858 | 869 | async createSpace(a: { workspace: string; viewer: Viewer; input: NewDocSpace }): Promise<Result<DocSpace>> { | |
| 859 | 870 | const found = await this.viewerWorkspace(a.workspace, a.viewer); | |
| 860 | 871 | if (!found.ok) return found; | |
| ⋯ | |||
| 946 | 957 | if (c.default_role !== undefined && (c.kind ?? space.row.kind) !== "private") set("default_role", isRole(c.default_role) ? c.default_role : null); | |
| 947 | 958 | if (c.agent_mode !== undefined) set("agent_mode", c.agent_mode === "edit" ? "edit" : "suggest"); | |
| 948 | 959 | if (c.editors_can_share !== undefined) set("editors_can_share", c.editors_can_share ? 1 : 0); | |
| 949 | − | if (c.archived !== undefined) { | |
| 950 | − | if (space.row.is_default && c.archived) return fail("invalid", "The General space can't be archived."); | |
| 951 | − | set("archived_at", c.archived ? now() : null); | |
| 952 | − | } | |
| 960 | + | // Any space archives, General too: a space is not required to exist. | |
| 961 | + | if (c.archived !== undefined) set("archived_at", c.archived ? now() : null); | |
| 953 | 962 | const statements: D1PreparedStatement[] = []; | |
| 954 | 963 | if (sets.length) statements.push(this.db.prepare(`UPDATE spaces SET ${sets.join(", ")} WHERE id = ?`).bind(...values, space.row.id)); | |
| 955 | 964 | if (c.projects !== undefined) { | |
| ⋯ | |||
| 2624 | 2633 | return Response.json(await service.home(args)); | |
| 2625 | 2634 | case "space": | |
| 2626 | 2635 | return Response.json(await service.space(args)); | |
| 2636 | + | case "spaces": | |
| 2637 | + | return Response.json(await service.spaces(args)); | |
| 2627 | 2638 | case "create_space": | |
| 2628 | 2639 | return Response.json(await service.createSpace(args)); | |
| 2629 | 2640 | case "update_space": | |
| 50 | 50 | created_by: string; | |
| 51 | 51 | created_at: string; | |
| 52 | 52 | archived_at: string | null; | |
| 53 | + | /** | |
| 54 | + | * Set when the space was deleted (migration 0007). The row stays, so the | |
| 55 | + | * trash's rows that point at it keep their space's name and rules (whoever | |
| 56 | + | * could edit them still restores them) and General, once deleted, is not | |
| 57 | + | * made again; listings leave deleted spaces out. | |
| 58 | + | */ | |
| 59 | + | deleted_at: string | null; | |
| 60 | + | deleted_by: string | null; | |
| 53 | 61 | }; | |
| 54 | 62 | ||
| 55 | 63 | /** A space, with who is in it and the viewer's role (null: they can't read it). */ | |
| ⋯ | |||
| 271 | 279 | return spaces.map((s) => ({ ...s, role: roleOf(rulesOf(s), person) })); | |
| 272 | 280 | } | |
| 273 | 281 | ||
| 274 | − | /** Makes the workspace's General space, once. */ | |
| 282 | + | /** | |
| 283 | + | * Makes the workspace's General space, once. A deleted General still | |
| 284 | + | * has its row (`deleted_at` set), so it is never made again: a | |
| 285 | + | * workspace may have no spaces. | |
| 286 | + | */ | |
| 275 | 287 | async ensureDefault(workspace: Workspace, viewer: User): Promise<void> { | |
| 276 | 288 | if (defaultsMade.has(workspace.id)) return; | |
| 277 | 289 | const db = this.env.DB; | |
Comments
Sign in to comment.