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) 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. * 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 * PAGE HISTORY
*/ */
@ -1483,7 +1699,7 @@ async function routes(app: FastifyInstance) {
schema: { schema: {
summary: 'Get a page version by its ID alone', summary: 'Get a page version by its ID alone',
description: 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'], tags: ['Pages'],
params: { params: {
type: 'object', type: 'object',
@ -1512,6 +1728,34 @@ async function routes(app: FastifyInstance) {
if (!version) { if (!version) {
return reply.notFound('This version does not exist.') 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. 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 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 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. 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: 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.' '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: { pagePath: {
type: 'string', type: 'string',
description: description:

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

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

@ -1,5 +1,5 @@
import { isEqual } from 'es-toolkit/predicate' 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 type { SQL } from 'drizzle-orm'
import { import {
pageHistory as pageHistoryTable, pageHistory as pageHistoryTable,
@ -10,11 +10,12 @@ import {
/** /**
* The kinds of change a history row records. * 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, * `created` and `deleted` are the two ends of a page's life, and `restored` is a deleted page coming
* which is worth telling apart from an ordinary edit because it is what breaks links; `updated` is * back out of the recycle bin under its own id; `moved` is a change of path or title, which is worth
* everything else, content and metadata alike. * 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] export type PageHistoryAction = (typeof pageHistoryActions)[number]
@ -115,6 +116,36 @@ export type PageHistoryEntry = {
author: PageHistoryAuthor 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. */ /** A version in full, source included. */
export type PageHistoryVersion = PageHistoryEntry & { export type PageHistoryVersion = PageHistoryEntry & {
content: string content: string
@ -177,8 +208,8 @@ function toVersion(row: any): PageHistoryVersion {
* Page history model * Page history model
* *
* Records a version of a page every time one changes, and reads those versions back for the history * 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 * view — which lists them and diffs any two against each other — and for the recycle bin, which is
* that was deleted, are still to come. * nothing more than the pages whose last version is their deletion.
*/ */
class PageHistory { class PageHistory {
/** /**
@ -342,6 +373,172 @@ class PageHistory {
return row ? { ...toVersion(row), pageId: row.pageId } : null 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. * Drop every version older than a timeframe, across every site.
* *

@ -373,6 +373,24 @@ export interface PageInput {
reasonForChange?: string 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. */ /** Who is saving, and what they are allowed to put in a page. */
/** One row of the admin dashboard's recently-edited panel. */ /** One row of the admin dashboard's recently-edited panel. */
export interface RecentPage { export interface RecentPage {
@ -2307,6 +2325,208 @@ class Pages {
return { page, versionId } 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. * 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 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. removing an icon; `check-icons.mjs` fails the build if this drifts.
282 icons. 283 icons.
*/ */
export const BUNDLED_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}, "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: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: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: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": {"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: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}, "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},

File diff suppressed because it is too large Load Diff

@ -345,7 +345,8 @@ const ACTION_STYLES = {
created: { icon: 'la:plus', dot: 'bg-positive' }, created: { icon: 'la:plus', dot: 'bg-positive' },
updated: { icon: 'la:pen', dot: 'bg-blue-7' }, updated: { icon: 'la:pen', dot: 'bg-blue-7' },
moved: { icon: 'la:share', dot: 'bg-warning' }, 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' } const ACTION_FALLBACK = { icon: 'la:circle', dot: 'bg-grey-7' }

@ -107,6 +107,7 @@
@click="emit(`branch`)" /> @click="emit(`branch`)" />
<!-- -> The same orange every page header gives the action that changes the page --> <!-- -> The same orange every page header gives the action that changes the page -->
<w-btn <w-btn
v-if="canRestore"
class="acrylic-btn ml-2" class="acrylic-btn ml-2"
flat flat
icon="la:undo" 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 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. `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 Rendered only with a path to go to, which a page in the recycle bin does not have: a button
this view refuses a version whose page has been deleted (page rules need a page to be checked whose target is empty would navigate to the site root and quietly look like it had worked.
against), but a button whose target is empty would navigate to the site root and quietly look
like it had worked.
--> -->
<w-btn <w-btn
class="ml-2" class="ml-2"
@ -185,6 +184,11 @@ defineProps({
livePath: { livePath: {
type: String, type: String,
default: '' default: ''
},
/** Whether Restore is offered -- see `canRestore` in `PageVersion.vue`. */
canRestore: {
type: Boolean,
default: true
} }
}) })

@ -293,6 +293,10 @@ const header = computed(() => {
case 'renamePage': { case 'renamePage': {
return { icon: 'img:/_assets/icons/fluent-rename.svg', title: 'pageRenameDialog.title' } 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': { case 'renameAsset': {
return { icon: 'img:/_assets/icons/fluent-rename.svg', title: 'fileman.assetRenameMove' } return { icon: 'img:/_assets/icons/fluent-rename.svg', title: 'fileman.assetRenameMove' }
} }
@ -691,7 +695,8 @@ onMounted(async () => {
state.typesToFetch = ['folder', 'page'] state.typesToFetch = ['folder', 'page']
break break
} }
case 'renamePage': { case 'renamePage':
case 'restorePage': {
state.typesToFetch = ['folder', 'page'] state.typesToFetch = ['folder', 'page']
state.pathDirty = true state.pathDirty = true
break 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 <!-- -> Monospaced and selectable: it is an identifier, and the reason to show one in full is
so it can be read off and quoted --> so it can be read off and quoted -->
<span class="text-caption font-robotomono select-all truncate">{{ state.version.id }}</span> <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> </div>
<!-- <!--
Off on a phone, as the page view's date is: on a 390px screen it takes a whole line of its own 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" :title="state.version.title"
:description="versionDescription" :description="versionDescription"
:live-path="livePath" :live-path="livePath"
:can-restore="canRestore"
@download="downloadVersion" @download="downloadVersion"
@restore="restoreVersion" @restore="restoreVersion"
@branch="branchFrom" /> @branch="branchFrom" />
@ -152,6 +160,7 @@ import { computed, defineAsyncComponent, nextTick, reactive, ref, watch } from '
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useDeletedPages } from '@/composables/deletedPages'
import { useMeta } from '@/composables/meta' import { useMeta } from '@/composables/meta'
import { useMinWidth } from '@/composables/screen' import { useMinWidth } from '@/composables/screen'
import { confirm, dialog } from '@/composables/dialog' import { confirm, dialog } from '@/composables/dialog'
@ -184,6 +193,10 @@ const route = useRoute()
const { t } = useI18n() const { t } = useI18n()
// COMPOSABLES
const { restoreDeletedPage } = useDeletedPages()
// DATA // DATA
const state = reactive({ const state = reactive({
@ -252,12 +265,23 @@ const tocDepth = computed(() => state.version?.meta?.config?.tocDepth ?? { min:
*/ */
const livePath = computed(() => { const livePath = computed(() => {
const path = state.version?.pagePath 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 ''
} }
return `${siteStore.localeUrlPrefix(state.version.pageLocale)}/${path}` 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 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 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 * 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. * with the current state recorded in it.
*/ */
function restoreVersion() { async function restoreVersion() {
const version = state.version const version = state.version
if (!version) { if (!version) {
return 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({ confirm({
title: t('history.restore'), title: t('history.restore'),
message: [ message: [

Loading…
Cancel
Save