mirror of
https://github.com/requarks/wiki.git
synced 2026-10-12 05:54:49 -05:00
feat: recycle bin + list folders first option
This commit is contained in:
13 files changed
+1553
-240
No files matched your search
+246
-2
@@ -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},
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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: [
|
||||
|
||||
Reference in new issue
Block a user