feat: recycle bin + list folders first option

pull/8104/head
NGPixel 3 days ago
parent f64d643123
commit 41b1e0e9b5
No known key found for this signature in database

@ -117,6 +117,31 @@ export function mayOnPage(req: FastifyRequest, permission: string, page: RulePag
return WIKI.models.groups.checkAccess(WIKI.models.groups.actorForRequest(req), permission, page)
}
/**
* The page permissions that open the recycle bin, either one sufficient.
*
* Whoever may delete a page at a path may see what was deleted there and put it back, which is the
* undo of the same act; `manage:pages` is the broader authority over the pages at a path. Nothing
* else in the bin asks for more: viewing, downloading and restoring are one grant, since the bin is
* where a page is recovered FROM and each of the three is a step of doing so.
*/
const RECYCLE_BIN_PERMISSIONS = ['delete:pages', 'manage:pages']
/**
* Whether this requester may see and recover a deleted page, asked of where it was when it went.
*
* The deletion's path, locale and tags stand in for the page, since there is none left to ask about
* -- which is also why this is the only page check that takes a version rather than a page.
*/
function mayRecoverPage(
req: FastifyRequest,
siteId: string,
deleted: { path: string; locale: string; tags?: string[] }
): boolean {
const ref = { siteId, path: deleted.path, locale: deleted.locale, tags: deleted.tags ?? [] }
return RECYCLE_BIN_PERMISSIONS.some((permission) => mayOnPage(req, permission, ref))
}
/**
* Whether this requester may be handed a page's SOURCE.
*
@ -1375,6 +1400,197 @@ async function routes(app: FastifyInstance) {
}
)
/**
* RECYCLE BIN
*/
app.get<{ Params: { siteId: string }; Querystring: { locale: string } }>(
'/sites/:siteId/pages/deleted',
{
/*
No route-level `permissions`: the bin is gated by page rules, `delete:pages` or
`manage:pages`, and those are resolved per deleted page against where it was.
*/
schema: {
summary: 'List deleted pages',
description:
"The site's recycle bin: every page whose newest version is its deletion, most recently deleted first, in one locale.\n\nOnly the pages the caller holds `delete:pages` or `manage:pages` on, where each page was when it was deleted. Each entry names the version recording the deletion, which is what `GET /sites/:siteId/versions/:versionId` reads and `POST /sites/:siteId/pages/:pageId/restore` restores from.",
tags: ['Pages'],
params: siteIdParam,
querystring: {
type: 'object',
properties: {
locale: {
type: 'string',
description: 'The locale the page was in when it was deleted.'
}
},
required: ['locale']
},
response: {
200: {
type: 'array',
items: {
type: 'object',
properties: {
versionId: {
type: 'string',
format: 'uuid',
description: 'The version recording the deletion.'
},
pageId: { type: 'string', format: 'uuid' },
locale: { type: 'string' },
path: {
type: 'string',
description: 'Where the page was when it was deleted.'
},
title: { type: 'string' },
icon: {
type: 'string',
description: 'An Iconify reference. Empty when the page had none of its own.'
},
editor: { type: 'string' },
deletedAt: {
type: 'string',
format: 'date-time',
description: 'RFC 3339 Date Time'
},
deletedBy: {
type: 'object',
description:
'Who deleted it. Null id and empty name once that account is deleted.',
properties: {
id: { type: ['string', 'null'], format: 'uuid' },
name: { type: 'string' }
}
}
}
}
}
}
}
},
async (req) => {
const entries = await WIKI.models.pageHistory.listDeleted(req.params.siteId, req.query.locale)
return entries.filter((entry) => mayRecoverPage(req, req.params.siteId, entry))
}
)
app.post<{
Params: { siteId: string; pageId: string }
Body: { versionId: string; render?: string; path?: string; locale?: string; title?: string }
}>(
'/sites/:siteId/pages/:pageId/restore',
{
// -> Checked per page below, for the same reason as the recycle bin listing above
schema: {
summary: 'Restore a deleted page',
description:
"Bring a page back out of the recycle bin under its own id, so its whole history comes back with it. The page is restored as the deletion's snapshot held it; only its render is the caller's, produced from that snapshot's source the way an editor produces one, and sanitized against what the caller may embed.\n\nBack where it was unless `path` / `locale` say otherwise. A path another page has taken since answers 409 `pageDuplicatePath`, and a `versionId` that is no longer the page's deletion (it was restored and deleted again meanwhile) answers 409 `pageRestoreStale`.\n\nNeeds `delete:pages` or `manage:pages` where the page was when it was deleted, and at the destination when that is somewhere else.",
tags: ['Pages'],
params: pageIdParam,
body: {
type: 'object',
properties: {
versionId: {
type: 'string',
format: 'uuid',
description: 'The deletion being undone, as the recycle bin listed it.'
},
render: {
type: 'string',
description: "The HTML produced from the snapshot's source."
},
path: {
type: 'string',
description: 'Where to restore it, when not back where it was.'
},
locale: {
type: 'string',
description: 'The locale to restore it into, when not the one it was in.'
},
title: {
type: 'string',
description: 'A new title, when not the one it had.'
}
},
required: ['versionId']
},
response: {
200: {
description: 'Page restored successfully',
type: 'object',
properties: {
ok: { type: 'boolean' },
message: { type: 'string' },
page: { $ref: 'Page#' }
}
}
}
}
},
async (req, reply) => {
const actor = actorFrom(req)
if (!actor) {
return reply.unauthorized('Restoring a page requires a logged in user.')
}
const deletion = await WIKI.models.pageHistory.deletionOf(
req.params.siteId,
req.params.pageId
)
if (
!deletion ||
!mayRecoverPage(req, req.params.siteId, {
path: deletion.path,
locale: deletion.locale,
tags: deletion.meta.tags
})
) {
return reply.notFound('This page is not in the recycle bin.')
}
// -> Somewhere else is somewhere this caller has to be allowed to put it, on the same terms
const destination = {
path: req.body.path ? normalizePagePath(req.body.path) : deletion.path,
locale: req.body.locale || deletion.locale,
tags: deletion.meta.tags
}
if (
(destination.path !== deletion.path || destination.locale !== deletion.locale) &&
!mayRecoverPage(req, req.params.siteId, destination)
) {
return reply.forbidden('You are not allowed to restore a page here.')
}
const { page, versionId } = await WIKI.models.pages.restorePage(
req.params.siteId,
{
pageId: req.params.pageId,
versionId: req.body.versionId,
render: req.body.render,
path: destination.path,
locale: destination.locale,
title: req.body.title
},
actor
)
await audit(req, 'page', 'restorePage', {
pageId: page.id,
siteId: req.params.siteId,
locale: page.locale,
path: page.path,
title: page.title,
// -> Both ends: the deletion undone, and the version the page came back as
deletedVersionId: deletion.id,
versionId
})
return {
ok: true,
message: 'Page restored successfully.',
page
}
}
)
/**
* PAGE HISTORY
*/
@ -1483,7 +1699,7 @@ async function routes(app: FastifyInstance) {
schema: {
summary: 'Get a page version by its ID alone',
description:
'The same version as the history route, addressed WITHOUT naming the page — what a `/_version/<id>` link resolves. The page it came off is named in the reply, since that is what the reader is asking to be told.\n\nNeeds `read:history` and the ability to read that page, on the same terms as the history list. A version whose page has since been deleted answers 404: the permissions that would decide who may read it are page rules, and there is no longer a page to check them against.',
"The same version as the history route, addressed WITHOUT naming the page — what a `/_version/<id>` link resolves. The page it came off is named in the reply, since that is what the reader is asking to be told.\n\nNeeds `read:history` and the ability to read that page, on the same terms as the history list.\n\nA version of a page that is in the recycle bin is answered on the recycle bin's terms instead: `delete:pages` or `manage:pages` where the page was when it was deleted, since there is no page left for the rules to be matched against. `pageIsDeleted` is then true, and `pagePath` / `pageLocale` are where it was.",
tags: ['Pages'],
params: {
type: 'object',
@ -1512,6 +1728,34 @@ async function routes(app: FastifyInstance) {
if (!version) {
return reply.notFound('This version does not exist.')
}
/*
A page in the recycle bin has no row for the rules to be matched against, so the deletion
stands in for it: where the page was when it went, which is where the bin decides who may see
it. Every version of it is readable on those terms -- the bin's View opens the deletion, and
the history walked from there is the same page's.
*/
const deletion = await WIKI.models.pageHistory.deletionOf(req.params.siteId, version.pageId)
if (deletion) {
if (
!mayRecoverPage(req, req.params.siteId, {
path: deletion.path,
locale: deletion.locale,
tags: deletion.meta.tags
})
) {
return reply.notFound('This version does not exist.')
}
// -> As for a live page: a password guarded the content, and the bin is not a way around it
if (deletion.meta.password && !mayBypassPassword(req)) {
return reply.forbidden('This page is password protected.')
}
return {
...version,
pageIsDeleted: true,
pagePath: deletion.path,
pageLocale: deletion.locale
}
}
/*
The page as it stands, which is what carries the access rules — a version has none of its own.
Note the rules are matched against the page's CURRENT path, not the path the version was
@ -1536,7 +1780,7 @@ async function routes(app: FastifyInstance) {
for a page that has since moved it points at nothing. Free to include — the page is already
loaded, one line above, to decide whether this reader may be here at all.
*/
return { ...version, pagePath: page.path, pageLocale: page.locale }
return { ...version, pageIsDeleted: false, pagePath: page.path, pageLocale: page.locale }
}
)

@ -513,6 +513,11 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
description:
'The page this is a version of. Present because a version URL names only the version, so this is how the reader is told what they are looking at a snapshot OF.'
},
pageIsDeleted: {
type: 'boolean',
description:
'The page is in the recycle bin. `pagePath` and `pageLocale` are then where it was when it was deleted, and there is no live page to link to.'
},
pagePath: {
type: 'string',
description:

@ -195,6 +195,7 @@
"admin.audit.actions.requestPasswordReset": "Requested a password reset",
"admin.audit.actions.resetPassword": "Reset a password from an emailed link",
"admin.audit.actions.resetUserPassword": "Set a user's password",
"admin.audit.actions.restorePage": "Restored a deleted page",
"admin.audit.actions.retryJob": "Retried a job",
"admin.audit.actions.revokeApiKey": "Revoked an API key",
"admin.audit.actions.runScheduledTask": "Ran a scheduled task",
@ -2584,6 +2585,7 @@
"fileman.aiFileType": "Adobe Illustrator Document",
"fileman.aifFileType": "AIF Audio File",
"fileman.apkFileType": "Android Package",
"fileman.asciidocPageType": "AsciiDoc Page",
"fileman.assetDelete": "Confirm Delete Asset",
"fileman.assetDeleteConfirm": "Are you sure you want to delete {name}?",
"fileman.assetDeleteId": "Asset ID {id}",
@ -2595,7 +2597,11 @@
"fileman.aviFileType": "AVI Video File",
"fileman.binFileType": "Binary File",
"fileman.blogPageType": "Blog",
"fileman.browseUsing": "Browse using...",
"fileman.browseUsingPaths": "Browse Using Paths",
"fileman.browseUsingTitles": "Browse Using Titles",
"fileman.bz2FileType": "BZIP2 Archive",
"fileman.compactList": "Compact List",
"fileman.copyURLSuccess": "URL has been copied to the clipboard.",
"fileman.createFolderInvalidData": "One or more fields are invalid.",
"fileman.createFolderSuccess": "Folder created successfully.",
@ -2605,14 +2611,18 @@
"fileman.detailsAssetDimensions": "Dimensions",
"fileman.detailsAssetSize": "File Size",
"fileman.detailsAssetType": "Type",
"fileman.detailsDeletedAt": "Deleted",
"fileman.detailsDeletedBy": "Deleted By",
"fileman.detailsPageCreated": "Created",
"fileman.detailsPageEditor": "Editor",
"fileman.detailsPageType": "Type",
"fileman.detailsPageUpdated": "Last Updated",
"fileman.detailsPath": "Path",
"fileman.detailsTitle": "Title",
"fileman.dmgFileType": "Apple Disk Image File",
"fileman.docxFileType": "Microsoft Word Document",
"fileman.epsFileType": "EPS Image",
"fileman.excalidrawPageType": "Drawing",
"fileman.exeFileType": "Windows Executable",
"fileman.flacFileType": "FLAC Audio File",
"fileman.folderChildrenCount": "Empty folder | 1 child | {count} children",
@ -2641,6 +2651,7 @@
"fileman.jpegFileType": "JPEG Image",
"fileman.jpgFileType": "JPEG Image",
"fileman.jsonFileType": "JSON Document",
"fileman.listFoldersFirst": "List Folders First",
"fileman.m4aFileType": "M4A Audio File",
"fileman.markdownPageType": "Markdown Page",
"fileman.midFileType": "MIDI Audio File",
@ -2659,11 +2670,21 @@
"fileman.previewFitToScreen": "Fit to Screen",
"fileman.psdFileType": "Adobe Photoshop Document",
"fileman.rarFileType": "RAR Archive",
"fileman.recycleBin": "Recycle Bin",
"fileman.recycleBinEmpty": "The recycle bin is empty.",
"fileman.recycleBinLoadFailed": "Failed to load the recycle bin.",
"fileman.recycleBinLoading": "Fetching deleted pages...",
"fileman.redirectPageType": "Redirection",
"fileman.renameAssetInvalid": "Asset name is invalid.",
"fileman.renameFolderInvalidData": "One or more fields are invalid.",
"fileman.renameFolderSuccess": "Folder renamed successfully.",
"fileman.restoreFailed": "Failed to restore the page.",
"fileman.restorePageTo": "Restore Page To...",
"fileman.restorePathTaken": "Another page now exists at /{path}. Choose where to restore this page instead.",
"fileman.restoreStale": "This page changed in the recycle bin since it was listed. The list has been refreshed.",
"fileman.restoreSuccess": "Page restored successfully.",
"fileman.searchFolder": "Search folder...",
"fileman.showFolders": "Show Folders",
"fileman.svgFileType": "Scalable Vector Graphic",
"fileman.tarFileType": "TAR Archive",
"fileman.tgzFileType": "Gzipped TAR Archive",
@ -2674,6 +2695,7 @@
"fileman.unknownFileType": "{type} file",
"fileman.uploadSuccess": "File(s) uploaded successfully.",
"fileman.viewOptions": "View Options",
"fileman.visualPageType": "Visual Page",
"fileman.wavFileType": "WAV Audio File",
"fileman.wmaFileType": "WMA Audio File",
"fileman.wmvFileType": "WMV Video File",
@ -2690,6 +2712,7 @@
"history.action.created": "Created",
"history.action.deleted": "Deleted",
"history.action.moved": "Moved",
"history.action.restored": "Restored",
"history.action.updated": "Updated",
"history.branchFailed": "Failed to create a page from this version.",
"history.branchOff": "Branch off from here",
@ -2698,6 +2721,7 @@
"history.branchSuccess": "New page created from this version.",
"history.changedFields": "Changed: {fields}",
"history.current": "Current",
"history.deletedPage": "Deleted page",
"history.downloadFailed": "Failed to download this version.",
"history.downloadVersion": "Download Version",
"history.emptyPage": "Nothing",
@ -2713,6 +2737,8 @@
"history.restore.success": "Page version restored succesfully!",
"history.restoreConfirm": "Restore the page content as it was on **{date}**?",
"history.restoreConfirmHint": "The page keeps its current title, tags and settings, and the state it is in now is kept in the history.",
"history.restoreDeletedConfirm": "Restore this page to **/{path}**, as it was when it was deleted on **{date}**?",
"history.restoreDeletedConfirmHint": "Its whole history comes back with it.",
"history.restoreFailed": "Failed to restore this version.",
"history.restoreReason": "Restored the content from {date}",
"history.restoreSuccess": "Page content restored successfully.",

@ -31,6 +31,7 @@ export const AUDIT_ACTIONS = {
'convertPage',
'movePage',
'deletePage',
'restorePage',
'renderPage',
'unlockPage',
'watchPage',

@ -1,5 +1,5 @@
import { isEqual } from 'es-toolkit/predicate'
import { and, desc, eq, lt, sql } from 'drizzle-orm'
import { and, desc, eq, lt, notExists, sql } from 'drizzle-orm'
import type { SQL } from 'drizzle-orm'
import {
pageHistory as pageHistoryTable,
@ -10,11 +10,12 @@ import {
/**
* The kinds of change a history row records.
*
* `created` and `deleted` are the two ends of a page's life; `moved` is a change of path or title,
* which is worth telling apart from an ordinary edit because it is what breaks links; `updated` is
* everything else, content and metadata alike.
* `created` and `deleted` are the two ends of a page's life, and `restored` is a deleted page coming
* back out of the recycle bin under its own id; `moved` is a change of path or title, which is worth
* telling apart from an ordinary edit because it is what breaks links; `updated` is everything else,
* content and metadata alike.
*/
export const pageHistoryActions = ['created', 'updated', 'moved', 'deleted'] as const
export const pageHistoryActions = ['created', 'updated', 'moved', 'deleted', 'restored'] as const
export type PageHistoryAction = (typeof pageHistoryActions)[number]
@ -115,6 +116,36 @@ export type PageHistoryEntry = {
author: PageHistoryAuthor
}
/**
* A page in the recycle bin, as the file manager lists it: the version recording its deletion, and
* enough of what it was to draw a row and to check the page rules it stood under.
*/
export type DeletedPageEntry = {
/** The deletion's own version, which is what viewing, downloading and restoring are built from. */
versionId: string
pageId: string
locale: string
path: string
title: string
icon: string
editor: string
tags: string[]
deletedAt: Date
deletedBy: PageHistoryAuthor
}
/** The version recording a page's deletion, with the snapshot it holds. */
export type PageDeletion = {
id: string
pageId: string
locale: string
path: string
title: string
content: string
meta: Record<string, any>
versionDate: Date
}
/** A version in full, source included. */
export type PageHistoryVersion = PageHistoryEntry & {
content: string
@ -177,8 +208,8 @@ function toVersion(row: any): PageHistoryVersion {
* Page history model
*
* Records a version of a page every time one changes, and reads those versions back for the history
* view — which lists them and diffs any two against each other. Restoring one, and recovering a page
* that was deleted, are still to come.
* view — which lists them and diffs any two against each other — and for the recycle bin, which is
* nothing more than the pages whose last version is their deletion.
*/
class PageHistory {
/**
@ -342,6 +373,172 @@ class PageHistory {
return row ? { ...toVersion(row), pageId: row.pageId } : null
}
/**
* The pages in a site's recycle bin, most recently deleted first.
*
* A page is in the bin when its newest version is a deletion and no page row carries its id. Both
* halves are asked: the newest row alone would be enough today, since nothing can record against a
* page that is gone, but a page restored and deleted again has two deletions, and only the later
* one describes it.
*
* Newest per page across EVERY locale, and filtered by locale only afterwards — a page that was
* deleted in one locale, restored into another and deleted again belongs to the second, and
* filtering first would list it in both.
*
* Every such page, unpaged: the page rules that decide which of these a caller may see are resolved
* per row by the caller, so a page taken here could be a page of rows nobody may see.
*/
async listDeleted(siteId: string, locale: string): Promise<DeletedPageEntry[]> {
const latest = WIKI.db
.selectDistinctOn([pageHistoryTable.pageId], {
id: pageHistoryTable.id,
pageId: pageHistoryTable.pageId,
action: pageHistoryTable.action,
locale: pageHistoryTable.locale,
path: pageHistoryTable.path,
title: pageHistoryTable.title,
icon: sql<string | null>`${pageHistoryTable.meta}->>'icon'`.as('icon'),
editor: sql<string | null>`${pageHistoryTable.meta}->>'editor'`.as('editor'),
tags: sql<string[] | null>`${pageHistoryTable.meta}->'tags'`.as('tags'),
versionDate: pageHistoryTable.versionDate,
authorId: pageHistoryTable.authorId
})
.from(pageHistoryTable)
.where(
and(
eq(pageHistoryTable.siteId, siteId),
notExists(
WIKI.db
.select({ id: pagesTable.id })
.from(pagesTable)
.where(eq(pagesTable.id, pageHistoryTable.pageId))
)
)
)
.orderBy(
pageHistoryTable.pageId,
desc(pageHistoryTable.versionDate),
desc(pageHistoryTable.id)
)
.as('latest')
const rows = await WIKI.db
.select({
id: latest.id,
pageId: latest.pageId,
locale: latest.locale,
path: latest.path,
title: latest.title,
icon: latest.icon,
editor: latest.editor,
tags: latest.tags,
versionDate: latest.versionDate,
authorId: usersTable.id,
authorName: usersTable.name,
authorEmail: usersTable.email
})
.from(latest)
.leftJoin(usersTable, eq(usersTable.id, latest.authorId))
.where(and(eq(latest.action, 'deleted'), eq(latest.locale, locale)))
.orderBy(desc(latest.versionDate), desc(latest.id))
return rows.map((row: any) => ({
versionId: row.id,
pageId: row.pageId,
locale: row.locale,
path: row.path,
title: row.title,
icon: row.icon ?? '',
editor: row.editor || 'markdown',
tags: Array.isArray(row.tags) ? row.tags : [],
deletedAt: row.versionDate,
deletedBy: {
id: row.authorId ?? null,
name: row.authorName ?? '',
email: row.authorEmail ?? ''
}
}))
}
/**
* The deletion a page is in the recycle bin by, or null when it is not in the bin — because it was
* never deleted, because it has been restored since, or because it never existed on this site.
*
* The newest version of the page, and only when that version is a deletion and no page row carries
* the id. That row is where the page was when it went, which is what its page rules are resolved
* against, and the snapshot a restore puts back.
*/
async deletionOf(siteId: string, pageId: string): Promise<PageDeletion | null> {
const live = await WIKI.db
.select({ id: pagesTable.id })
.from(pagesTable)
.where(eq(pagesTable.id, pageId))
.limit(1)
if (live.length > 0) {
return null
}
const rows = await WIKI.db
.select({
id: pageHistoryTable.id,
pageId: pageHistoryTable.pageId,
action: pageHistoryTable.action,
locale: pageHistoryTable.locale,
path: pageHistoryTable.path,
title: pageHistoryTable.title,
content: pageHistoryTable.content,
meta: pageHistoryTable.meta,
versionDate: pageHistoryTable.versionDate
})
.from(pageHistoryTable)
.where(and(eq(pageHistoryTable.siteId, siteId), eq(pageHistoryTable.pageId, pageId)))
.orderBy(desc(pageHistoryTable.versionDate), desc(pageHistoryTable.id))
.limit(1)
const row = rows[0]
if (!row || row.action !== 'deleted') {
return null
}
return {
id: row.id,
pageId: row.pageId,
locale: row.locale,
path: row.path,
title: row.title,
content: row.content ?? '',
meta: (row.meta ?? {}) as Record<string, any>,
versionDate: row.versionDate
}
}
/**
* When a page first appeared and who made it, off its oldest version.
*
* What a restored page takes its `createdAt` and `creatorId` back from: neither is part of a
* version's snapshot (both are fixed for the page's life, see `EXCLUDED_FROM_META`), so without
* this every page brought back would claim to have been written the day it was restored, by
* whoever restored it. Null when the history has been purged past the page's creation.
*/
async originOf(
siteId: string,
pageId: string
): Promise<{ versionDate: Date; authorId: string | null } | null> {
const rows = await WIKI.db
.select({
versionDate: pageHistoryTable.versionDate,
authorId: pageHistoryTable.authorId
})
.from(pageHistoryTable)
.where(
and(
eq(pageHistoryTable.siteId, siteId),
eq(pageHistoryTable.pageId, pageId),
eq(pageHistoryTable.action, 'created')
)
)
.orderBy(pageHistoryTable.versionDate)
.limit(1)
return rows[0] ?? null
}
/**
* Drop every version older than a timeframe, across every site.
*

@ -373,6 +373,24 @@ export interface PageInput {
reasonForChange?: string
}
/** What bringing a page back out of the recycle bin takes. */
export interface PageRestoreInput {
pageId: string
/**
* The deletion being undone, as the caller read it. Asked for rather than looked up alone, because
* the render below was produced from THAT snapshot's source: a page restored and deleted again in
* the meantime has a newer one, and the two must not be mixed.
*/
versionId: string
/** The HTML the browser rendered the snapshot's source into, for where the page is going. */
render?: string
/** Where to put it, when not back where it was. */
path?: string
locale?: string
/** A title of its own, asked for alongside a new path; the one it had otherwise. */
title?: string
}
/** Who is saving, and what they are allowed to put in a page. */
/** One row of the admin dashboard's recently-edited panel. */
export interface RecentPage {
@ -2307,6 +2325,208 @@ class Pages {
return { page, versionId }
}
/**
* Bring a deleted page back, under its own id, from the version that recorded its deletion.
*
* The same id rather than a new page, which is what makes this an undo: every version the page ever
* had is keyed by that id and comes back with it, and so do the `/_version/` links pointing into
* them. The page is restored as the snapshot held it — title, content, tags, config, scripts,
* publish state and password — and put back where it was unless another path is asked for.
*
* What does not come back, and why:
* - The RENDER is not part of a version, so it is the browser's, from the snapshot's source — and
* is sanitized against what the RESTORER may embed, as any render is. Scripts the page carried in
* its own `scripts` column come back regardless, since those are what their author wrote.
* - Its place in a set of TRANSLATIONS. Leaving the set dissolved it if it left one page behind, and
* rejoining one is a statement about the other pages that is not this restore's to make.
* - An ALIAS another page has taken since.
* - Its sidebar MENU, if it overrode one: that was keyed by the page's tree entry and went with it.
* - Its ratings, which are the readers' rows and were dropped with the page.
*
* @throws 404 when the page is not in the bin, 409 when `versionId` is not its current deletion or
* when the destination path is taken
*/
async restorePage(
siteId: string,
input: PageRestoreInput,
actor: PageActor
): Promise<PageChange> {
if (!WIKI.sites[siteId]) {
throw new CustomError('pageInvalidSite', 'This site does not exist.', 404)
}
const deletion = await WIKI.models.pageHistory.deletionOf(siteId, input.pageId)
if (!deletion) {
throw new CustomError('pageNotDeleted', 'This page is not in the recycle bin.', 404)
}
if (deletion.id !== input.versionId) {
throw new CustomError(
'pageRestoreStale',
'This page has changed in the recycle bin since it was loaded. Reload it and try again.',
409
)
}
const meta = deletion.meta
const title = input.title?.trim() || deletion.title
const path = normalizePath(input.path ?? deletion.path)
const locale = input.locale || deletion.locale
const editor = meta.editor || 'markdown'
const contentType = meta.contentType || EDITOR_CONTENT_TYPES[editor] || 'text'
const duplicate = await WIKI.db
.select({ id: pagesTable.id })
.from(pagesTable)
.where(
and(eq(pagesTable.siteId, siteId), eq(pagesTable.locale, locale), eq(pagesTable.path, path))
)
.limit(1)
if (duplicate.length > 0) {
throw new CustomError('pageDuplicatePath', 'A page already exists at this path.', 409)
}
const pathParts = path.split('/')
await this.guardAgainstAssetCollision({
siteId,
locale,
parentPath: pathParts.slice(0, -1).join('/'),
fileName: pathParts.at(-1)!,
contentType
})
// -> Kept only while it is still free: an alias is unique across the site, and one taken since is
// somebody else's now
let alias: string | null = meta.alias || null
if (alias) {
const taken = await WIKI.db
.select({ id: pagesTable.id })
.from(pagesTable)
.where(and(eq(pagesTable.siteId, siteId), eq(pagesTable.alias, alias)))
.limit(1)
if (taken.length > 0) {
alias = null
}
}
/*
Who made it and who owns it, as they were -- but only while those accounts exist. Both columns
are foreign keys, and an account deleted since the page went would otherwise make the page
impossible to restore at all; the restorer stands in, as they would for a page they created.
*/
const origin = await WIKI.models.pageHistory.originOf(siteId, input.pageId)
const wantedUsers = [meta.ownerId, origin?.authorId].filter((id): id is string => Boolean(id))
const existingUsers = new Set(
wantedUsers.length > 0
? (
await WIKI.db
.select({ id: usersTable.id })
.from(usersTable)
.where(inArray(usersTable.id, wantedUsers))
).map((row) => row.id)
: []
)
const creatorId =
origin?.authorId && existingUsers.has(origin.authorId) ? origin.authorId : actor.id
const ownerId = meta.ownerId && existingUsers.has(meta.ownerId) ? meta.ownerId : actor.id
const { render, toc, text, links } = await WIKI.models.rendering.postProcess(
siteId,
input.render ?? '',
{
scripts: hasPermission(actor, 'write:scripts'),
styles: hasPermission(actor, 'write:styles')
}
)
const tags: string[] = Array.isArray(meta.tags) ? meta.tags : []
const inserted = await WIKI.db
.insert(pagesTable)
.values({
id: input.pageId,
alias,
authorId: actor.id,
creatorId,
ownerId,
config: meta.config ?? {},
content: deletion.content,
contentType,
description: meta.description ?? '',
editor,
hash: generatePathHash(path),
icon: meta.icon ?? '',
isBrowsable: meta.isBrowsable ?? true,
isSearchable: meta.isSearchable ?? true,
locale,
localeGroupId: null,
password: meta.password || null,
path,
publishState: meta.publishState ?? 'published',
publishStartDate: meta.publishStartDate ? new Date(meta.publishStartDate) : null,
publishEndDate: meta.publishEndDate ? new Date(meta.publishEndDate) : null,
relations: meta.relations ?? [],
render,
searchContent: text,
scripts: meta.scripts ?? {},
siteId,
tags,
title,
toc,
...(origin ? { createdAt: origin.versionDate } : {})
})
.returning()
const page = inserted[0]
try {
await WIKI.models.tree.addPage({
id: page.id,
parentPath: pathParts.slice(0, -1).join('/'),
fileName: pathParts.at(-1)!,
title: page.title,
locale,
siteId,
tags,
meta: this.treeMeta(page)
})
} catch (err) {
// -> As on a create: a page with no tree entry is invisible to everything that lists the wiki,
// and it is back in the bin rather than lost -- the deletion is still its newest version
await WIKI.db.delete(pagesTable).where(eq(pagesTable.id, page.id))
throw err
}
// -> Outside the rollback, as on a create: the folder is a convenience, see `createPage`
if (editor === BLOG_EDITOR) {
await WIKI.models.blogs.ensureFolder({ siteId, locale, path, title: page.title })
}
await WIKI.models.pageLinks.refreshForPage(page, links)
const versionId = await WIKI.models.pageHistory.record({
siteId,
pageId: page.id,
action: 'restored',
authorId: actor.id
})
const stored = this.toStoragePage(siteId, actor.id, page, page.content ?? '')
await WIKI.models.storage.mirrorPage(stored.ref, stored.content)
await WIKI.models.search.indexPage(page.id, locale)
// -> A create, to anything listening: a page has appeared at a path, which is what a webhook
// subscribed to new pages is there to hear
await WIKI.models.hooks.emit('page:create', {
id: page.id,
path: page.path,
locale,
siteId,
authorId: actor.id,
metadata: { title: page.title, description: page.description, editor }
})
invalidateAppShellCache()
return { page: (await this.getPage({ siteId, id: page.id })) as Page, versionId }
}
/**
* Delete every page on a site carrying a tag.
*

@ -5,7 +5,7 @@
never waits on (or depends on) the icon service. Regenerate with `npm run icons` after adding or
removing an icon; `check-icons.mjs` fails the build if this drifts.
282 icons.
283 icons.
*/
export const BUNDLED_ICONS = {
"la:angle-down": {"body":"<path fill=\"currentColor\" d=\"M4.219 10.781L2.78 12.22l12.5 12.5l.719.687l.719-.687l12.5-12.5l-1.438-1.438L16 22.562z\"/>","width":32,"height":32},
@ -121,6 +121,7 @@ export const BUNDLED_ICONS = {
"la:print": {"body":"<path fill=\"currentColor\" d=\"M9 4v7H7c-1.645 0-3 1.355-3 3v10h5v4h14v-4h5V14c0-1.645-1.355-3-3-3h-2V4zm2 2h10v5H11zm-4 7h18c.566 0 1 .434 1 1v8h-3v-4H9v4H6v-8c0-.566.434-1 1-1m1 1c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1m3 6h10v6H11z\"/>","width":32,"height":32},
"la:project-diagram": {"body":"<path fill=\"currentColor\" d=\"M3 6v8h5.635L12 19.908V27h8v-8h-6.217l-2.845-5H11v-3h10v3h8V6h-8v3H11V6zm2 2h4v4H5zm18 0h4v4h-4zm-8.582 13H18v4h-4v-3.762z\"/>","width":32,"height":32},
"la:question-circle": {"body":"<path fill=\"currentColor\" d=\"M16 4C9.383 4 4 9.383 4 16s5.383 12 12 12s12-5.383 12-12S22.617 4 16 4m0 2c5.535 0 10 4.465 10 10s-4.465 10-10 10S6 21.535 6 16S10.465 6 16 6m0 4c-2.2 0-4 1.8-4 4h2c0-1.117.883-2 2-2s2 .883 2 2a1.78 1.78 0 0 1-1.219 1.688l-.406.124A2.02 2.02 0 0 0 15 17.72V19h2v-1.281l.406-.125A3.81 3.81 0 0 0 20 14c0-2.2-1.8-4-4-4m-1 10v2h2v-2z\"/>","width":32,"height":32},
"la:recycle": {"body":"<path fill=\"currentColor\" d=\"M16 3.969a3.27 3.27 0 0 0-2.75 1.5l-2.813 4.625l1.72 1.031l2.812-4.594c.539-.804 1.562-.824 2.094 0l3.125 5l-1.5.875L23.313 15V9.687l-1.438.844L18.75 5.47A3.27 3.27 0 0 0 16 3.97zm-5.594 8.125l-4.5 2.594l1.25.75l-2.562 4.218l-.032-.031c-.011.02.012.043 0 .063C3.09 21.918 4.79 25 7.5 25H13v-2H7.5c-1.266 0-1.945-1.25-1.281-2.219l.031-.031v-.031l2.625-4.25l1.531.937zm14.656 3.562l-1.718 1.063l2.5 4c.64 1.09-.086 2.281-1.25 2.281H19v-2l-4.906 3L19 27v-2h5.594c2.636 0 4.328-3 2.968-5.313v-.03z\"/>","width":32,"height":32},
"la:redo": {"body":"<path fill=\"currentColor\" d=\"M19.219 5.281L17.78 6.72L24.063 13H11c-3.844 0-7 3.156-7 7v7h2v-7c0-2.754 2.246-5 5-5h13.063l-6.282 6.281l1.438 1.438l8-8l.687-.719l-.687-.719z\"/>","width":32,"height":32},
"la:redo-alt": {"body":"<path fill=\"currentColor\" d=\"M16 3C8.832 3 3 8.832 3 16s5.832 13 13 13s13-5.832 13-13h-2c0 6.086-4.914 11-11 11S5 22.086 5 16S9.914 5 16 5c3.875 0 7.262 1.984 9.219 5H20v2h8V4h-2v3.719C23.617 4.844 20.02 3 16 3\"/>","width":32,"height":32},
"la:reply": {"body":"<path fill=\"currentColor\" d=\"m12.281 5.281l-8 8l-.687.719l.687.719l8 8l1.438-1.438L7.438 15H21c2.773 0 5 2.227 5 5s-2.227 5-5 5v2c3.855 0 7-3.145 7-7s-3.145-7-7-7H7.437l6.282-6.281z\"/>","width":32,"height":32},

@ -101,29 +101,68 @@
Narrower while it overlays, so there is a comfortable width of scrim left to tap on.
-->
<w-drawer class="fileman-left" v-model="treeDrawerOpen" :width="isTreeOverlay ? 300 : 350">
<w-scroll-area :thumb-style="thumbStyle" :bar-style="barStyle" style="height: 100%">
<div class="flex h-full flex-col">
<w-scroll-area
class="min-h-0 flex-1"
:thumb-style="thumbStyle"
:bar-style="barStyle"
style="height: 100%">
<!--
-> No side padding: the tree's rows run the full width of the drawer, so a hovered or
selected row reads as a band across it rather than a floating pill. `pt-2` is the gap
above the root entry that the padding used to imply.
-->
<!--
-> The tree is told the bin is selected by being handed an id no folder has, which leaves
every row -- the root included -- unhighlighted while the bin is open
-->
<div class="pt-2 pb-2">
<tree
ref="treeComp"
:nodes="state.treeNodes"
:roots="state.treeRoots"
v-model:selected="state.currentFolderId"
:selected="state.isRecycleBin ? RECYCLE_BIN_SELECTION : state.currentFolderId"
@update:selected="openFolder"
@lazy-load="treeLazyLoad"
:use-lazy-load="true"
@context-action="treeContextAction"
:display-mode="state.displayMode" />
</div>
</w-scroll-area>
<!--
Pinned under the tree rather than a node in it: it is not a place in the wiki, and it has to
stay reachable however far the tree has been scrolled or expanded. Signed in only -- the
permissions that open it cannot be granted to the guests group, so a visitor would only
ever find it empty.
-->
<button
v-if="userStore.authenticated"
type="button"
class="fileman-bin w-unstyled"
:class="{ 'is-active': state.isRecycleBin }"
:aria-pressed="state.isRecycleBin"
@click="openRecycleBin">
<w-icon name="la:recycle" size="sm" />
<span class="fileman-bin-label">{{ t('fileman.recycleBin') }}</span>
<w-icon v-if="state.isRecycleBin" name="la:angle-right" />
</button>
</div>
</w-drawer>
<w-drawer class="fileman-right" :model-value="detailsPaneShown" :width="350" side="right">
<w-scroll-area :thumb-style="thumbStyle" :bar-style="barStyle" style="height: 100%">
<div class="p-4">
<template v-if="currentFileDetails">
<template v-if="state.isRecycleBin">
<template v-if="selectedDeletedPage">
<img
class="mb-4 w-full rounded object-cover"
src="/_assets/illustrations/fileman-page.svg" />
<div class="fileman-details-row" v-for="item of deletedPageDetails" :key="item.label">
<label>{{ item.label }}</label>
<span>{{ item.value }}</span>
</div>
</template>
</template>
<template v-else-if="currentFileDetails">
<!--
A button around the thumbnail, and only for an image: it opens the full view, and the
illustration standing in for a page has nothing behind it to open.
@ -213,6 +252,10 @@
t(`common.sidebar.browse`)
}}</w-tooltip>
</w-btn>
<div v-if="state.isRecycleBin" class="fileman-toolbar-title">
<w-icon name="la:recycle" class="mr-2" size="sm" />
{{ t('fileman.recycleBin') }}
</div>
<w-space />
<w-btn
class="mr-2"
@ -240,7 +283,7 @@
<w-item-section side>
<w-icon name="la:list" color="grey" size="xs" />
</w-item-section>
<w-item-section class="pr-2">Browse using...</w-item-section>
<w-item-section class="pr-2">{{ t(`fileman.browseUsing`) }}</w-item-section>
<w-item-section side>
<w-icon name="la:angle-right" color="grey" size="xs" />
</w-item-section>
@ -255,7 +298,9 @@
:color="state.displayMode === `path` ? `positive` : `grey`"
size="xs" />
</w-item-section>
<w-item-section class="pr-2">Browse Using Paths</w-item-section>
<w-item-section class="pr-2">{{
t(`fileman.browseUsingPaths`)
}}</w-item-section>
</w-item>
<w-item clickable @click="state.displayMode = `title`">
<w-item-section side>
@ -266,7 +311,9 @@
:color="state.displayMode === `title` ? `positive` : `grey`"
size="xs" />
</w-item-section>
<w-item-section class="pr-2">Browse Using Titles</w-item-section>
<w-item-section class="pr-2">{{
t(`fileman.browseUsingTitles`)
}}</w-item-section>
</w-item>
</w-list>
</w-menu>
@ -278,7 +325,7 @@
:color="state.isCompact ? `positive` : `grey`"
size="xs" />
</w-item-section>
<w-item-section class="pr-2">Compact List</w-item-section>
<w-item-section class="pr-2">{{ t(`fileman.compactList`) }}</w-item-section>
</w-item>
<w-item clickable @click="state.shouldShowFolders = !state.shouldShowFolders">
<w-item-section side>
@ -287,7 +334,21 @@
:color="state.shouldShowFolders ? `positive` : `grey`"
size="xs" />
</w-item-section>
<w-item-section class="pr-2">Show Folders</w-item-section>
<w-item-section class="pr-2">{{ t(`fileman.showFolders`) }}</w-item-section>
</w-item>
<w-item
clickable
:disabled="!state.shouldShowFolders"
@click="state.shouldListFoldersFirst = !state.shouldListFoldersFirst">
<w-item-section side>
<w-icon
:name="state.shouldListFoldersFirst ? `la:check-square` : `la:stop`"
:color="state.shouldListFoldersFirst ? `positive` : `grey`"
size="xs" />
</w-item-section>
<w-item-section class="pr-2">{{
t(`fileman.listFoldersFirst`)
}}</w-item-section>
</w-item>
</w-list>
</w-card>
@ -301,11 +362,13 @@
color="grey"
:aria-label="t(`common.actions.refresh`)"
icon="la:redo-alt"
@click="reloadFolder(state.currentFolderId)">
@click="refreshListing">
<w-tooltip anchor="bottom middle" self="top middle">{{
t(`common.actions.refresh`)
}}</w-tooltip>
</w-btn>
<!-- -> New, Upload and Insert all act on the folder being listed, and the bin is not one -->
<template v-if="!state.isRecycleBin">
<w-separator class="mr-2" inset vertical />
<w-btn
class="mr-2"
@ -351,10 +414,81 @@
icon="la:plus-circle"
@click="insertItem()" />
</template>
</template>
</w-toolbar>
<div class="flex flex-wrap" style="flex: 1 1 100%">
<div class="min-w-0 flex-1">
<w-scroll-area :thumb-style="thumbStyle" :bar-style="barStyle" style="height: 100%">
<!-- RECYCLE BIN ---------------------------------------------- -->
<template v-if="state.isRecycleBin">
<div class="fileman-loadinglist" v-if="state.binLoading">
<w-spinner class="mr-2" color="primary" size="64px" />
<span class="text-primary">{{ t('fileman.recycleBinLoading') }}</span>
</div>
<div class="fileman-emptylist" v-else-if="deletedPages.length < 1">
<img src="/_assets/icons/carbon-copy-empty-box.svg" />
<span>{{ t('fileman.recycleBinEmpty') }}</span>
</div>
<w-list class="fileman-filelist" v-else :class="state.isCompact && `is-compact`">
<w-item
v-for="item of deletedPages"
:key="item.versionId"
clickable
active-class="active"
:active="item.versionId === state.binSelectedId"
@click="state.binSelectedId = item.versionId"
@dblclick="viewDeletedPage(item)">
<w-item-section class="fileman-filelist-icon" avatar>
<!-- -> The page's own icon where it chose one, and its kind's otherwise. No
colour of its own: it inherits the row's ink, which is what keeps it
visible on a selected row's primary fill -->
<w-icon
:name="item.icon || deletedPageTypeIcon(item)"
:size="state.isCompact ? `md` : `xl`" />
</w-item-section>
<w-item-section class="fileman-filelist-label">
<w-item-label>{{ item.title }}</w-item-label>
<w-item-label caption v-if="!state.isCompact">/{{ item.path }}</w-item-label>
</w-item-section>
<w-item-section class="fileman-filelist-side" side>
<div class="text-caption">{{ formatDateTime(item.deletedAt) }}</div>
</w-item-section>
<!-- RIGHT-CLICK MENU -->
<w-menu
class="translucent-menu"
touch-position
context-menu
auto-close
transition-show="jump-down"
transition-hide="jump-up">
<w-card class="p-2">
<w-list dense style="min-width: 150px">
<w-item clickable @click="viewDeletedPage(item)">
<w-item-section side>
<w-icon name="la:eye" color="primary" />
</w-item-section>
<w-item-section>{{ t(`common.actions.view`) }}</w-item-section>
</w-item>
<w-item clickable @click="restoreDeletedItem(item)">
<w-item-section side>
<w-icon name="la:undo" color="deep-orange-9" />
</w-item-section>
<w-item-section>{{ t(`history.restore`) }}...</w-item-section>
</w-item>
<w-item clickable @click="downloadDeletedPage(item)">
<w-item-section side>
<w-icon name="la:download" color="primary" />
</w-item-section>
<w-item-section>{{ t(`common.actions.download`) }}</w-item-section>
</w-item>
</w-list>
</w-card>
</w-menu>
</w-item>
</w-list>
</template>
<!-- FOLDER LISTING ------------------------------------------- -->
<template v-else>
<div class="fileman-loadinglist" v-if="state.fileListLoading">
<w-spinner class="mr-2" color="primary" size="64px" />
<span class="text-primary">Fetching folder contents...</span>
@ -380,7 +514,9 @@
</w-item-section>
<w-item-section class="fileman-filelist-label">
<w-item-label>{{ usePathTitle ? item.fileName : item.title }}</w-item-label>
<w-item-label caption v-if="!state.isCompact">{{ item.caption }}</w-item-label>
<w-item-label caption v-if="!state.isCompact">{{
item.caption
}}</w-item-label>
</w-item-section>
<w-item-section class="fileman-filelist-side" side v-if="item.side">
<div class="text-caption">{{ item.side }}</div>
@ -433,7 +569,9 @@
<!-- -> Behind the experimental flag: neither has a handler behind it yet,
so on an ordinary instance they are two rows that do nothing -->
<template
v-if="flagsStore.experimental && item.type === `asset` && item.imageEdit">
v-if="
flagsStore.experimental && item.type === `asset` && item.imageEdit
">
<w-item clickable>
<w-item-section side>
<w-icon name="la:edit" color="orange" />
@ -447,13 +585,19 @@
<w-item-section>Resize Image...</w-item-section>
</w-item>
</template>
<w-item clickable v-if="item.type !== `folder`" @click="copyItemURL(item)">
<w-item
clickable
v-if="item.type !== `folder`"
@click="copyItemURL(item)">
<w-item-section side>
<w-icon name="la:clipboard" color="primary" />
</w-item-section>
<w-item-section>{{ t(`common.actions.copyURL`) }}</w-item-section>
</w-item>
<w-item clickable v-if="item.type === `asset`" @click="downloadItem(item)">
<w-item
clickable
v-if="item.type === `asset`"
@click="downloadItem(item)">
<w-item-section side>
<w-icon name="la:download" color="primary" />
</w-item-section>
@ -473,7 +617,10 @@
upload has none to copy -- a second name over the same bytes is all it could
mean, which is not what anyone is asking a wiki for.
-->
<w-item clickable v-if="item.type !== `asset`" @click="duplicateItem(item)">
<w-item
clickable
v-if="item.type !== `asset`"
@click="duplicateItem(item)">
<w-item-section side>
<w-icon name="la:copy" color="teal" />
</w-item-section>
@ -484,7 +631,10 @@
the page view's own action rail opens, so offering them as two actions
would be offering two ways into one form.
-->
<w-item clickable v-if="item.type === `page`" @click="renameMovePage(item)">
<w-item
clickable
v-if="item.type === `page`"
@click="renameMovePage(item)">
<w-item-section side>
<w-icon name="la:share" color="teal" />
</w-item-section>
@ -533,6 +683,7 @@
</w-menu>
</w-item>
</w-list>
</template>
</w-scroll-area>
</div>
</div>
@ -540,7 +691,9 @@
</w-page-container>
<w-footer>
<w-bar class="fileman-path">
<small class="text-caption text-grey-7">{{ folderPath }}</small>
<small class="text-caption text-grey-7">{{
state.isRecycleBin ? t('fileman.recycleBin') : folderPath
}}</small>
</w-bar>
</w-footer>
<input type="file" ref="fileIpt" multiple @change="uploadNewFiles" style="display: none" />
@ -567,11 +720,13 @@ import { loading } from '@/composables/loading'
import { notify } from '@/composables/notify'
import { useMinWidth, useScreen } from '@/composables/screen'
import { useDark } from '@/composables/dark'
import { useDeletedPages } from '@/composables/deletedPages'
import { useCommonStore } from '@/stores/common'
import { useFlagsStore } from '@/stores/flags'
import { usePageStore } from '@/stores/page'
import { useSiteStore } from '@/stores/site'
import { useUserStore } from '@/stores/user'
import { filesize } from 'filesize'
import Fuse from 'fuse.js/basic'
@ -581,6 +736,7 @@ import { apiErrorMessage } from '@/helpers/apiError'
import { assetUrl } from '@/helpers/assets'
import fileTypes from '@/helpers/fileTypes'
import { folderIconStyle } from '@/helpers/folderColors'
import { saveVersionSource } from '@/helpers/pageVersions'
import FolderCreateDialog from '@/components/FolderCreateDialog.vue'
import FolderDeleteDialog from '@/components/FolderDeleteDialog.vue'
import FolderRenameDialog from '@/components/FolderRenameDialog.vue'
@ -590,6 +746,7 @@ import LocaleSelectorMenu from '@/components/LocaleSelectorMenu.vue'
const dark = useDark()
const screen = useScreen()
const { restoreDeletedPage } = useDeletedPages()
// STORES
@ -597,6 +754,7 @@ const commonStore = useCommonStore()
const flagsStore = useFlagsStore()
const pageStore = usePageStore()
const siteStore = useSiteStore()
const userStore = useUserStore()
// ROUTER
@ -615,6 +773,13 @@ const { t } = useI18n()
*/
const VIEW_OPTIONS_KEY = 'wiki.fileman.viewOptions'
/**
* What the folder tree is told is selected while the Recycle Bin is open: an id no folder can have, so
* that no row in it -- the root included, which is what a null would highlight -- claims to be the
* place being listed.
*/
const RECYCLE_BIN_SELECTION = 'recycle-bin'
/**
* The remembered view options, each taken only if it is still a value this component understands.
*
@ -637,6 +802,9 @@ function storedViewOptions() {
...(typeof stored.isCompact === 'boolean' ? { isCompact: stored.isCompact } : {}),
...(typeof stored.shouldShowFolders === 'boolean'
? { shouldShowFolders: stored.shouldShowFolders }
: {}),
...(typeof stored.shouldListFoldersFirst === 'boolean'
? { shouldListFoldersFirst: stored.shouldListFoldersFirst }
: {})
}
}
@ -666,11 +834,23 @@ const state = reactive({
displayMode: 'title',
isCompact: false,
shouldShowFolders: true,
/** Only consulted while `shouldShowFolders` is on: with no folders, there is nothing to put first. */
shouldListFoldersFirst: false,
isUploading: false,
shouldCancelUpload: false,
uploadPercentage: 0,
fileList: [],
fileListLoading: false
fileListLoading: false,
/**
* Whether the Recycle Bin is what the centre pane lists, in place of the folder. The folder stays
* selected underneath, listing and all, so leaving the bin for it again costs no fetch.
*/
isRecycleBin: false,
binLoading: false,
/** The deleted pages this reader may recover, as `GET …/pages/deleted` answered, newest first. */
binItems: [],
/** Keyed by the deletion's version id, which is what every action on a bin entry works from. */
binSelectedId: null
})
// -> Over the defaults just above, which is what the view falls back to on a first visit
@ -681,12 +861,12 @@ Object.assign(state, storedViewOptions())
the editor's insert flow, which can be dismissed in ways that never reach a teardown here.
*/
watch(
() => [state.displayMode, state.isCompact, state.shouldShowFolders],
([displayMode, isCompact, shouldShowFolders]) => {
() => [state.displayMode, state.isCompact, state.shouldShowFolders, state.shouldListFoldersFirst],
([displayMode, isCompact, shouldShowFolders, shouldListFoldersFirst]) => {
try {
globalThis.localStorage?.setItem(
VIEW_OPTIONS_KEY,
JSON.stringify({ displayMode, isCompact, shouldShowFolders })
JSON.stringify({ displayMode, isCompact, shouldShowFolders, shouldListFoldersFirst })
)
} catch {
// -> Full, or storage denied. Not worth a word to the reader: the options still work, they
@ -772,6 +952,18 @@ const filteredFiles = computed(() => {
}
})
/**
* The order the listing is drawn in: folders ahead of everything else when List Folders First is on,
* and otherwise exactly as it came. The sort is stable, so the listing's own order -- or a search's
* ranking -- still holds within each of the two groups.
*/
function listingOrder(a, b) {
if (!state.shouldShowFolders || !state.shouldListFoldersFirst) {
return 0
}
return Number(b.type === 'folder') - Number(a.type === 'folder')
}
const files = computed(() => {
return filteredFiles.value
.filter((f) => {
@ -781,6 +973,7 @@ const files = computed(() => {
}
return true
})
.toSorted(listingOrder)
.map((f) => {
switch (f.type) {
case 'folder': {
@ -816,6 +1009,37 @@ const files = computed(() => {
})
})
/** The Recycle Bin, narrowed by the same search field the folder listing uses. */
const deletedPages = computed(() => {
if (!state.search) {
return state.binItems
}
const fuse = new Fuse(state.binItems, { keys: ['title', 'path'] })
return fuse.search(state.search).map((n) => n.item)
})
const selectedDeletedPage = computed(
() => state.binItems.find((item) => item.versionId === state.binSelectedId) ?? null
)
/** The details pane for a bin entry, in the same rows a live page gets. */
const deletedPageDetails = computed(() => {
const item = selectedDeletedPage.value
if (!item) {
return []
}
return [
{ label: t('fileman.detailsTitle'), value: item.title },
{ label: t('fileman.detailsPath'), value: `/${item.path}` },
{ label: t('fileman.detailsPageType'), value: t(`fileman.${item.editor}PageType`) },
{ label: t('fileman.detailsDeletedAt'), value: formatDateTime(item.deletedAt) },
// -> Left out once the account is gone, rather than shown as an empty row
...(item.deletedBy?.name
? [{ label: t('fileman.detailsDeletedBy'), value: item.deletedBy.name }]
: [])
]
})
/**
* Whether this listing entry is an image, which is what decides both the thumbnail beside it and
* whether View opens it in place rather than handing it to the browser.
@ -931,6 +1155,58 @@ function close() {
siteStore.overlay = null
}
/**
* List a folder picked in the tree -- which is also how the Recycle Bin is left.
*
* Picking the folder that was already selected under the bin changes nothing the watcher would see,
* and needs nothing fetched either: its listing was left as it was when the bin opened.
*/
function openFolder(folderId) {
state.isRecycleBin = false
state.currentFolderId = folderId
}
function openRecycleBin() {
// -> The same as picking a folder, while the tree is a panel: what was asked for is underneath it
state.treeOpen = false
if (state.isRecycleBin) {
return
}
state.isRecycleBin = true
state.binSelectedId = null
loadRecycleBin()
}
async function loadRecycleBin() {
state.binLoading = true
try {
state.binItems = await API_CLIENT.get(`sites/${siteStore.id}/pages/deleted`, {
searchParams: { locale: state.locale }
}).json()
if (!state.binItems.some((item) => item.versionId === state.binSelectedId)) {
state.binSelectedId = null
}
} catch (err) {
state.binItems = []
notify({
type: 'negative',
message: t('fileman.recycleBinLoadFailed'),
caption: apiErrorMessage(err, 'An unexpected error occured.')
})
} finally {
state.binLoading = false
}
}
/** The toolbar's Refresh, which reloads whichever of the two the centre pane is showing. */
function refreshListing() {
if (state.isRecycleBin) {
loadRecycleBin()
} else {
reloadFolder(state.currentFolderId)
}
}
function formatDateTime(value) {
if (!value) {
return ''
@ -1104,6 +1380,11 @@ async function switchLocale(locale) {
// -> The tree marks which folders it has fetched children for, by id. None of them are in the tree
// being entered, so the marks would only ever be wrong about it
treeComp.value?.resetLoaded()
// -> The bin is listed per locale too, so it follows the picker like the folder listing does
if (state.isRecycleBin) {
state.binSelectedId = null
loadRecycleBin()
}
await loadTree({ initLoad: true })
}
@ -1283,6 +1564,8 @@ function findFolderIdByPath(path) {
*/
async function goToFolder({ id, folderPath, fileName, locale }) {
const path = folderPath ? `${folderPath}/${fileName}` : fileName
// -> Where the reader is being taken is a folder, so the bin, if it was open, is left for it
state.isRecycleBin = false
// -> Set before anything is fetched: every request below asks for one locale's tree
state.locale = locale
state.treeNodes = {}
@ -1461,6 +1744,72 @@ function delPage(pageId, pageName) {
})
}
// --------------------------------------
// RECYCLE BIN METHODS
// --------------------------------------
/** The kind of page a bin entry was, for a page with no icon of its own. */
function deletedPageTypeIcon(item) {
return fileTypes[item.editor]?.icon ?? fileTypes.page.icon
}
/**
* The deletion's version in full -- source included, and where the page was -- which the bin's list
* does not carry: a screenful of deleted pages has no business carrying a screenful of their content.
*/
function fetchDeletedVersion(item) {
return API_CLIENT.get(`sites/${siteStore.id}/versions/${item.versionId}`).json()
}
/**
* Read the page as it was when it went, in the version view -- the one screen that draws a page out
* of a version, and where it can also be restored or branched off from.
*/
function viewDeletedPage(item) {
router.push(`/_version/${item.versionId}`)
close()
}
async function downloadDeletedPage(item) {
try {
await saveVersionSource(await fetchDeletedVersion(item))
} catch (err) {
notify({
type: 'negative',
message: t('history.downloadFailed'),
caption: apiErrorMessage(err, 'An unexpected error occured.')
})
}
}
async function restoreDeletedItem(item) {
let version
try {
version = await fetchDeletedVersion(item)
} catch (err) {
notify({
type: 'negative',
message: t('fileman.restoreFailed'),
caption: apiErrorMessage(err, 'An unexpected error occured.')
})
return
}
const restored = await restoreDeletedPage(version)
if (!restored) {
return
}
await loadRecycleBin()
if (restored !== 'stale') {
/*
The page is back in the tree, possibly with folders that were not there a moment ago -- its own
folder was deleted with it, or the reader put it somewhere new. The folders loaded so far are
marked stale and the branch under the bin is fetched again, ancestors and roots included.
*/
treeComp.value?.resetLoaded()
await loadTree({ parentId: state.currentFolderId, initLoad: true })
}
}
// --------------------------------------
// ASSET METHODS
// --------------------------------------
@ -2070,6 +2419,56 @@ $fileman-hdr-wrap-max: 899.98px;
}
}
/*
The Recycle Bin, pinned under the tree. Drawn as one of the tree's own rows -- the same padding,
the same full-width band on hover and while it is open -- with a rule above it, since it is not a
place IN the tree and should not read as its last folder.
*/
&-bin {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 10px 12px;
font: inherit;
text-align: left;
color: inherit;
cursor: pointer;
transition: background-color 0.4s ease;
@at-root .body--light & {
border-top: 1px solid rgba(0, 0, 0, 0.08);
}
@at-root .body--dark & {
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
&:hover,
&:focus-visible,
&.is-active {
@at-root .body--light & {
background-color: rgba(0, 0, 0, 0.05);
}
@at-root .body--dark & {
background-color: rgba(255, 255, 255, 0.1);
}
}
&-label {
flex: 1 1 auto;
}
}
&-toolbar-title {
display: flex;
align-items: center;
font-weight: 500;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&-path {
@at-root .body--light & {
background-color: $blue-grey-1 !important;

@ -345,7 +345,8 @@ const ACTION_STYLES = {
created: { icon: 'la:plus', dot: 'bg-positive' },
updated: { icon: 'la:pen', dot: 'bg-blue-7' },
moved: { icon: 'la:share', dot: 'bg-warning' },
deleted: { icon: 'la:trash', dot: 'bg-negative' }
deleted: { icon: 'la:trash', dot: 'bg-negative' },
restored: { icon: 'la:undo', dot: 'bg-teal' }
}
const ACTION_FALLBACK = { icon: 'la:circle', dot: 'bg-grey-7' }

@ -107,6 +107,7 @@
@click="emit(`branch`)" />
<!-- -> The same orange every page header gives the action that changes the page -->
<w-btn
v-if="canRestore"
class="acrylic-btn ml-2"
flat
icon="la:undo"
@ -134,10 +135,8 @@
primary as TEXT on a dark page, but as a FILL behind white it measures 2.4:1 where plain
`primary` gives 4.6:1. A fill carries its own contrast, so one colour serves both themes.
Rendered only with a path to go to. In practice there is always one, since the endpoint behind
this view refuses a version whose page has been deleted (page rules need a page to be checked
against), but a button whose target is empty would navigate to the site root and quietly look
like it had worked.
Rendered only with a path to go to, which a page in the recycle bin does not have: a button
whose target is empty would navigate to the site root and quietly look like it had worked.
-->
<w-btn
class="ml-2"
@ -185,6 +184,11 @@ defineProps({
livePath: {
type: String,
default: ''
},
/** Whether Restore is offered -- see `canRestore` in `PageVersion.vue`. */
canRestore: {
type: Boolean,
default: true
}
})

@ -293,6 +293,10 @@ const header = computed(() => {
case 'renamePage': {
return { icon: 'img:/_assets/icons/fluent-rename.svg', title: 'pageRenameDialog.title' }
}
// -> Out of the recycle bin, to somewhere other than where it was: that path has been taken
case 'restorePage': {
return { icon: 'img:/_assets/icons/fluent-save-as.svg', title: 'fileman.restorePageTo' }
}
case 'renameAsset': {
return { icon: 'img:/_assets/icons/fluent-rename.svg', title: 'fileman.assetRenameMove' }
}
@ -691,7 +695,8 @@ onMounted(async () => {
state.typesToFetch = ['folder', 'page']
break
}
case 'renamePage': {
case 'renamePage':
case 'restorePage': {
state.typesToFetch = ['folder', 'page']
state.pathDirty = true
break

@ -0,0 +1,176 @@
import { defineAsyncComponent } from 'vue'
import { useI18n } from 'vue-i18n'
import { confirm, dialog } from '@/composables/dialog'
import { loading } from '@/composables/loading'
import { notify } from '@/composables/notify'
import { apiErrorMessage } from '@/helpers/apiError'
import { renderVersionSource } from '@/helpers/pageVersions'
import { useEditorStore } from '@/stores/editor'
import { useSiteStore } from '@/stores/site'
/**
* Bringing a page back out of the recycle bin.
*
* Shared by the two places that offer it -- the file manager's Recycle Bin, and the version view a
* bin entry opens in -- because the flow is the same from both and has more to it than a request: the
* page's HTML has to be rendered here first (a version records the source and never the render), and
* a path that has been taken since the page went has to become a question about where else to put it.
*
* Call it during `setup()`, like any composable: it reads the i18n catalogue and the stores.
*
* @returns {{ restoreDeletedPage: (version: object) => Promise<object | null> }}
*/
export function useDeletedPages() {
const { t } = useI18n()
const editorStore = useEditorStore()
const siteStore = useSiteStore()
async function renderFor(version, pagePath) {
// -> Configured per site, and the configuration arrives with the editor configs
if (!editorStore.configIsLoaded) {
await editorStore.fetchConfigs()
}
return renderVersionSource(version, {
markdownConfig: editorStore.editors.markdown,
asciidocConfig: editorStore.editors.asciidoc,
pagePath
})
}
function deletedOn(version) {
return version.versionDate
? Temporal.Instant.from(version.versionDate).toLocaleString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit'
})
: ''
}
/**
* Ask where the page should go instead, in the same tree picker a duplicate or a move uses.
*
* @returns {Promise<{ path: string, title: string, locale: string } | null>} Null when dismissed.
*/
function pickDestination(version, path, locale) {
return new Promise((resolve) => {
dialog({
component: defineAsyncComponent(() => import('@/components/TreeBrowserDialog.vue')),
componentProps: {
mode: 'restorePage',
itemId: version.pageId,
itemTitle: version.title,
itemFileName: path,
locale
}
})
.onOk(resolve)
.onCancel(() => resolve(null))
})
}
/**
* One attempt at the request, somewhere in particular.
*
* @returns {Promise<{ page?: object, conflict?: string }>} `conflict` is the server's error name for
* the two refusals the caller has an answer to; anything else is thrown.
*/
async function attempt(version, { path, locale, title }) {
loading.show()
try {
const resp = await API_CLIENT.post(`sites/${siteStore.id}/pages/${version.pageId}/restore`, {
json: {
versionId: version.id,
// -> Rendered for where it is going: a relative link or image resolves against that path
render: await renderFor(version, path),
path,
locale,
...(title ? { title } : {})
}
}).json()
// -> The API client does not throw on 400, so a refused path comes back as a parsed error
if (resp?.ok === false || !resp?.page?.id) {
throw new Error(resp?.message || 'An unexpected error occured.')
}
return { page: resp.page }
} catch (err) {
if (['pageDuplicatePath', 'pageRestoreStale'].includes(err?.data?.error)) {
return { conflict: err.data.error }
}
throw err
} finally {
loading.hide()
}
}
/**
* Restore a deleted page: confirm, then put it back where it was -- or, when another page has taken
* that path since, wherever the reader picks instead.
*
* @param {object} version The version recording the deletion, WITH its `content`, as
* `GET /sites/:siteId/versions/:versionId` answers for a page in the bin: `pagePath` and
* `pageLocale` are where it was.
* @returns {Promise<object | null | 'stale'>} The restored page; null when the reader backed out or
* it failed (already reported); `stale` when the bin changed underneath, which the caller answers
* by reloading whatever it listed.
*/
function restoreDeletedPage(version) {
return new Promise((resolve) => {
confirm({
title: t('history.restore'),
message: [
t('history.restoreDeletedConfirm', {
path: version.pagePath,
date: deletedOn(version)
}),
t('history.restoreDeletedConfirmHint')
],
cancel: true,
okLabel: t('history.restore')
})
.onOk(async () => {
let target = { path: version.pagePath, locale: version.pageLocale }
try {
for (;;) {
const result = await attempt(version, target)
if (result.page) {
notify({ type: 'positive', message: t('fileman.restoreSuccess') })
resolve(result.page)
return
}
if (result.conflict === 'pageRestoreStale') {
notify({ type: 'warning', message: t('fileman.restoreStale') })
resolve('stale')
return
}
// -> The path is taken. Asked again for as long as the answer is a taken path too
notify({
type: 'warning',
message: t('fileman.restorePathTaken', { path: target.path })
})
const picked = await pickDestination(version, target.path, target.locale)
if (!picked) {
resolve(null)
return
}
target = picked
}
} catch (err) {
notify({
type: 'negative',
message: t('fileman.restoreFailed'),
caption: apiErrorMessage(err)
})
resolve(null)
}
})
.onCancel(() => resolve(null))
})
}
return { restoreDeletedPage }
}

@ -23,6 +23,13 @@
<!-- -> Monospaced and selectable: it is an identifier, and the reason to show one in full is
so it can be read off and quoted -->
<span class="text-caption font-robotomono select-all truncate">{{ state.version.id }}</span>
<!-- -> Said outright, since everything else on this screen looks the same either way: the
page this is a version of is in the recycle bin, and there is no live page behind it -->
<span
v-if="state.version.pageIsDeleted"
class="text-caption shrink-0 ml-2 px-2 rounded bg-negative text-white">
{{ t('history.deletedPage') }}
</span>
</div>
<!--
Off on a phone, as the page view's date is: on a 390px screen it takes a whole line of its own
@ -40,6 +47,7 @@
:title="state.version.title"
:description="versionDescription"
:live-path="livePath"
:can-restore="canRestore"
@download="downloadVersion"
@restore="restoreVersion"
@branch="branchFrom" />
@ -152,6 +160,7 @@ import { computed, defineAsyncComponent, nextTick, reactive, ref, watch } from '
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useDeletedPages } from '@/composables/deletedPages'
import { useMeta } from '@/composables/meta'
import { useMinWidth } from '@/composables/screen'
import { confirm, dialog } from '@/composables/dialog'
@ -184,6 +193,10 @@ const route = useRoute()
const { t } = useI18n()
// COMPOSABLES
const { restoreDeletedPage } = useDeletedPages()
// DATA
const state = reactive({
@ -252,12 +265,23 @@ const tocDepth = computed(() => state.version?.meta?.config?.tocDepth ?? { min:
*/
const livePath = computed(() => {
const path = state.version?.pagePath
if (!path) {
// -> A page in the recycle bin has no live page: `pagePath` is then where it WAS
if (!path || state.version.pageIsDeleted) {
return ''
}
return `${siteStore.localeUrlPrefix(state.version.pageLocale)}/${path}`
})
/**
* Whether Restore is offered. Always for a live page, where it puts this version's source back on it.
* For a page in the recycle bin, only on the version recording its deletion: restoring brings the
* page back from that snapshot, so offering it on an older version would restore something other
* than what is on screen.
*/
const canRestore = computed(
() => !state.version?.pageIsDeleted || state.version.action === 'deleted'
)
/*
Whether there is a contents section to draw, rather than whether the page asked for one: a version
with no headings, or whose depth settings leave nothing to list, would otherwise show "Contents" over
@ -396,11 +420,21 @@ async function downloadVersion() {
* text. Nothing is lost either way -- this is an ordinary edit, so it becomes a version of its own
* with the current state recorded in it.
*/
function restoreVersion() {
async function restoreVersion() {
const version = state.version
if (!version) {
return
}
// -> Out of the recycle bin rather than onto a live page: there is no page to PATCH
if (version.pageIsDeleted) {
const restored = await restoreDeletedPage(version)
if (restored === 'stale') {
await loadVersion(version.id)
} else if (restored) {
router.push(`${siteStore.localeUrlPrefix(restored.locale)}/${restored.path}`)
}
return
}
confirm({
title: t('history.restore'),
message: [

Loading…
Cancel
Save